Files
Sportstime/SportsTime/Core/Theme/Theme.swift
Trey t d63d311cab feat: add WCAG AA accessibility app-wide, fix CloudKit container config, remove debug logs
- Add VoiceOver labels, hints, and element grouping across all 60+ views
- Add Reduce Motion support (Theme.Animation.prefersReducedMotion) to all animations
- Replace fixed font sizes with semantic Dynamic Type styles
- Hide decorative elements from VoiceOver with .accessibilityHidden(true)
- Add .minimumHitTarget() modifier ensuring 44pt touch targets
- Add AccessibilityAnnouncer utility for VoiceOver announcements
- Improve color contrast values in Theme.swift for WCAG AA compliance
- Extract CloudKitContainerConfig for explicit container identity
- Remove PostHog debug console log from AnalyticsManager

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 09:27:23 -06:00

518 lines
16 KiB
Swift

//
// Theme.swift
// SportsTime
//
// Central design system for colors, typography, spacing, and animations.
//
import SwiftUI
// MARK: - App Theme Selection
enum AppTheme: String, CaseIterable, Identifiable {
case teal = "Teal"
case orbit = "Orbit"
case retro = "Retro"
case clutch = "Clutch"
case monochrome = "Monochrome"
case sunset = "Sunset"
case midnight = "Midnight"
var id: String { rawValue }
var displayName: String { rawValue }
var description: String {
switch self {
case .teal: return "Cool cyan and teal tones"
case .orbit: return "Bold navy and orange"
case .retro: return "Classic columbia blue"
case .clutch: return "Championship red and gold"
case .monochrome: return "Clean grayscale aesthetic"
case .sunset: return "Warm oranges and purples"
case .midnight: return "Deep blues and gold"
}
}
/// The alternate icon name in the asset catalog, or nil for the default (teal).
var alternateIconName: String? {
switch self {
case .teal: return nil
case .orbit: return "AppIcon-orbit"
case .retro: return "AppIcon-retro"
case .clutch: return "AppIcon-clutch"
case .monochrome: return "AppIcon-monochrome"
case .sunset: return "AppIcon-sunset"
case .midnight: return "AppIcon-midnight"
}
}
var previewColors: [Color] {
switch self {
case .teal: return [Color(hex: "4ECDC4"), Color(hex: "1A535C"), Color(hex: "FFE66D")]
case .orbit: return [Color(hex: "EB6E1F"), Color(hex: "002D62"), Color(hex: "FFFFFF")]
case .retro: return [Color(hex: "418FDE"), Color(hex: "C41E3A"), Color(hex: "FFFFFF")]
case .clutch: return [Color(hex: "CE1141"), Color(hex: "FDB927"), Color(hex: "041E42")]
case .monochrome: return [Color(hex: "808080"), Color(hex: "1A1A1A"), Color(hex: "FAFAFA")]
case .sunset: return [Color(hex: "F97316"), Color(hex: "7C3AED"), Color(hex: "EC4899")]
case .midnight: return [Color(hex: "3B82F6"), Color(hex: "1E3A5F"), Color(hex: "F59E0B")]
}
}
}
// MARK: - Theme Manager
@Observable
final class ThemeManager {
static let shared = ThemeManager()
var currentTheme: AppTheme {
didSet {
UserDefaults.standard.set(currentTheme.rawValue, forKey: "selectedTheme")
updateAppIcon(for: currentTheme)
}
}
private func updateAppIcon(for theme: AppTheme) {
let iconName = theme.alternateIconName
guard UIApplication.shared.alternateIconName != iconName else { return }
UIApplication.shared.setAlternateIconName(iconName)
}
private init() {
if let saved = UserDefaults.standard.string(forKey: "selectedTheme"),
let theme = AppTheme(rawValue: saved) {
self.currentTheme = theme
} else {
self.currentTheme = .teal
}
}
}
// MARK: - Appearance Mode
enum AppearanceMode: String, CaseIterable, Identifiable {
case system = "System"
case light = "Light"
case dark = "Dark"
var id: String { rawValue }
var displayName: String { rawValue }
var iconName: String {
switch self {
case .system: return "circle.lefthalf.filled"
case .light: return "sun.max.fill"
case .dark: return "moon.fill"
}
}
var description: String {
switch self {
case .system: return "Match device settings"
case .light: return "Always use light mode"
case .dark: return "Always use dark mode"
}
}
/// Returns the ColorScheme to apply, or nil for system default
var colorScheme: ColorScheme? {
switch self {
case .system: return nil
case .light: return .light
case .dark: return .dark
}
}
}
// MARK: - Appearance Manager
@Observable
final class AppearanceManager {
static let shared = AppearanceManager()
var currentMode: AppearanceMode {
didSet {
UserDefaults.standard.set(currentMode.rawValue, forKey: "appearanceMode")
}
}
private init() {
if let saved = UserDefaults.standard.string(forKey: "appearanceMode"),
let mode = AppearanceMode(rawValue: saved) {
self.currentMode = mode
} else {
self.currentMode = .system
}
}
}
// MARK: - Theme
enum Theme {
private static var current: AppTheme { ThemeManager.shared.currentTheme }
// MARK: - Primary Accent Color
static var warmOrange: Color {
switch current {
case .teal: return Color(hex: "4ECDC4")
case .orbit: return Color(hex: "EB6E1F")
case .retro: return Color(hex: "418FDE")
case .clutch: return Color(hex: "CE1141")
case .monochrome: return Color(hex: "808080")
case .sunset: return Color(hex: "F97316")
case .midnight: return Color(hex: "3B82F6")
}
}
static var warmOrangeGlow: Color {
warmOrange.opacity(0.7)
}
// MARK: - Secondary Accent Colors
static var routeGold: Color {
switch current {
case .teal: return Color(hex: "FFE66D")
case .orbit: return Color(hex: "002D62")
case .retro: return Color(hex: "C41E3A")
case .clutch: return Color(hex: "FDB927")
case .monochrome: return Color(hex: "A0A0A0")
case .sunset: return Color(hex: "7C3AED")
case .midnight: return Color(hex: "F59E0B")
}
}
static var routeAmber: Color {
switch current {
case .teal: return Color(hex: "FF6B6B")
case .orbit: return Color(hex: "EB6E1F")
case .retro: return Color(hex: "418FDE")
case .clutch: return Color(hex: "CE1141")
case .monochrome: return Color(hex: "505050")
case .sunset: return Color(hex: "EC4899")
case .midnight: return Color(hex: "60A5FA")
}
}
// MARK: - Sport Colors (constant across themes)
static let mlbRed = Color(hex: "E31937")
static let nbaOrange = Color(hex: "F58426")
static let nhlBlue = Color(hex: "003087")
static let nflBrown = Color(hex: "8B5A2B")
static let mlsGreen = Color(hex: "00A651")
static let wnbaPurple = Color(hex: "FF6F20") // WNBA orange
static let nwslTeal = Color(hex: "009688") // NWSL teal
// MARK: - Dark Mode Colors
static var darkBackground1: Color {
switch current {
case .teal: return Color(hex: "1A535C")
case .orbit: return Color(hex: "002D62")
case .retro: return Color(hex: "1A3A5C")
case .clutch: return Color(hex: "041E42")
case .monochrome: return Color(hex: "121212")
case .sunset: return Color(hex: "1F1033")
case .midnight: return Color(hex: "0F172A")
}
}
static var darkBackground2: Color {
switch current {
case .teal: return Color(hex: "143F46")
case .orbit: return Color(hex: "001A3A")
case .retro: return Color(hex: "0F2840")
case .clutch: return Color(hex: "020E1F")
case .monochrome: return Color(hex: "0A0A0A")
case .sunset: return Color(hex: "150A24")
case .midnight: return Color(hex: "020617")
}
}
static var darkCardBackground: Color {
switch current {
case .teal: return Color(hex: "1E5A64")
case .orbit: return Color(hex: "0A3A6E")
case .retro: return Color(hex: "234B6E")
case .clutch: return Color(hex: "0A2847")
case .monochrome: return Color(hex: "1C1C1C")
case .sunset: return Color(hex: "2D1B4E")
case .midnight: return Color(hex: "1E3A5F")
}
}
static var darkCardBackgroundLight: Color {
switch current {
case .teal: return Color(hex: "2A6B75")
case .orbit: return Color(hex: "154A7E")
case .retro: return Color(hex: "2E5A7E")
case .clutch: return Color(hex: "153557")
case .monochrome: return Color(hex: "2A2A2A")
case .sunset: return Color(hex: "3D2B5E")
case .midnight: return Color(hex: "2A4A6F")
}
}
static var darkSurfaceGlow: Color {
switch current {
case .teal: return Color(hex: "92AEAB")
case .orbit: return Color(hex: "708BAA")
case .retro: return Color(hex: "87A0BA")
case .clutch: return Color(hex: "6D8399")
case .monochrome: return Color(hex: "707070")
case .sunset: return Color(hex: "84719A")
case .midnight: return Color(hex: "7F95B0")
}
}
static var darkTextPrimary: Color {
switch current {
case .teal: return Color(hex: "F7FFF7")
case .orbit: return Color(hex: "FFFFFF")
case .retro: return Color(hex: "FFFFFF")
case .clutch: return Color(hex: "FFFFFF")
case .monochrome: return Color(hex: "FAFAFA")
case .sunset: return Color(hex: "FFF7ED")
case .midnight: return Color(hex: "F8FAFC")
}
}
static var darkTextSecondary: Color {
switch current {
case .teal: return Color(hex: "B8E8E4")
case .orbit: return Color(hex: "FFB380")
case .retro: return Color(hex: "A8C8E8")
case .clutch: return Color(hex: "FFD080")
case .monochrome: return Color(hex: "D0D0D0")
case .sunset: return Color(hex: "FED7AA")
case .midnight: return Color(hex: "93C5FD")
}
}
static var darkTextMuted: Color {
switch current {
case .teal: return Color(hex: "B4CFCC")
case .orbit: return Color(hex: "A0B1C3")
case .retro: return Color(hex: "A4BBCF")
case .clutch: return Color(hex: "A8B7C6")
case .monochrome: return Color(hex: "B0B0B0")
case .sunset: return Color(hex: "C9B5D3")
case .midnight: return Color(hex: "A3B3C8")
}
}
// MARK: - Light Mode Colors
static var lightBackground1: Color {
switch current {
case .teal: return Color(hex: "F7FFF7")
case .orbit: return Color(hex: "FFF8F5")
case .retro: return Color(hex: "F5F9FF")
case .clutch: return Color(hex: "FFFAF5")
case .monochrome: return Color(hex: "FAFAFA")
case .sunset: return Color(hex: "FFFBF5")
case .midnight: return Color(hex: "F8FAFC")
}
}
static var lightBackground2: Color {
switch current {
case .teal: return Color(hex: "E8F8F5")
case .orbit: return Color(hex: "FFF0E8")
case .retro: return Color(hex: "E8F0FF")
case .clutch: return Color(hex: "FFF0E8")
case .monochrome: return Color(hex: "F0F0F0")
case .sunset: return Color(hex: "FFF3E8")
case .midnight: return Color(hex: "EFF6FF")
}
}
static var lightCardBackground: Color { .white }
static var lightCardBackgroundElevated: Color { lightBackground1 }
static var lightSurfaceBorder: Color {
switch current {
case .teal: return Color(hex: "568E88")
case .orbit: return Color(hex: "5F86AE")
case .retro: return Color(hex: "5D90B8")
case .clutch: return Color(hex: "6E8194")
case .monochrome: return Color(hex: "7A7A7A")
case .sunset: return Color(hex: "B98474")
case .midnight: return Color(hex: "7789A3")
}
}
static var lightTextPrimary: Color {
switch current {
case .teal: return Color(hex: "1A535C")
case .orbit: return Color(hex: "002D62")
case .retro: return Color(hex: "1A3A5C")
case .clutch: return Color(hex: "041E42")
case .monochrome: return Color(hex: "1A1A1A")
case .sunset: return Color(hex: "431407")
case .midnight: return Color(hex: "1E3A5F")
}
}
static var lightTextSecondary: Color {
switch current {
case .teal: return Color(hex: "2A6B75")
case .orbit: return Color(hex: "1A4A7A")
case .retro: return Color(hex: "2A5A7C")
case .clutch: return Color(hex: "1A3A5A")
case .monochrome: return Color(hex: "404040")
case .sunset: return Color(hex: "7C2D12")
case .midnight: return Color(hex: "2A4A6F")
}
}
static var lightTextMuted: Color {
switch current {
case .teal: return Color(hex: "497F79")
case .orbit: return Color(hex: "537596")
case .retro: return Color(hex: "4A7A99")
case .clutch: return Color(hex: "5A6B7E")
case .monochrome: return Color(hex: "707070")
case .sunset: return Color(hex: "8A5A4A")
case .midnight: return Color(hex: "64748B")
}
}
// MARK: - Adaptive Gradients
static func backgroundGradient(_ colorScheme: ColorScheme) -> LinearGradient {
colorScheme == .dark
? LinearGradient(colors: [darkBackground1, darkBackground2], startPoint: .top, endPoint: .bottom)
: LinearGradient(colors: [lightBackground1, lightBackground2], startPoint: .top, endPoint: .bottom)
}
// MARK: - Adaptive Colors
static func cardBackground(_ colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? darkCardBackground : lightCardBackground
}
static func cardBackgroundElevated(_ colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? darkCardBackgroundLight : lightCardBackgroundElevated
}
static func textPrimary(_ colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? darkTextPrimary : lightTextPrimary
}
static func textSecondary(_ colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? darkTextSecondary : lightTextSecondary
}
static func textMuted(_ colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? darkTextMuted : lightTextMuted
}
static func surfaceGlow(_ colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? darkSurfaceGlow : lightSurfaceBorder
}
static func cardShadow(_ colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? Color.black.opacity(0.3) : Color.black.opacity(0.08)
}
// MARK: - Spacing
enum Spacing {
static let xxs: CGFloat = 4
static let xs: CGFloat = 8
static let sm: CGFloat = 12
static let md: CGFloat = 16
static let lg: CGFloat = 20
static let xl: CGFloat = 24
static let xxl: CGFloat = 32
}
// MARK: - Corner Radius
enum CornerRadius {
static let small: CGFloat = 8
static let medium: CGFloat = 12
static let large: CGFloat = 16
static let xlarge: CGFloat = 20
}
// MARK: - Animation
enum Animation {
static let springResponse: Double = 0.3
static let springDamping: Double = 0.7
static let staggerDelay: Double = 0.1
static let routeDrawDuration: Double = 2.0
static var spring: SwiftUI.Animation {
.spring(response: springResponse, dampingFraction: springDamping)
}
static var gentleSpring: SwiftUI.Animation {
.spring(response: 0.5, dampingFraction: 0.8)
}
/// Whether the system Reduce Motion preference is enabled.
static var prefersReducedMotion: Bool {
UIAccessibility.isReduceMotionEnabled
}
/// Performs a state change with animation, or instantly if Reduce Motion is enabled.
static func withMotion(_ animation: SwiftUI.Animation = spring, _ body: () -> Void) {
if prefersReducedMotion {
body()
} else {
withAnimation(animation) { body() }
}
}
}
}
// MARK: - Color Hex Extension
extension Color {
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:
(a, r, g, b) = (255, 0, 0, 0)
}
self.init(
.sRGB,
red: Double(r) / 255,
green: Double(g) / 255,
blue: Double(b) / 255,
opacity: Double(a) / 255
)
}
}
// MARK: - Environment Key for Theme
private struct ColorSchemeKey: EnvironmentKey {
static let defaultValue: ColorScheme = .light
}
extension EnvironmentValues {
var themeColorScheme: ColorScheme {
get { self[ColorSchemeKey.self] }
set { self[ColorSchemeKey.self] = newValue }
}
}