feat(settings): add dark/light/system appearance mode toggle

- Add AppearanceMode enum with system, light, and dark options
- Add AppearanceManager singleton to persist user preference
- Add appearance section in SettingsView with icon and description
- Apply preferredColorScheme at app root for immediate effect
- Include appearance mode in reset to defaults

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-14 13:08:38 -06:00
parent 3d4952e5ff
commit f7f1bbd87a
4 changed files with 116 additions and 0 deletions

View File

@@ -69,6 +69,65 @@ final class ThemeManager {
}
}
// 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 {

View File

@@ -72,6 +72,7 @@ final class SettingsViewModel {
selectedTheme = .teal
selectedSports = Set(Sport.supported)
maxDrivingHoursPerDay = 8
AppearanceManager.shared.currentMode = .system
}
// MARK: - Persistence

View File

@@ -17,6 +17,9 @@ struct SettingsView: View {
// Subscription
subscriptionSection
// Appearance Mode (Light/Dark/System)
appearanceSection
// Theme Selection
themeSection
@@ -55,6 +58,57 @@ struct SettingsView: View {
}
}
// MARK: - Appearance Section
private var appearanceSection: some View {
Section {
ForEach(AppearanceMode.allCases) { mode in
Button {
withAnimation(.easeInOut(duration: 0.2)) {
AppearanceManager.shared.currentMode = mode
}
} label: {
HStack(spacing: 12) {
// Icon
ZStack {
Circle()
.fill(Theme.warmOrange.opacity(0.15))
.frame(width: 32, height: 32)
Image(systemName: mode.iconName)
.font(.system(size: 16))
.foregroundStyle(Theme.warmOrange)
}
VStack(alignment: .leading, spacing: 2) {
Text(mode.displayName)
.font(.body)
.foregroundStyle(.primary)
Text(mode.description)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if AppearanceManager.shared.currentMode == mode {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.warmOrange)
.font(.title3)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
} header: {
Text("Appearance")
} footer: {
Text("Choose light, dark, or follow your device settings.")
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
// MARK: - Theme Section
private var themeSection: some View {

View File

@@ -82,6 +82,7 @@ struct BootstrappedContentView: View {
@State private var hasCompletedInitialSync = false
@State private var showOnboardingPaywall = false
@State private var deepLinkHandler = DeepLinkHandler.shared
@State private var appearanceManager = AppearanceManager.shared
private var shouldShowOnboardingPaywall: Bool {
!UserDefaults.standard.bool(forKey: "hasSeenOnboardingPaywall") && !StoreManager.shared.isPro
@@ -142,6 +143,7 @@ struct BootstrappedContentView: View {
break
}
}
.preferredColorScheme(appearanceManager.currentMode.colorScheme)
}
@MainActor