Sfoglia il codice sorgente

Replace "Force Dark Mode" with dynamic Appearance setting (System/Light/Dark) (#505)

Co-authored-by: hmatax-tom <tom@hma.tax>
Peanut IO 6 mesi fa
parent
commit
e41a8f9727
30 ha cambiato i file con 345 aggiunte e 83 eliminazioni
  1. 6 4
      LoopFollow.xcodeproj/project.pbxproj
  2. 1 1
      LoopFollow/Alarm/AddAlarm/AddAlarmSheet.swift
  3. 1 1
      LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift
  4. 1 1
      LoopFollow/Alarm/AlarmListView.swift
  5. 1 1
      LoopFollow/Alarm/AlarmSettingsView.swift
  6. 1 1
      LoopFollow/Alarm/AlarmsContainerView.swift
  7. 1 1
      LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift
  8. 5 2
      LoopFollow/Controllers/Nightscout/DeviceStatus.swift
  9. 40 0
      LoopFollow/Helpers/AppearanceMode.swift
  10. 4 5
      LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift
  11. 1 1
      LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift
  12. 1 1
      LoopFollow/Log/LogView.swift
  13. 1 1
      LoopFollow/Nightscout/NightscoutSettingsView.swift
  14. 35 4
      LoopFollow/Remote/RemoteViewController.swift
  15. 1 1
      LoopFollow/Remote/Settings/RemoteSettingsView.swift
  16. 1 1
      LoopFollow/Settings/AdvancedSettingsView.swift
  17. 1 1
      LoopFollow/Settings/CalendarSettingsView.swift
  18. 1 1
      LoopFollow/Settings/ContactSettingsView.swift
  19. 1 1
      LoopFollow/Settings/DexcomSettingsView.swift
  20. 7 3
      LoopFollow/Settings/GeneralSettingsView.swift
  21. 1 1
      LoopFollow/Settings/GraphSettingsView.swift
  22. 1 1
      LoopFollow/Settings/TabCustomizationModal.swift
  23. 31 0
      LoopFollow/Snoozer/SnoozerViewController.swift
  24. 3 2
      LoopFollow/Storage/Storage+Migrate.swift
  25. 1 1
      LoopFollow/Storage/Storage.swift
  26. 33 1
      LoopFollow/ViewControllers/AlarmViewController.swift
  27. 55 14
      LoopFollow/ViewControllers/MainViewController.swift
  28. 46 24
      LoopFollow/ViewControllers/MoreMenuViewController.swift
  29. 29 3
      LoopFollow/ViewControllers/NightScoutViewController.swift
  30. 34 4
      LoopFollow/ViewControllers/SettingsViewController.swift

+ 6 - 4
LoopFollow.xcodeproj/project.pbxproj

@@ -34,7 +34,6 @@
 		6589CC712E9E814F00BB18FE /* AlarmSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */; };
 		6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */; };
 		65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; };
-		65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; };
 		65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; };
 		DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; };
 		DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; };
@@ -71,6 +70,7 @@
 		DD16AF112C997B4600FB655A /* LoadingButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD16AF102C997B4600FB655A /* LoadingButtonView.swift */; };
 		DD1D52B92E1EB5DC00432050 /* TabPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52B82E1EB5DC00432050 /* TabPosition.swift */; };
 		DD1D52BB2E1EB60B00432050 /* MoreMenuViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52BA2E1EB60B00432050 /* MoreMenuViewController.swift */; };
+		DD1D52C02E4C100000000001 /* AppearanceMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1D52BF2E4C100000000001 /* AppearanceMode.swift */; };
 		DD2C2E4F2D3B8AF1006413A5 /* NightscoutSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */; };
 		DD2C2E512D3B8B0C006413A5 /* NightscoutSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */; };
 		DD485F142E454B2600CE8CBF /* SecureMessenger.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD485F132E454B2600CE8CBF /* SecureMessenger.swift */; };
@@ -434,7 +434,6 @@
 		6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmSelectionView.swift; sourceTree = "<group>"; };
 		6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMigrationManager.swift; sourceTree = "<group>"; };
 		65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = "<group>"; };
-		65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTokenValidationView.swift; sourceTree = "<group>"; };
 		65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeButtonHandler.swift; sourceTree = "<group>"; };
 		A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = "<group>"; };
