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
|
// MARK: - Theme
|
||||||
|
|
||||||
enum Theme {
|
enum Theme {
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ final class SettingsViewModel {
|
|||||||
selectedTheme = .teal
|
selectedTheme = .teal
|
||||||
selectedSports = Set(Sport.supported)
|
selectedSports = Set(Sport.supported)
|
||||||
maxDrivingHoursPerDay = 8
|
maxDrivingHoursPerDay = 8
|
||||||
|
AppearanceManager.shared.currentMode = .system
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Persistence
|
// MARK: - Persistence
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ struct SettingsView: View {
|
|||||||
// Subscription
|
// Subscription
|
||||||
subscriptionSection
|
subscriptionSection
|
||||||
|
|
||||||
|
// Appearance Mode (Light/Dark/System)
|
||||||
|
appearanceSection
|
||||||
|
|
||||||
// Theme Selection
|
// Theme Selection
|
||||||
themeSection
|
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
|
// MARK: - Theme Section
|
||||||
|
|
||||||
private var themeSection: some View {
|
private var themeSection: some View {
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ struct BootstrappedContentView: View {
|
|||||||
@State private var hasCompletedInitialSync = false
|
@State private var hasCompletedInitialSync = false
|
||||||
@State private var showOnboardingPaywall = false
|
@State private var showOnboardingPaywall = false
|
||||||
@State private var deepLinkHandler = DeepLinkHandler.shared
|
@State private var deepLinkHandler = DeepLinkHandler.shared
|
||||||
|
@State private var appearanceManager = AppearanceManager.shared
|
||||||
|
|
||||||
private var shouldShowOnboardingPaywall: Bool {
|
private var shouldShowOnboardingPaywall: Bool {
|
||||||
!UserDefaults.standard.bool(forKey: "hasSeenOnboardingPaywall") && !StoreManager.shared.isPro
|
!UserDefaults.standard.bool(forKey: "hasSeenOnboardingPaywall") && !StoreManager.shared.isPro
|
||||||
@@ -142,6 +143,7 @@ struct BootstrappedContentView: View {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.preferredColorScheme(appearanceManager.currentMode.colorScheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
Reference in New Issue
Block a user