Applies verified fixes from deep audit (concurrency, performance, security, accessibility), standardizes CRUD form buttons to Add/Save pattern, removes .drawingGroup() that broke search bar TextFields, and converts vulnerable .sheet(isPresented:) + if-let patterns to safe presentation to prevent blank white modals. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
180 lines
5.9 KiB
Swift
180 lines
5.9 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - Design System
|
|
// Modern, sleek design system for Casera with Light and Dark mode support
|
|
|
|
// MARK: - Colors
|
|
|
|
extension Color {
|
|
// MARK: - Dynamic Theme Resolution
|
|
|
|
/// Shared App Group defaults for reading the active theme.
|
|
/// Thread-safe: UserDefaults is safe to read from any thread/actor.
|
|
private static let _themeDefaults: UserDefaults = {
|
|
UserDefaults(suiteName: "group.com.tt.casera.CaseraDev") ?? .standard
|
|
}()
|
|
|
|
private static func themed(_ name: String) -> Color {
|
|
// Read theme directly from shared UserDefaults instead of going through
|
|
// @MainActor-isolated ThemeManager.shared. This is safe to call from any
|
|
// actor context (including widget timeline providers and background threads).
|
|
let theme = _themeDefaults.string(forKey: "selectedTheme") ?? ThemeID.bright.rawValue
|
|
return Color("\(theme)/\(name)", bundle: nil)
|
|
}
|
|
|
|
// MARK: - Semantic Colors (Use These in UI)
|
|
// These dynamically resolve based on the active theme stored in App Group UserDefaults.
|
|
// Safe to call from any actor context (main app, widget extensions, background threads).
|
|
static var appPrimary: Color { themed("Primary") }
|
|
static var appSecondary: Color { themed("Secondary") }
|
|
static var appAccent: Color { themed("Accent") }
|
|
static var appBackgroundPrimary: Color { themed("BackgroundPrimary") }
|
|
static var appBackgroundSecondary: Color { themed("BackgroundSecondary") }
|
|
static var appError: Color { themed("Error") }
|
|
static var appTextPrimary: Color { themed("TextPrimary") }
|
|
static var appTextSecondary: Color { themed("TextSecondary") }
|
|
static var appTextOnPrimary: Color { themed("TextOnPrimary") }
|
|
|
|
// MARK: - Hex Support
|
|
init?(hex: String) {
|
|
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
|
var int: UInt64 = 0
|
|
Scanner(string: hex).scanHexInt64(&int)
|
|
let a, r, g, b: UInt64
|
|
switch hex.count {
|
|
case 3: // RGB (12-bit)
|
|
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
|
case 6: // RGB (24-bit)
|
|
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
|
case 8: // ARGB (32-bit)
|
|
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
self.init(
|
|
.sRGB,
|
|
red: Double(r) / 255,
|
|
green: Double(g) / 255,
|
|
blue: Double(b) / 255,
|
|
opacity: Double(a) / 255
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Spacing
|
|
|
|
struct AppSpacing {
|
|
static let xxs: CGFloat = 4
|
|
static let xs: CGFloat = 8
|
|
static let sm: CGFloat = 12
|
|
static let md: CGFloat = 16
|
|
static let lg: CGFloat = 24
|
|
static let xl: CGFloat = 32
|
|
static let xxl: CGFloat = 48
|
|
static let xxxl: CGFloat = 64
|
|
}
|
|
|
|
struct AppRadius {
|
|
static let xs: CGFloat = 4
|
|
static let sm: CGFloat = 8
|
|
static let md: CGFloat = 12
|
|
static let lg: CGFloat = 16
|
|
static let xl: CGFloat = 20
|
|
static let xxl: CGFloat = 24
|
|
static let full: CGFloat = 9999
|
|
}
|
|
|
|
struct AppShadow {
|
|
static let sm = Shadow(color: .black.opacity(0.05), radius: 2, y: 1)
|
|
static let md = Shadow(color: .black.opacity(0.1), radius: 4, y: 2)
|
|
static let lg = Shadow(color: .black.opacity(0.1), radius: 8, y: 4)
|
|
static let xl = Shadow(color: .black.opacity(0.15), radius: 16, y: 8)
|
|
|
|
struct Shadow {
|
|
let color: Color
|
|
let radius: CGFloat
|
|
let x: CGFloat
|
|
let y: CGFloat
|
|
|
|
init(color: Color, radius: CGFloat, x: CGFloat = 0, y: CGFloat) {
|
|
self.color = color
|
|
self.radius = radius
|
|
self.x = x
|
|
self.y = y
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - View Modifiers
|
|
|
|
struct CardStyle: ViewModifier {
|
|
var shadow: AppShadow.Shadow = AppShadow.md
|
|
var padding: CGFloat = AppSpacing.md
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.background(Color.appBackgroundSecondary)
|
|
.cornerRadius(AppRadius.lg)
|
|
.shadow(color: shadow.color, radius: shadow.radius, x: shadow.x, y: shadow.y)
|
|
}
|
|
}
|
|
|
|
struct PrimaryButtonStyle: ButtonStyle {
|
|
var isLoading: Bool = false
|
|
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.font(.headline)
|
|
.foregroundColor(.appTextOnPrimary)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 56)
|
|
.background(
|
|
configuration.isPressed ? Color.appPrimary.opacity(0.8) : Color.appPrimary
|
|
)
|
|
.cornerRadius(AppRadius.md)
|
|
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
|
|
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
|
|
}
|
|
}
|
|
|
|
struct SecondaryButtonStyle: ButtonStyle {
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.font(.headline)
|
|
.foregroundColor(.appPrimary)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 56)
|
|
.background(Color.appPrimary.opacity(0.1))
|
|
.cornerRadius(AppRadius.md)
|
|
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
|
|
.animation(.easeInOut(duration: 0.1), value: configuration.isPressed)
|
|
}
|
|
}
|
|
|
|
struct TextFieldStyle: ViewModifier {
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.padding(AppSpacing.md)
|
|
.background(Color.appBackgroundSecondary)
|
|
.cornerRadius(AppRadius.md)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: AppRadius.md)
|
|
.stroke(Color.appTextSecondary.opacity(0.3), lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - View Extensions
|
|
|
|
extension View {
|
|
func cardStyle(shadow: AppShadow.Shadow = AppShadow.md) -> some View {
|
|
modifier(CardStyle(shadow: shadow))
|
|
}
|
|
|
|
func textFieldStyle() -> some View {
|
|
modifier(TextFieldStyle())
|
|
}
|
|
}
|
|
|