@@ -472,6 +471,7 @@
 		DD16AF102C997B4600FB655A /* LoadingButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingButtonView.swift; sourceTree = "<group>"; };
 		DD1D52B82E1EB5DC00432050 /* TabPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPosition.swift; sourceTree = "<group>"; };
 		DD1D52BA2E1EB60B00432050 /* MoreMenuViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoreMenuViewController.swift; sourceTree = "<group>"; };
+		DD1D52BF2E4C100000000001 /* AppearanceMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceMode.swift; sourceTree = "<group>"; };
 		DD2C2E4E2D3B8AEC006413A5 /* NightscoutSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsView.swift; sourceTree = "<group>"; };
 		DD2C2E502D3B8B0B006413A5 /* NightscoutSettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutSettingsViewModel.swift; sourceTree = "<group>"; };
 		DD485F132E454B2600CE8CBF /* SecureMessenger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureMessenger.swift; sourceTree = "<group>"; };
@@ -1538,6 +1538,7 @@
 				DD026E582EA2C8A200A39CB5 /* InsulinPrecisionManager.swift */,
 				656F8C112E49F3780008DC1D /* QRCodeGenerator.swift */,
 				DD4A407D2E6AFEE6007B318B /* AuthService.swift */,
+				DD1D52BF2E4C100000000001 /* AppearanceMode.swift */,
 				DD1D52B82E1EB5DC00432050 /* TabPosition.swift */,
 				DD83164B2DE4DB3A004467AA /* BinaryFloatingPoint+localized.swift */,
 				DD4AFB3A2DB55CB600BB593F /* TimeOfDay.swift */,
@@ -2144,6 +2145,7 @@
 				DD493AE52ACF2383009A6922 /* Treatments.swift in Sources */,
 				DD7F4C112DD51ED900D449E9 /* TempTargetStartAlarmEditor.swift in Sources */,
 				DD1D52B92E1EB5DC00432050 /* TabPosition.swift in Sources */,
+				DD1D52C02E4C100000000001 /* AppearanceMode.swift in Sources */,
 				DD50C7552D0862770057AE6F /* ContactImageUpdater.swift in Sources */,
 				654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */,
 				6541341C2E1DC28000BDBE08 /* DateExtensions.swift in Sources */,
@@ -2211,7 +2213,7 @@
 				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
 				CODE_SIGN_STYLE = Automatic;
 				CURRENT_PROJECT_VERSION = 1;
-				DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)";
+				DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; 
 				ENABLE_USER_SCRIPT_SANDBOXING = YES;
 				GCC_C_LANGUAGE_STANDARD = gnu17;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -2238,7 +2240,7 @@
 				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
 				CODE_SIGN_STYLE = Automatic;
 				CURRENT_PROJECT_VERSION = 1;
-				DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)";
+				DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; 
 				ENABLE_USER_SCRIPT_SANDBOXING = YES;
 				GCC_C_LANGUAGE_STANDARD = gnu17;
 				GENERATE_INFOPLIST_FILE = YES;

+ 1 - 1
LoopFollow/Alarm/AddAlarm/AddAlarmSheet.swift

@@ -40,6 +40,6 @@ struct AddAlarmSheet: View {
                 }
             }
         }
-        .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
     }
 }

+ 1 - 1
LoopFollow/Alarm/AlarmEditing/AlarmEditor.swift

@@ -41,7 +41,7 @@ struct AlarmEditor: View {
                 }
                 .navigationTitle(alarm.type.rawValue)
         }
-        .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
     }
 
     @ViewBuilder

+ 1 - 1
LoopFollow/Alarm/AlarmListView.swift

@@ -90,7 +90,7 @@ struct AlarmListView: View {
                 Button { sheetInfo = .picker } label: { Image(systemName: "plus") }
             }
         }
-        .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
     }
 
     // MARK: - Views

+ 1 - 1
LoopFollow/Alarm/AlarmSettingsView.swift

@@ -194,7 +194,7 @@ struct AlarmSettingsView: View {
                 }
             }
         }
-        .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .navigationBarTitle("Alarm Settings", displayMode: .inline)
     }
 }

+ 1 - 1
LoopFollow/Alarm/AlarmsContainerView.swift

@@ -17,6 +17,6 @@ struct AlarmsContainerView: View {
                     }
                 }
         }
-        .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
     }
 }

+ 1 - 1
LoopFollow/BackgroundRefresh/BackgroundRefreshSettingsView.swift

@@ -27,7 +27,7 @@ struct BackgroundRefreshSettingsView: View {
                 stopTimer()
             }
         }
-        .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .navigationBarTitle("Background Refresh Settings", displayMode: .inline)
     }
 

+ 5 - 2
LoopFollow/Controllers/Nightscout/DeviceStatus.swift

