From d611110925d3246ed266e3004f43eeaa9e221db7 Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:32:07 +1000 Subject: [PATCH] feat(theme): introduce semantic color tokens, light/dark, and appearance preference - Adds AppearancePreference (system/light/dark) backed by AppStorage. - Refactors AppTheme to expose scheme-aware tokens: textPrimary/Secondary/Tertiary, cardBackground(for:), cardStroke(for:), backgroundGradient(for:), accentSurface(_:for:), shadowColor/Radius(for:). - Resolves the Classic accent from a generic .blue to a fixed RGB so all themes have deterministic colors. - Adds view modifiers: bfTextPrimary/Secondary/Tertiary and bfBackground. This is the foundation for migrating every screen to light/dark support and consistent text contrast across the five themes. Co-authored-by: opencode-agent --- Apps/iOS/BetterFitApp/AppTheme.swift | 355 ++++++++++++++++++++++++--- 1 file changed, 316 insertions(+), 39 deletions(-) diff --git a/Apps/iOS/BetterFitApp/AppTheme.swift b/Apps/iOS/BetterFitApp/AppTheme.swift index 100f020..3a18387 100644 --- a/Apps/iOS/BetterFitApp/AppTheme.swift +++ b/Apps/iOS/BetterFitApp/AppTheme.swift @@ -4,6 +4,54 @@ import SwiftUI import UIKit #endif +// MARK: - Appearance Preference + +enum AppearancePreference: String, CaseIterable, Identifiable { + case system + case light + case dark + + var id: String { rawValue } + + var displayName: String { + switch self { + case .system: return "System" + case .light: return "Light" + case .dark: return "Dark" + } + } + + var systemImage: String { + switch self { + case .system: return "circle.lefthalf.filled" + case .light: return "sun.max.fill" + case .dark: return "moon.fill" + } + } + + var resolvedColorScheme: ColorScheme? { + switch self { + case .system: return nil + case .light: return .light + case .dark: return .dark + } + } +} + +extension AppearancePreference { + static let storageKey = "betterfit.appearancePreference" + static let defaultPreference: AppearancePreference = .dark + + static func fromStorage(_ rawValue: String?) -> AppearancePreference { + guard let rawValue, let pref = AppearancePreference(rawValue: rawValue) else { + return defaultPreference + } + return pref + } +} + +// MARK: - AppTheme + enum AppTheme: String, CaseIterable, Identifiable { case bold case classic @@ -23,88 +71,257 @@ enum AppTheme: String, CaseIterable, Identifiable { } } + // MARK: Accent (same in light + dark) + var accent: Color { switch self { case .bold: return Color(red: 1.00, green: 0.84, blue: 0.00) - case .classic: return .blue + case .classic: return Color(red: 0.0, green: 0.48, blue: 1.00) case .midnight: return Color(red: 0.62, green: 0.45, blue: 1.0) - case .forest: return Color(red: 0.20, green: 0.72, blue: 0.47) + case .forest: return Color(red: 0.20, green: 0.78, blue: 0.47) case .sunset: return Color(red: 1.0, green: 0.45, blue: 0.34) } } - var backgroundGradient: LinearGradient { - let colors: [Color] + // MARK: Semantic Color Tokens (light + dark) + + /// Primary text — strongest contrast against the background. + func textPrimary(for scheme: ColorScheme) -> Color { switch self { case .bold: - colors = [Color.black, Color(red: 0.06, green: 0.06, blue: 0.06)] + return scheme == .dark + ? .white + : Color(red: 0.08, green: 0.08, blue: 0.08) case .classic: - colors = [Color(.systemBackground), Color(.secondarySystemBackground)] + return scheme == .dark + ? .white + : Color(red: 0.07, green: 0.07, blue: 0.07) case .midnight: - colors = [ - Color(red: 0.05, green: 0.06, blue: 0.10), - Color(red: 0.10, green: 0.07, blue: 0.18), - ] + return scheme == .dark + ? .white + : Color(red: 0.10, green: 0.10, blue: 0.20) case .forest: - colors = [ - Color(red: 0.04, green: 0.09, blue: 0.07), - Color(red: 0.06, green: 0.13, blue: 0.09), - ] + return scheme == .dark + ? .white + : Color(red: 0.05, green: 0.15, blue: 0.10) case .sunset: - colors = [ - Color(red: 0.10, green: 0.06, blue: 0.07), - Color(red: 0.16, green: 0.08, blue: 0.08), - ] + return scheme == .dark + ? .white + : Color(red: 0.18, green: 0.10, blue: 0.08) } + } - return LinearGradient( - colors: colors, - startPoint: .topLeading, - endPoint: .bottomTrailing - ) + /// Secondary text — labels, captions, supporting copy. + /// Uses a hand-picked contrast pair (not SwiftUI's .secondary) to guarantee readability. + func textSecondary(for scheme: ColorScheme) -> Color { + switch self { + case .bold: + return scheme == .dark + ? Color(white: 0.78) + : Color(red: 0.32, green: 0.32, blue: 0.32) + case .classic: + return scheme == .dark + ? Color(white: 0.75) + : Color(red: 0.30, green: 0.30, blue: 0.30) + case .midnight: + return scheme == .dark + ? Color(white: 0.80) + : Color(red: 0.32, green: 0.32, blue: 0.42) + case .forest: + return scheme == .dark + ? Color(white: 0.78) + : Color(red: 0.25, green: 0.38, blue: 0.30) + case .sunset: + return scheme == .dark + ? Color(white: 0.80) + : Color(red: 0.42, green: 0.30, blue: 0.28) + } } - var preferredColorScheme: ColorScheme? { + /// Tertiary text — hints, placeholders, dimmed metadata. + func textTertiary(for scheme: ColorScheme) -> Color { switch self { case .bold: - return .dark + return scheme == .dark + ? Color(white: 0.60) + : Color(red: 0.50, green: 0.50, blue: 0.50) case .classic: - return nil - case .midnight, .forest, .sunset: - return .dark + return scheme == .dark + ? Color(white: 0.58) + : Color(red: 0.48, green: 0.48, blue: 0.48) + case .midnight: + return scheme == .dark + ? Color(white: 0.62) + : Color(red: 0.50, green: 0.50, blue: 0.60) + case .forest: + return scheme == .dark + ? Color(white: 0.60) + : Color(red: 0.45, green: 0.55, blue: 0.48) + case .sunset: + return scheme == .dark + ? Color(white: 0.62) + : Color(red: 0.55, green: 0.45, blue: 0.42) } } - var cardBackground: AnyShapeStyle { + /// Surface/card background fill (semi-transparent so gradient bleeds through). + /// Returns a `Color` (not `ShapeStyle`) so call sites can chain `.opacity()`. + func cardBackground(for scheme: ColorScheme) -> Color { switch self { case .bold: - return AnyShapeStyle(Color.black.opacity(0.55)) + return scheme == .dark + ? Color.white.opacity(0.06) + : Color.white.opacity(0.45) case .classic: - return AnyShapeStyle(.thinMaterial) + return scheme == .dark + ? Color.white.opacity(0.05) + : Color.white.opacity(0.55) case .midnight, .forest, .sunset: - return AnyShapeStyle(.ultraThinMaterial) + return scheme == .dark + ? Color.white.opacity(0.06) + : Color.white.opacity(0.55) } } - var cardStroke: Color { + /// Card border / divider line. + func cardStroke(for scheme: ColorScheme) -> Color { switch self { case .bold: - return accent.opacity(0.35) + return scheme == .dark + ? Color.white.opacity(0.10) + : Color.black.opacity(0.10) case .classic: - return Color(.separator).opacity(0.35) + return scheme == .dark + ? Color.white.opacity(0.10) + : Color.black.opacity(0.08) case .midnight: - return Color.white.opacity(0.10) + return scheme == .dark + ? Color.white.opacity(0.10) + : Color.black.opacity(0.10) case .forest: - return Color.white.opacity(0.10) + return scheme == .dark + ? Color.white.opacity(0.10) + : Color.black.opacity(0.10) case .sunset: - return Color.white.opacity(0.10) + return scheme == .dark + ? Color.white.opacity(0.10) + : Color.black.opacity(0.10) } } + + /// Solid background (used as the base under gradients in some places). + func backgroundBase(for scheme: ColorScheme) -> Color { + switch self { + case .bold: + return scheme == .dark + ? Color(red: 0.04, green: 0.04, blue: 0.04) + : Color(red: 0.96, green: 0.96, blue: 0.94) + case .classic: + return scheme == .dark + ? Color.black + : Color(red: 0.98, green: 0.98, blue: 1.00) + case .midnight: + return scheme == .dark + ? Color(red: 0.05, green: 0.06, blue: 0.10) + : Color(red: 0.94, green: 0.94, blue: 0.98) + case .forest: + return scheme == .dark + ? Color(red: 0.04, green: 0.09, blue: 0.07) + : Color(red: 0.94, green: 0.97, blue: 0.94) + case .sunset: + return scheme == .dark + ? Color(red: 0.10, green: 0.06, blue: 0.07) + : Color(red: 0.98, green: 0.94, blue: 0.92) + } + } + + /// Page-level background gradient (resolved against the current color scheme). + func backgroundGradient(for scheme: ColorScheme) -> LinearGradient { + let colors: [Color] + switch self { + case .bold: + if scheme == .dark { + colors = [Color.black, Color(red: 0.06, green: 0.06, blue: 0.06)] + } else { + // Neutral warm white with a hint of yellow — keeps the Bold feel without overpowering text. + colors = [ + Color(red: 0.97, green: 0.96, blue: 0.93), + Color(red: 0.94, green: 0.93, blue: 0.89), + ] + } + case .classic: + if scheme == .dark { + colors = [Color(red: 0.07, green: 0.07, blue: 0.08), Color(red: 0.13, green: 0.13, blue: 0.15)] + } else { + colors = [Color(red: 0.98, green: 0.98, blue: 1.00), Color(red: 0.93, green: 0.95, blue: 0.99)] + } + case .midnight: + if scheme == .dark { + colors = [ + Color(red: 0.05, green: 0.06, blue: 0.10), + Color(red: 0.10, green: 0.07, blue: 0.18), + ] + } else { + colors = [ + Color(red: 0.95, green: 0.95, blue: 1.00), + Color(red: 0.90, green: 0.88, blue: 0.98), + ] + } + case .forest: + if scheme == .dark { + colors = [ + Color(red: 0.04, green: 0.09, blue: 0.07), + Color(red: 0.06, green: 0.13, blue: 0.09), + ] + } else { + colors = [ + Color(red: 0.94, green: 0.98, blue: 0.94), + Color(red: 0.85, green: 0.94, blue: 0.86), + ] + } + case .sunset: + if scheme == .dark { + colors = [ + Color(red: 0.10, green: 0.06, blue: 0.07), + Color(red: 0.16, green: 0.08, blue: 0.08), + ] + } else { + colors = [ + Color(red: 0.99, green: 0.95, blue: 0.92), + Color(red: 0.98, green: 0.88, blue: 0.84), + ] + } + } + return LinearGradient( + colors: colors, + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + /// Accent color tinted for use as a soft surface fill. + /// In light mode the opacity is boosted so the tint reads against the bright background. + func accentSurface(_ opacity: Double, for scheme: ColorScheme) -> Color { + let resolved = scheme == .dark ? opacity : min(0.85, opacity * 1.8 + 0.08) + return accent.opacity(resolved) + } + + /// Shadow + func shadowColor(for scheme: ColorScheme) -> Color { + scheme == .dark + ? Color.black.opacity(0.30) + : Color.black.opacity(0.12) + } + + func shadowRadius(for scheme: ColorScheme) -> CGFloat { + scheme == .dark ? 14 : 10 + } } +// MARK: - Storage + extension AppTheme { static let storageKey = "betterfit.appTheme" - static let defaultTheme: AppTheme = .bold static func fromStorage(_ rawValue: String?) -> AppTheme { @@ -115,6 +332,8 @@ extension AppTheme { } } +// MARK: - Typography + extension AppTheme { static let headingFontCandidates: [String] = [ "BBHHegarty-ExtraBold", @@ -155,6 +374,8 @@ extension AppTheme { } } +// MARK: - View Modifiers + extension View { func bfHeading(theme: AppTheme, size: CGFloat, relativeTo textStyle: Font.TextStyle = .headline) -> some View @@ -167,4 +388,60 @@ extension View { { font(theme.italicFont(size: size, relativeTo: textStyle)) } + + /// Primary text color (strongest contrast against the background). + func bfTextPrimary(theme: AppTheme) -> some View { + modifier(BFTextPrimaryModifier(theme: theme)) + } + + /// Secondary text color (labels, captions). + func bfTextSecondary(theme: AppTheme) -> some View { + modifier(BFTextSecondaryModifier(theme: theme)) + } + + /// Tertiary text color (hints, placeholders). + func bfTextTertiary(theme: AppTheme) -> some View { + modifier(BFTextTertiaryModifier(theme: theme)) + } + + /// Page-level background that resolves the theme gradient against the current color scheme. + func bfBackground(theme: AppTheme) -> some View { + modifier(BFBackgroundModifier(theme: theme)) + } +} + +private struct BFTextPrimaryModifier: ViewModifier { + let theme: AppTheme + @Environment(\.colorScheme) private var scheme + + func body(content: Content) -> some View { + content.foregroundStyle(theme.textPrimary(for: scheme)) + } +} + +private struct BFTextSecondaryModifier: ViewModifier { + let theme: AppTheme + @Environment(\.colorScheme) private var scheme + + func body(content: Content) -> some View { + content.foregroundStyle(theme.textSecondary(for: scheme)) + } +} + +private struct BFTextTertiaryModifier: ViewModifier { + let theme: AppTheme + @Environment(\.colorScheme) private var scheme + + func body(content: Content) -> some View { + content.foregroundStyle(theme.textTertiary(for: scheme)) + } +} + +private struct BFBackgroundModifier: ViewModifier { + let theme: AppTheme + @Environment(\.colorScheme) private var scheme + + func body(content: Content) -> some View { + content.background(theme.backgroundGradient(for: scheme).ignoresSafeArea()) + } }