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:
@@ -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 {
|
||||
|
||||
@@ -72,6 +72,7 @@ final class SettingsViewModel {
|
||||
selectedTheme = .teal
|
||||
selectedSports = Set(Sport.supported)
|
||||
maxDrivingHoursPerDay = 8
|
||||
AppearanceManager.shared.currentMode = .system
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user