@@ -64,10 +64,13 @@ extension MainViewController {
             LoopStatusLabel.textAlignment = .right
             LoopStatusLabel.font = UIFont.systemFont(ofSize: 17)
 
-            if Storage.shared.forceDarkMode.value {
+            switch Storage.shared.appearanceMode.value {
+            case .dark:
                 LoopStatusLabel.textColor = UIColor.white
-            } else {
+            case .light:
                 LoopStatusLabel.textColor = UIColor.black
+            case .system:
+                LoopStatusLabel.textColor = UIColor.label
             }
         }
     }

+ 40 - 0
LoopFollow/Helpers/AppearanceMode.swift

@@ -0,0 +1,40 @@
+// LoopFollow
+// AppearanceMode.swift
+
+import SwiftUI
+
+extension Notification.Name {
+    static let appearanceDidChange = Notification.Name("appearanceDidChange")
+}
+
+enum AppearanceMode: String, CaseIterable, Codable {
+    case system
+    case light
+    case dark
+
+    var displayName: String {
+        switch self {
+        case .system: return "System"
+        case .light: return "Light"
+        case .dark: return "Dark"
+        }
+    }
+
+    /// Returns the ColorScheme for SwiftUI's preferredColorScheme modifier
+    var colorScheme: ColorScheme? {
+        switch self {
+        case .system: return nil
+        case .light: return .light
+        case .dark: return .dark
+        }
+    }
+
+    /// Returns the UIUserInterfaceStyle for UIKit views
+    var userInterfaceStyle: UIUserInterfaceStyle {
+        switch self {
+        case .system: return .unspecified
+        case .light: return .light
+        case .dark: return .dark
+        }
+    }
+}

+ 4 - 5
LoopFollow/Helpers/Views/SimpleQRCodeScannerView.swift

@@ -16,11 +16,10 @@ struct SimpleQRCodeScannerView: UIViewControllerRepresentable {
 
         let navController = UINavigationController(rootViewController: scannerVC)
 
-        // Apply dark mode if needed
-        if Storage.shared.forceDarkMode.value {
-            scannerVC.overrideUserInterfaceStyle = .dark
-            navController.overrideUserInterfaceStyle = .dark
-        }
+        // Apply appearance mode
+        let style = Storage.shared.appearanceMode.value.userInterfaceStyle
+        scannerVC.overrideUserInterfaceStyle = style
+        navController.overrideUserInterfaceStyle = style
 
         return navController
     }

+ 1 - 1
LoopFollow/InfoDisplaySettings/InfoDisplaySettingsView.swift

@@ -42,7 +42,7 @@ struct InfoDisplaySettingsView: View {
                 NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
             }
         }
-        .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .navigationBarTitle("Information Display Settings", displayMode: .inline)
     }
 }

+ 1 - 1
LoopFollow/Log/LogView.swift

@@ -36,7 +36,7 @@ struct LogView: View {
                 viewModel.loadLogEntries()
             }
         }
-        .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .navigationBarTitle("Today's Logs", displayMode: .inline)
     }
 }

+ 1 - 1
LoopFollow/Nightscout/NightscoutSettingsView.swift

@@ -18,7 +18,7 @@ struct NightscoutSettingsView: View {
                 viewModel.dismiss()
             }
         }
-        .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .navigationBarTitle("Nightscout Settings", displayMode: .inline)
     }
 

+ 35 - 4
LoopFollow/Remote/RemoteViewController.swift

@@ -6,19 +6,42 @@ import SwiftUI
 import UIKit
 
 class RemoteViewController: UIViewController {
-    private var cancellable: AnyCancellable?
+    private var cancellables = Set<AnyCancellable>()
     private var hostingController: UIHostingController<AnyView>?
 
     override func viewDidLoad() {
         super.viewDidLoad()
 
-        cancellable = Storage.shared.device.$value
+        // Apply initial appearance
+        overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
+
+        Storage.shared.device.$value
             .removeDuplicates()
             .sink { [weak self] _ in
                 DispatchQueue.main.async {
                     self?.updateView()
                 }
             }
+            .store(in: &cancellables)
+
+        // Listen for appearance setting changes
+        Storage.shared.appearanceMode.$value
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] mode in
+                self?.overrideUserInterfaceStyle = mode.userInterfaceStyle
+                self?.hostingController?.overrideUserInterfaceStyle = mode.userInterfaceStyle
+            }
+            .store(in: &cancellables)
+
+        // Listen for system appearance changes (when in System mode)
+        NotificationCenter.default.publisher(for: .appearanceDidChange)
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] _ in
+                let style = Storage.shared.appearanceMode.value.userInterfaceStyle
+                self?.overrideUserInterfaceStyle = style
+                self?.hostingController?.overrideUserInterfaceStyle = style
+            }
+            .store(in: &cancellables)
     }
 
     private func updateView() {
@@ -89,7 +112,15 @@ class RemoteViewController: UIViewController {
         updateView()
     }
 
-    deinit {
-        cancellable?.cancel()
+    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+        super.traitCollectionDidChange(previousTraitCollection)
+
+        if Storage.shared.appearanceMode.value == .system,
+           previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle
+        {
+            let style = Storage.shared.appearanceMode.value.userInterfaceStyle
+            overrideUserInterfaceStyle = style
+            hostingController?.overrideUserInterfaceStyle = style
+        }
     }
 }

+ 1 - 1
LoopFollow/Remote/Settings/RemoteSettingsView.swift

@@ -373,7 +373,7 @@ struct RemoteSettingsView: View {
                 // The sheet will be shown automatically due to the binding
             }
         }
-        .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .navigationTitle("Remote Settings")
         .navigationBarTitleDisplayMode(.inline)
     }

+ 1 - 1
LoopFollow/Settings/AdvancedSettingsView.swift

@@ -27,7 +27,7 @@ struct AdvancedSettingsView: View {
                 }
             }
         }
-        .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .navigationBarTitle("Advanced Settings", displayMode: .inline)
     }
 }

+ 1 - 1
LoopFollow/Settings/CalendarSettingsView.swift

@@ -74,7 +74,7 @@ struct CalendarSettingsView: View {
                 await requestCalendarAccessAndLoad()
             }
         }
-        .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .navigationBarTitle("Calendar", displayMode: .inline)
     }
 

+ 1 - 1
LoopFollow/Settings/ContactSettingsView.swift

@@ -79,7 +79,7 @@ struct ContactSettingsView: View {
                 Alert(title: Text(alertTitle), message: Text(alertMessage), dismissButton: .default(Text("OK")))
             }
         }
-        .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .navigationBarTitle("Contact", displayMode: .inline)
     }
 

+ 1 - 1
LoopFollow/Settings/DexcomSettingsView.swift

@@ -37,7 +37,7 @@ struct DexcomSettingsView: View {
                 importSection
             }
         }
-        .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .navigationBarTitle("Dexcom Settings", displayMode: .inline)
     }
 

+ 7 - 3
LoopFollow/Settings/GeneralSettingsView.swift

@@ -6,7 +6,7 @@ import SwiftUI
 struct GeneralSettingsView: View {
     @ObservedObject var colorBGText = Storage.shared.colorBGText
     @ObservedObject var appBadge = Storage.shared.appBadge
-    @ObservedObject var forceDarkMode = Storage.shared.forceDarkMode
+    @ObservedObject var appearanceMode = Storage.shared.appearanceMode
     @ObservedObject var showStats = Storage.shared.showStats
     @ObservedObject var useIFCC = Storage.shared.useIFCC
     @ObservedObject var showSmallGraph = Storage.shared.showSmallGraph
@@ -36,7 +36,11 @@ struct GeneralSettingsView: View {
                 }
 
                 Section("Display") {
-                    Toggle("Force Dark Mode (restart app)", isOn: $forceDarkMode.value)
+                    Picker("Appearance", selection: $appearanceMode.value) {
+                        ForEach(AppearanceMode.allCases, id: \.self) { mode in
+                            Text(mode.displayName).tag(mode)
+                        }
+                    }
                     Toggle("Display Stats", isOn: $showStats.value)
                     Toggle("Use IFCC A1C", isOn: $useIFCC.value)
                     Toggle("Display Small Graph", isOn: $showSmallGraph.value)
@@ -115,7 +119,7 @@ struct GeneralSettingsView: View {
                 }
             }
         }
-        .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .navigationBarTitle("General Settings", displayMode: .inline)
     }
 }

+ 1 - 1
LoopFollow/Settings/GraphSettingsView.swift

@@ -132,7 +132,7 @@ struct GraphSettingsView: View {
                 }
             }
         }
-        .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .navigationBarTitle("Graph Settings", displayMode: .inline)
     }
 

+ 1 - 1
LoopFollow/Settings/TabCustomizationModal.swift

@@ -103,7 +103,7 @@ struct TabCustomizationModal: View {
                 }
             }
         }
-        .preferredColorScheme(Storage.shared.forceDarkMode.value ? .dark : nil)
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
     }
 
     private func checkForChanges() {

+ 31 - 0
LoopFollow/Snoozer/SnoozerViewController.swift

@@ -7,6 +7,7 @@ import UIKit
 
 class SnoozerViewController: UIViewController {
     private var hostingController: UIHostingController<SnoozerView>?
+    private var cancellables = Set<AnyCancellable>()
 
     @State private var snoozeMinutes = 15
 
@@ -18,6 +19,26 @@ class SnoozerViewController: UIViewController {
 
         let hosting = UIHostingController(rootView: snoozerView)
         hostingController = hosting
+
+        // Apply initial appearance
+        hosting.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
+
+        // Listen for appearance setting changes
+        Storage.shared.appearanceMode.$value
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] mode in
+                self?.hostingController?.overrideUserInterfaceStyle = mode.userInterfaceStyle
+            }
+            .store(in: &cancellables)
+
+        // Listen for system appearance changes (when in System mode)
+        NotificationCenter.default.publisher(for: .appearanceDidChange)
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] _ in
+                self?.hostingController?.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
+            }
+            .store(in: &cancellables)
+
         addChild(hosting)
         view.addSubview(hosting.view)
         hosting.view.translatesAutoresizingMaskIntoConstraints = false
@@ -31,4 +52,14 @@ class SnoozerViewController: UIViewController {
 
         hosting.didMove(toParent: self)
     }
+
+    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+        super.traitCollectionDidChange(previousTraitCollection)
+
+        if Storage.shared.appearanceMode.value == .system,
+           previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle
+        {
+            hostingController?.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
+        }
+    }
 }

+ 3 - 2
LoopFollow/Storage/Storage+Migrate.swift

@@ -59,7 +59,8 @@ extension Storage {
 
         let legacyForceDarkMode = UserDefaultsValue<Bool>(key: "forceDarkMode", default: true)
         if legacyForceDarkMode.exists {
-            Storage.shared.forceDarkMode.value = legacyForceDarkMode.value
+            // Migrate from Bool to AppearanceMode: true -> .dark, false -> .system
+            Storage.shared.appearanceMode.value = legacyForceDarkMode.value ? .dark : .system
             legacyForceDarkMode.setNil(key: "forceDarkMode")
         }
 
@@ -152,7 +153,7 @@ extension Storage {
         // ── General (done earlier, but safe to repeat) ──
         move(UserDefaultsValue<Bool>(key: "colorBGText", default: true), into: Storage.shared.colorBGText)
         move(UserDefaultsValue<Bool>(key: "appBadge", default: true), into: appBadge)
-        move(UserDefaultsValue<Bool>(key: "forceDarkMode", default: false), into: forceDarkMode)
+        // Note: forceDarkMode migration to appearanceMode is handled earlier in migrateGeneralSettings()
         move(UserDefaultsValue<Bool>(key: "showStats", default: true), into: showStats)
         move(UserDefaultsValue<Bool>(key: "useIFCC", default: false), into: useIFCC)
         move(UserDefaultsValue<Bool>(key: "showSmallGraph", default: true), into: showSmallGraph)

+ 1 - 1
LoopFollow/Storage/Storage.swift

@@ -60,7 +60,7 @@ class Storage {
     // General Settings [BEGIN]
     var appBadge = StorageValue<Bool>(key: "appBadge", defaultValue: true)
     var colorBGText = StorageValue<Bool>(key: "colorBGText", defaultValue: true)
-    var forceDarkMode = StorageValue<Bool>(key: "forceDarkMode", defaultValue: true)
+    var appearanceMode = StorageValue<AppearanceMode>(key: "appearanceMode", defaultValue: .system)
     var showStats = StorageValue<Bool>(key: "showStats", defaultValue: true)
     var useIFCC = StorageValue<Bool>(key: "useIFCC", defaultValue: false)
     var showSmallGraph = StorageValue<Bool>(key: "showSmallGraph", defaultValue: true)

+ 33 - 1
LoopFollow/ViewControllers/AlarmViewController.swift

@@ -1,16 +1,38 @@
 // LoopFollow
 // AlarmViewController.swift
 
+import Combine
 import SwiftUI
 import UIKit
 
 class AlarmViewController: UIViewController {
+    private var hostingController: UIHostingController<AlarmsContainerView>!
+    private var cancellables = Set<AnyCancellable>()
+
     override func viewDidLoad() {
         super.viewDidLoad()
 
         let alarmsView = AlarmsContainerView()
+        hostingController = UIHostingController(rootView: alarmsView)
+
+        // Apply initial appearance
+        hostingController.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
+
+        // Listen for appearance setting changes
+        Storage.shared.appearanceMode.$value
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] mode in
+                self?.hostingController.overrideUserInterfaceStyle = mode.userInterfaceStyle
+            }
+            .store(in: &cancellables)
 
-        let hostingController = UIHostingController(rootView: alarmsView)
+        // Listen for system appearance changes (when in System mode)
+        NotificationCenter.default.publisher(for: .appearanceDidChange)
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] _ in
+                self?.hostingController.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
+            }
+            .store(in: &cancellables)
 
         addChild(hostingController)
         view.addSubview(hostingController.view)
@@ -25,4 +47,14 @@ class AlarmViewController: UIViewController {
 
         hostingController.didMove(toParent: self)
     }
+
+    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+        super.traitCollectionDidChange(previousTraitCollection)
+
+        if Storage.shared.appearanceMode.value == .system,
+           previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle
+        {
+            hostingController.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
+        }
+    }
 }

+ 55 - 14
LoopFollow/ViewControllers/MainViewController.swift

@@ -173,10 +173,8 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         BGChart.delegate = self
         BGChartFull.delegate = self
 
-        if Storage.shared.forceDarkMode.value {
-            overrideUserInterfaceStyle = .dark
-            tabBarController?.overrideUserInterfaceStyle = .dark
-        }
+        // Apply initial appearance mode
+        updateAppearance(Storage.shared.appearanceMode.value)
 
         // Trigger foreground and background functions
         let notificationCenter = NotificationCenter.default
@@ -256,6 +254,14 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
             }
             .store(in: &cancellables)
 
+        // Update appearance when setting changes
+        Storage.shared.appearanceMode.$value
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] mode in
+                self?.updateAppearance(mode)
+            }
+            .store(in: &cancellables)
+
         Storage.shared.showStats.$value
             .receive(on: DispatchQueue.main)
             .sink { [weak self] _ in
@@ -895,6 +901,43 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         }
     }
 
+    func updateAppearance(_ mode: AppearanceMode) {
+        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+              let window = windowScene.windows.first else { return }
+
+        let style: UIUserInterfaceStyle
+        switch mode {
+        case .light:
+            style = .light
+        case .dark:
+            style = .dark
+        case .system:
+            // Use .unspecified to follow system
+            style = .unspecified
+        }
+
+        // Update this view controller
+        overrideUserInterfaceStyle = style
+
+        // Update the tab bar controller (affects all tabs)
+        tabBarController?.overrideUserInterfaceStyle = style
+
+        // Update the window (affects the entire app including modals)
+        window.overrideUserInterfaceStyle = style
+    }
+
+    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+        super.traitCollectionDidChange(previousTraitCollection)
+
+        // When system appearance changes and we're in "System" mode, notify all observers
+        if Storage.shared.appearanceMode.value == .system,
+           previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle
+        {
+            // Post notification so other view controllers can update if needed
+            NotificationCenter.default.post(name: .appearanceDidChange, object: nil)
+        }
+    }
+
     func bgDirectionGraphic(_ value: String) -> String {
         let // graphics:[String:String]=["Flat":"\u{2192}","DoubleUp":"\u{21C8}","SingleUp":"\u{2191}","FortyFiveUp":"\u{2197}\u{FE0E}","FortyFiveDown":"\u{2198}\u{FE0E}","SingleDown":"\u{2193}","DoubleDown":"\u{21CA}","None":"-","NOT COMPUTABLE":"-","RATE OUT OF RANGE":"-"]
             graphics: [String: String] = ["Flat": "→", "DoubleUp": "↑↑", "SingleUp": "↑", "FortyFiveUp": "↗", "FortyFiveDown": "↘︎", "SingleDown": "↓", "DoubleDown": "↓↓", "None": "-", "NONE": "-", "NOT COMPUTABLE": "-", "RATE OUT OF RANGE": "-", "": "-"]
@@ -1183,11 +1226,10 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         let hostingController = UIHostingController(rootView: nightscoutSettingsView)
         let navController = UINavigationController(rootViewController: hostingController)
 
-        // Apply dark mode if needed
-        if Storage.shared.forceDarkMode.value {
-            hostingController.overrideUserInterfaceStyle = .dark
-            navController.overrideUserInterfaceStyle = .dark
-        }
+        // Apply appearance mode
+        let style = Storage.shared.appearanceMode.value.userInterfaceStyle
+        hostingController.overrideUserInterfaceStyle = style
+        navController.overrideUserInterfaceStyle = style
 
         // Add a Done button
         hostingController.navigationItem.rightBarButtonItem = UIBarButtonItem(
@@ -1205,11 +1247,10 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
         let hostingController = UIHostingController(rootView: dexcomSettingsView)
         let navController = UINavigationController(rootViewController: hostingController)
 
-        // Apply dark mode if needed
-        if Storage.shared.forceDarkMode.value {
-            hostingController.overrideUserInterfaceStyle = .dark
-            navController.overrideUserInterfaceStyle = .dark
-        }
+        // Apply appearance mode
+        let style = Storage.shared.appearanceMode.value.userInterfaceStyle
+        hostingController.overrideUserInterfaceStyle = style
+        navController.overrideUserInterfaceStyle = style
 
         // Add a Done button
         hostingController.navigationItem.rightBarButtonItem = UIBarButtonItem(

+ 46 - 24
LoopFollow/ViewControllers/MoreMenuViewController.swift

@@ -1,11 +1,13 @@
 // LoopFollow
 // MoreMenuViewController.swift
 
+import Combine
 import SwiftUI
 import UIKit
 
 class MoreMenuViewController: UIViewController {
     private var tableView: UITableView!
+    private var cancellables = Set<AnyCancellable>()
 
     struct MenuItem {
         let title: String
@@ -21,10 +23,24 @@ class MoreMenuViewController: UIViewController {
         title = "More"
         view.backgroundColor = .systemBackground
 
-        // Apply dark mode if needed
-        if Storage.shared.forceDarkMode.value {
-            overrideUserInterfaceStyle = .dark
-        }
+        // Apply appearance mode
+        overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
+
+        // Listen for appearance setting changes
+        Storage.shared.appearanceMode.$value
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] mode in
+                self?.overrideUserInterfaceStyle = mode.userInterfaceStyle
+            }
+            .store(in: &cancellables)
+
+        // Listen for system appearance changes (when in System mode)
+        NotificationCenter.default.publisher(for: .appearanceDidChange)
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] _ in
+                self?.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
+            }
+            .store(in: &cancellables)
 
         setupTableView()
         updateMenuItems()
@@ -37,6 +53,16 @@ class MoreMenuViewController: UIViewController {
         Observable.shared.settingsPath.set(NavigationPath())
     }
 
+    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+        super.traitCollectionDidChange(previousTraitCollection)
+
+        if Storage.shared.appearanceMode.value == .system,
+           previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle
+        {
+            overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
+        }
+    }
+
     private func setupTableView() {
         tableView = UITableView(frame: view.bounds, style: .insetGrouped)
         tableView.translatesAutoresizingMaskIntoConstraints = false
@@ -102,11 +128,10 @@ class MoreMenuViewController: UIViewController {
         let settingsVC = UIHostingController(rootView: SettingsMenuView())
         let navController = UINavigationController(rootViewController: settingsVC)
 
-        // Apply dark mode if needed
-        if Storage.shared.forceDarkMode.value {
-            settingsVC.overrideUserInterfaceStyle = .dark
-            navController.overrideUserInterfaceStyle = .dark
-        }
+        // Apply appearance mode
+        let style = Storage.shared.appearanceMode.value.userInterfaceStyle
+        settingsVC.overrideUserInterfaceStyle = style
+        navController.overrideUserInterfaceStyle = style
 
         // Add a close button
         settingsVC.navigationItem.rightBarButtonItem = UIBarButtonItem(
@@ -124,11 +149,10 @@ class MoreMenuViewController: UIViewController {
         let alarmsVC = storyboard.instantiateViewController(withIdentifier: "AlarmViewController")
         let navController = UINavigationController(rootViewController: alarmsVC)
 
-        // Apply dark mode if needed
-        if Storage.shared.forceDarkMode.value {
-            alarmsVC.overrideUserInterfaceStyle = .dark
-            navController.overrideUserInterfaceStyle = .dark
-        }
+        // Apply appearance mode
+        let style = Storage.shared.appearanceMode.value.userInterfaceStyle
+        alarmsVC.overrideUserInterfaceStyle = style
+        navController.overrideUserInterfaceStyle = style
 
         // Add a close button
         alarmsVC.navigationItem.rightBarButtonItem = UIBarButtonItem(
@@ -146,11 +170,10 @@ class MoreMenuViewController: UIViewController {
         let remoteVC = storyboard.instantiateViewController(withIdentifier: "RemoteViewController")
         let navController = UINavigationController(rootViewController: remoteVC)
 
-        // Apply dark mode if needed
-        if Storage.shared.forceDarkMode.value {
-            remoteVC.overrideUserInterfaceStyle = .dark
-            navController.overrideUserInterfaceStyle = .dark
-        }
+        // Apply appearance mode
+        let style = Storage.shared.appearanceMode.value.userInterfaceStyle
+        remoteVC.overrideUserInterfaceStyle = style
+        navController.overrideUserInterfaceStyle = style
 
         // Add a close button
         remoteVC.navigationItem.rightBarButtonItem = UIBarButtonItem(
@@ -168,11 +191,10 @@ class MoreMenuViewController: UIViewController {
         let nightscoutVC = storyboard.instantiateViewController(withIdentifier: "NightscoutViewController")
         let navController = UINavigationController(rootViewController: nightscoutVC)
 
-        // Apply dark mode if needed
-        if Storage.shared.forceDarkMode.value {
-            nightscoutVC.overrideUserInterfaceStyle = .dark
-            navController.overrideUserInterfaceStyle = .dark
-        }
+        // Apply appearance mode
+        let style = Storage.shared.appearanceMode.value.userInterfaceStyle
+        nightscoutVC.overrideUserInterfaceStyle = style
+        navController.overrideUserInterfaceStyle = style
 
         // Add a close button
         nightscoutVC.navigationItem.rightBarButtonItem = UIBarButtonItem(

+ 29 - 3
LoopFollow/ViewControllers/NightScoutViewController.swift

@@ -1,17 +1,33 @@
 // LoopFollow
 // NightScoutViewController.swift
 
+import Combine
 import UIKit
 import WebKit
 
 class NightscoutViewController: UIViewController {
     @IBOutlet var webView: WKWebView!
+    private var cancellables = Set<AnyCancellable>()
 
     override func viewDidLoad() {
         super.viewDidLoad()
-        if Storage.shared.forceDarkMode.value {
-            overrideUserInterfaceStyle = .dark
-        }
+        overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
+
+        // Listen for appearance setting changes
+        Storage.shared.appearanceMode.$value
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] mode in
+                self?.overrideUserInterfaceStyle = mode.userInterfaceStyle
+            }
+            .store(in: &cancellables)
+
+        // Listen for system appearance changes (when in System mode)
+        NotificationCenter.default.publisher(for: .appearanceDidChange)
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] _ in
+                self?.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
+            }
+            .store(in: &cancellables)
 
         var url = Storage.shared.url.value
         let token = Storage.shared.token.value
@@ -41,6 +57,16 @@ class NightscoutViewController: UIViewController {
         sender.endRefreshing()
     }
 
+    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+        super.traitCollectionDidChange(previousTraitCollection)
+
+        if Storage.shared.appearanceMode.value == .system,
+           previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle
+        {
+            overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
+        }
+    }
+
     func clearWebCache() {
         let dataStore = WKWebsiteDataStore.default()
         let cacheTypes = Set([WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache])

+ 34 - 4
LoopFollow/ViewControllers/SettingsViewController.swift

@@ -1,6 +1,7 @@
 // LoopFollow
 // SettingsViewController.swift
 
+import Combine
 import SwiftUI
 import UIKit
 
@@ -8,6 +9,7 @@ final class SettingsViewController: UIViewController {
     // MARK: Stored properties
 
     private var host: UIHostingController<SettingsMenuView>!
+    private var cancellables = Set<AnyCancellable>()
 
     // MARK: Life-cycle
 
@@ -17,10 +19,24 @@ final class SettingsViewController: UIViewController {
         // Build SwiftUI menu
         host = UIHostingController(rootView: SettingsMenuView())
 
-        // Dark-mode override
-        if Storage.shared.forceDarkMode.value {
-            host.overrideUserInterfaceStyle = .dark
-        }
+        // Appearance mode override
+        host.overrideUserInterfaceStyle = Storage.shared.appearanceMode.value.userInterfaceStyle
+
+        // Listen for appearance setting changes
+        Storage.shared.appearanceMode.$value
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] mode in
+                self?.updateAppearance(mode)
+            }
+            .store(in: &cancellables)
+
+        // Listen for system appearance changes (when in System mode)
+        NotificationCenter.default.publisher(for: .appearanceDidChange)
+            .receive(on: DispatchQueue.main)
+            .sink { [weak self] _ in
+                self?.updateAppearance(Storage.shared.appearanceMode.value)
+            }
+            .store(in: &cancellables)
 
         // Embed
         addChild(host)
@@ -40,4 +56,18 @@ final class SettingsViewController: UIViewController {
 
         Observable.shared.settingsPath.set(NavigationPath())
     }
+
+    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
+        super.traitCollectionDidChange(previousTraitCollection)
+
+        if Storage.shared.appearanceMode.value == .system,
+           previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle
+        {
+            updateAppearance(.system)
+        }
+    }
+
+    private func updateAppearance(_ mode: AppearanceMode) {
+        host.overrideUserInterfaceStyle = mode.userInterfaceStyle
+    }
 }