Просмотр исходного кода

Units selection (#558)

* Refactor unit settings management and enhance metrics configuration

- Introduced UnitSettingsStore to centralize unit and metric settings management.
- Added new enums for glucose display units, time in range modes, glycemic metrics, and variability metrics.
- Updated StatsData to calculate and store coefficient of variation.
- Refactored Localizer to utilize UnitSettingsStore for unit conversions and formatting.
- Enhanced Nightscout and Dexcom settings views to support unit configuration and onboarding.
- Created UnitsConfigurationView for reusable unit and metric settings.
- Updated AggregatedStatsView and SimpleStatsViewModel to reflect new unit settings.
- Modified TIRView and its ViewModel to use UnitSettingsStore for thresholds and display.
- Removed legacy storage references for unit settings in favor of the new centralized approach.
- Added export functionality for new unit and metric settings in Nightscout settings.

* Add commit guidelines and best practices to README.md

* Fix navigation title for UnitsSettingsView to match consistency in SettingsMenuView

* Refactor absorption time handling in LoopAPNSCarbsView to use separate hour and minute states, enhancing clarity and usability

* Add polling of data after sucessful remote command

* Remove remote command polling (moved to separate PR #566)

* Replace deprecated NavigationLink(isActive:) with navigationDestination

* Remove carbs screen redesign (moved to separate PR)

* Remove README commit guidelines (moved to separate PR)

* Added custom range

* Remove list_prs.sh from repo

Accidentally committed utility script that belongs outside the project.

* Fix BGPicker not updating display when value changes or unit switches

Use @State for glucoseUnit, lowValue, and highValue so SwiftUI can
track changes and re-render. Add .id(glucoseUnit) on BGPickers to
force recreation when the unit changes, ensuring allValues and
formatting update correctly.

---------

Co-authored-by: Jonas Björkert <jonas@bjorkert.se>
Daniel Mini Johansson 2 месяцев назад
Родитель
Сommit
fff229544d

+ 8 - 0
LoopFollow.xcodeproj/project.pbxproj

@@ -59,6 +59,8 @@
 		6589CC6F2E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC572E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift */; };
 		6589CC712E9E814F00BB18FE /* AlarmSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC702E9E814F00BB18FE /* AlarmSelectionView.swift */; };
 		6589CC752E9EAFB700BB18FE /* SettingsMigrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6589CC742E9EAFB700BB18FE /* SettingsMigrationManager.swift */; };
+		65A100012F5AA00000AA1001 /* UnitsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65A100002F5AA00000AA1001 /* UnitsSettingsView.swift */; };
+		65A100032F5AA00000AA1002 /* UnitsConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65A100022F5AA00000AA1002 /* UnitsConfigurationView.swift */; };
 		65E153C32E4BB69100693A4F /* URLTokenValidationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E153C22E4BB69100693A4F /* URLTokenValidationView.swift */; };
 		65E8A2862E44B0300065037B /* VolumeButtonHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65E8A2852E44B0300065037B /* VolumeButtonHandler.swift */; };
 		66E3D12E66AA4534A144A54B /* BackgroundRefreshManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8CA8BE0B3D247408FE088B4 /* BackgroundRefreshManager.swift */; };
@@ -509,6 +511,8 @@
 		6589CC602E9E7D1600BB18FE /* TabCustomizationModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabCustomizationModal.swift; sourceTree = "<group>"; };
 		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>"; };
+		65A100002F5AA00000AA1001 /* UnitsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitsSettingsView.swift; sourceTree = "<group>"; };
+		65A100022F5AA00000AA1002 /* UnitsConfigurationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitsConfigurationView.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; };
@@ -970,6 +974,8 @@
 				6589CC5E2E9E7D1600BB18FE /* GraphSettingsView.swift */,
 				657F98172F043D8100F732BD /* HomeContentView.swift */,
 				6589CC5F2E9E7D1600BB18FE /* SettingsMenuView.swift */,
+				65A100002F5AA00000AA1001 /* UnitsSettingsView.swift */,
+				65A100022F5AA00000AA1002 /* UnitsConfigurationView.swift */,
 				6589CC602E9E7D1600BB18FE /* TabCustomizationModal.swift */,
 			);
 			path = Settings;
@@ -2251,6 +2257,8 @@
 				6589CC6C2E9E7D1600BB18FE /* GraphSettingsView.swift in Sources */,
 				6589CC6D2E9E7D1600BB18FE /* CalendarSettingsView.swift in Sources */,
 				6589CC6E2E9E7D1600BB18FE /* SettingsMenuView.swift in Sources */,
+				65A100012F5AA00000AA1001 /* UnitsSettingsView.swift in Sources */,
+				65A100032F5AA00000AA1002 /* UnitsConfigurationView.swift in Sources */,
 				657F98182F043D8100F732BD /* HomeContentView.swift in Sources */,
 				6589CC6F2E9E7D1600BB18FE /* AdvancedSettingsViewModel.swift in Sources */,
 				DD493ADF2ACF22BB009A6922 /* SAge.swift in Sources */,

+ 3 - 1
LoopFollow/Application/Base.lproj/Main.storyboard

@@ -223,7 +223,7 @@
                                                                     <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" alignment="center" translatesAutoresizingMaskIntoConstraints="NO" id="xGF-Pj-QE0">
                                                                         <rect key="frame" x="102.66666666666666" y="0.0" width="92.666666666666657" height="45"/>
                                                                         <subviews>
-                                                                            <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Est A1C:" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="WV0-Jy-FPs" userLabel="Est A1C:">
+                                                                            <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Est. A1C:" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="WV0-Jy-FPs" userLabel="Est. A1C:">
                                                                                 <rect key="frame" x="18.333333333333346" y="0.0" width="56.333333333333343" height="18"/>
                                                                                 <fontDescription key="fontDescription" type="system" pointSize="15"/>
                                                                                 <nil key="textColor"/>
@@ -306,11 +306,13 @@
                         <outlet property="smallGraphHeightConstraint" destination="qmO-ga-QWl" id="VsZ-zJ-LsJ"/>
                         <outlet property="statsAvgBG" destination="jpA-Nb-pU7" id="Uo8-a4-Aus"/>
                         <outlet property="statsEstA1C" destination="7Jx-XF-1vS" id="4RD-nm-JxO"/>
+                        <outlet property="statsEstA1CTitle" destination="WV0-Jy-FPs" id="WnU-h8-2hf"/>
                         <outlet property="statsHighPercent" destination="HON-rt-8pC" id="283-3S-PCR"/>
                         <outlet property="statsInRangePercent" destination="7mH-Np-j0L" id="vUp-Pv-Mva"/>
                         <outlet property="statsLowPercent" destination="TzL-hn-9qu" id="0QR-Mz-KJe"/>
                         <outlet property="statsPieChart" destination="Hhh-F1-s1p" id="Rhh-Up-Kr0"/>
                         <outlet property="statsStdDev" destination="wAI-Tp-784" id="BUZ-lS-JfA"/>
+                        <outlet property="statsStdDevTitle" destination="iXC-Mz-I09" id="QHf-Q8-J7B"/>
                         <outlet property="statsView" destination="ikj-at-auF" id="7AQ-VA-Pw2"/>
                     </connections>
                 </viewController>

+ 19 - 10
LoopFollow/Controllers/Graphs.swift

@@ -211,6 +211,10 @@ class TempTargetRenderer: LineChartRenderer {
 
 let ScaleXMax: Double = 150.0
 extension MainViewController {
+    private func graphRangeThresholds() -> (low: Double, high: Double) {
+        UnitSettingsStore.shared.effectiveThresholds()
+    }
+
     func updateChartRenderers() {
         let tempTargetDataIndex = GraphDataIndex.tempTarget.rawValue
         let smbDataIndex = GraphDataIndex.smb.rawValue
@@ -627,15 +631,17 @@ extension MainViewController {
         // Clear limit lines so they don't add multiples when changing the settings
         BGChart.rightAxis.removeAllLimitLines()
 
-        // Add lower red line based on low alert value
+        let thresholds = graphRangeThresholds()
+
+        // Add lower red line
         let ll = ChartLimitLine()
-        ll.limit = Storage.shared.lowLine.value
+        ll.limit = thresholds.low
         ll.lineColor = NSUIColor.systemRed.withAlphaComponent(0.5)
         BGChart.rightAxis.addLimitLine(ll)
 
-        // Add upper yellow line based on low alert value
+        // Add upper yellow line
         let ul = ChartLimitLine()
-        ul.limit = Storage.shared.highLine.value
+        ul.limit = thresholds.high
         ul.lineColor = NSUIColor.systemYellow.withAlphaComponent(0.5)
         BGChart.rightAxis.addLimitLine(ul)
 
@@ -786,15 +792,17 @@ extension MainViewController {
         // Clear limit lines so they don't add multiples when changing the settings
         BGChart.rightAxis.removeAllLimitLines()
 
-        // Add lower red line based on low alert value
+        let thresholds = graphRangeThresholds()
+
+        // Add lower red line
         let ll = ChartLimitLine()
-        ll.limit = Storage.shared.lowLine.value
+        ll.limit = thresholds.low
         ll.lineColor = NSUIColor.systemRed.withAlphaComponent(0.5)
         BGChart.rightAxis.addLimitLine(ll)
 
-        // Add upper yellow line based on low alert value
+        // Add upper yellow line
         let ul = ChartLimitLine()
-        ul.limit = Storage.shared.highLine.value
+        ul.limit = thresholds.high
         ul.lineColor = NSUIColor.systemYellow.withAlphaComponent(0.5)
         BGChart.rightAxis.addLimitLine(ul)
 
@@ -824,6 +832,7 @@ extension MainViewController {
         var colors = [NSUIColor]()
 
         topBG = Storage.shared.minBGScale.value
+        let thresholds = graphRangeThresholds()
         for i in 0 ..< entries.count {
             // Clamp the plotted y-value to the same bounds the header text uses
             // (HIGH/LOW), so the graph stays consistent with the main display.
@@ -836,9 +845,9 @@ extension MainViewController {
             mainChart.append(value)
             smallChart.append(value)
 
-            if Double(entries[i].sgv) >= Storage.shared.highLine.value {
+            if Double(entries[i].sgv) >= thresholds.high {
                 colors.append(NSUIColor.systemYellow)
-            } else if Double(entries[i].sgv) <= Storage.shared.lowLine.value {
+            } else if Double(entries[i].sgv) <= thresholds.low {
                 colors.append(NSUIColor.systemRed)
             } else {
                 colors.append(NSUIColor.systemGreen)

+ 15 - 2
LoopFollow/Controllers/MainViewController+updateStats.swift

@@ -26,12 +26,25 @@ extension MainViewController {
             statsInRangePercent.text = String(format: "%.1f%%", stats.percentRange)
             statsHighPercent.text = String(format: "%.1f%%", stats.percentHigh)
             statsAvgBG.text = Localizer.toDisplayUnits(String(format: "%.0f", stats.avgBG))
-            if Storage.shared.useIFCC.value {
+            statsEstA1CTitle.text = UnitSettingsStore.shared.glycemicMetricMode == .gmi ? "GMI:" : "Est. A1C:"
+
+            if UnitSettingsStore.shared.glycemicOutputUnit == .mmolMol {
                 statsEstA1C.text = String(format: "%.0f", stats.a1C)
             } else {
                 statsEstA1C.text = String(format: "%.1f", stats.a1C)
             }
-            statsStdDev.text = String(format: "%.2f", stats.stdDev)
+
+            if UnitSettingsStore.shared.variabilityMetricMode == .stdDeviation {
+                statsStdDevTitle.text = "Std Dev:"
+                if UnitSettingsStore.shared.glucoseUnit == .mgdL {
+                    statsStdDev.text = String(format: "%.0f", stats.stdDev)
+                } else {
+                    statsStdDev.text = String(format: "%.1f", stats.stdDev)
+                }
+            } else {
+                statsStdDevTitle.text = "CV:"
+                statsStdDev.text = String(format: "%.1f%%", stats.coefficientOfVariation)
+            }
 
             createStatsPie(pieData: stats.pie)
         }

+ 18 - 8
LoopFollow/Controllers/Stats.swift

@@ -14,6 +14,7 @@ class StatsData {
     var avgBG: Float
     var a1C: Float
     var stdDev: Float
+    var coefficientOfVariation: Float
     var bgDataCount: Int
     var pie: [DataStructs.pieData]
 
@@ -23,12 +24,17 @@ class StatsData {
         countHigh = 0
         totalGlucose = 0
         a1C = 0.0
+        coefficientOfVariation = 0.0
+
+        let thresholds = UnitSettingsStore.shared.effectiveThresholds()
+        let lowThreshold = thresholds.low
+        let highThreshold = thresholds.high
 
         for i in 0 ..< bgData.count {
             // Set low/range/high counts for pie chart and %'s
-            if Double(bgData[i].sgv) <= Storage.shared.lowLine.value {
+            if Double(bgData[i].sgv) < lowThreshold {
                 countLow += 1
-            } else if Double(bgData[i].sgv) >= Storage.shared.highLine.value {
+            } else if Double(bgData[i].sgv) > highThreshold {
                 countHigh += 1
             } else {
                 countRange += 1
@@ -60,15 +66,19 @@ class StatsData {
             partialSum += (Float(bgData[i].sgv) - avgBG) * (Float(bgData[i].sgv) - avgBG)
         }
 
-        stdDev = sqrt(partialSum / Float(bgData.count))
-        if Storage.shared.units.value != "mg/dL" {
-            stdDev = stdDev * Float(GlucoseConversion.mgDlToMmolL)
+        let stdDevMgdL = sqrt(partialSum / Float(bgData.count))
+        if avgBG > 0 {
+            coefficientOfVariation = (stdDevMgdL / avgBG) * 100.0
         }
+        stdDev = Float(UnitSettingsStore.shared.convertMgdlToDisplay(Double(stdDevMgdL)))
 
-        if Storage.shared.useIFCC.value {
-            a1C = (((46.7 + Float(avgBG)) / 28.7) - 2.152) / 0.09148
+        let avgBGDisplay = UnitSettingsStore.shared.convertMgdlToDisplay(Double(avgBG))
+        let metricValue: Double?
+        if UnitSettingsStore.shared.glycemicMetricMode == .gmi {
+            metricValue = GlycemicMetricCalculator.calculateGMI(avgGlucoseInDisplayUnits: avgBGDisplay)
         } else {
-            a1C = (46.7 + Float(avgBG)) / 28.7
+            metricValue = GlycemicMetricCalculator.calculateEhba1c(avgGlucoseInDisplayUnits: avgBGDisplay)
         }
+        a1C = Float(metricValue ?? 0.0)
     }
 }

+ 158 - 20
LoopFollow/Helpers/Localizer.swift

@@ -4,13 +4,160 @@
 import Foundation
 import HealthKit
 
+enum GlucoseDisplayUnit: String, Codable, CaseIterable {
+    case mgdL = "mg/dL"
+    case mmolL = "mmol/L"
+
+    var fractionDigits: Int {
+        switch self {
+        case .mgdL:
+            return 0
+        case .mmolL:
+            return 1
+        }
+    }
+}
+
+enum TimeInRangeDisplayMode: String, Codable, CaseIterable {
+    case tir = "TIR"
+    case titr = "TITR"
+    case custom = "Custom"
+}
+
+enum GlycemicMetricMode: String, Codable, CaseIterable {
+    case ehba1c = "eHbA1c"
+    case gmi = "GMI"
+}
+
+enum GlycemicOutputUnit: String, Codable, CaseIterable {
+    case percent = "%"
+    case mmolMol = "mmol/mol"
+}
+
+enum VariabilityMetricMode: String, Codable, CaseIterable {
+    case stdDeviation = "Std Deviation"
+    case cv = "CV"
+}
+
+final class UnitSettingsStore {
+    static let shared = UnitSettingsStore()
+
+    private init() {}
+
+    var glucoseUnit: GlucoseDisplayUnit {
+        get {
+            GlucoseDisplayUnit(rawValue: Storage.shared.units.value) ?? .mgdL
+        }
+        set {
+            Storage.shared.units.value = newValue.rawValue
+        }
+    }
+
+    var timeInRangeMode: TimeInRangeDisplayMode {
+        get {
+            TimeInRangeDisplayMode(rawValue: Storage.shared.timeInRangeModeRaw.value) ?? .tir
+        }
+        set {
+            Storage.shared.timeInRangeModeRaw.value = newValue.rawValue
+        }
+    }
+
+    /// Returns the effective low/high thresholds (in mg/dL) for the current range mode.
+    func effectiveThresholds() -> (low: Double, high: Double) {
+        switch timeInRangeMode {
+        case .tir:
+            return (70.0, 180.0)
+        case .titr:
+            return (70.0, 140.0)
+        case .custom:
+            return (Storage.shared.lowLine.value, Storage.shared.highLine.value)
+        }
+    }
+
+    var glycemicMetricMode: GlycemicMetricMode {
+        get {
+            Storage.shared.showGMI.value ? .gmi : .ehba1c
+        }
+        set {
+            Storage.shared.showGMI.value = (newValue == .gmi)
+        }
+    }
+
+    var glycemicOutputUnit: GlycemicOutputUnit {
+        get {
+            Storage.shared.useIFCC.value ? .mmolMol : .percent
+        }
+        set {
+            Storage.shared.useIFCC.value = (newValue == .mmolMol)
+        }
+    }
+
+    var variabilityMetricMode: VariabilityMetricMode {
+        get {
+            Storage.shared.showStdDev.value ? .stdDeviation : .cv
+        }
+        set {
+            Storage.shared.showStdDev.value = (newValue == .stdDeviation)
+        }
+    }
+
+    func convertMgdlToDisplay(_ mgdl: Double) -> Double {
+        switch glucoseUnit {
+        case .mgdL:
+            return mgdl
+        case .mmolL:
+            return mgdl * GlucoseConversion.mgDlToMmolL
+        }
+    }
+
+    func convertDisplayToMgdl(_ value: Double) -> Double {
+        switch glucoseUnit {
+        case .mgdL:
+            return value
+        case .mmolL:
+            return value * GlucoseConversion.mmolToMgDl
+        }
+    }
+}
+
+enum GlycemicMetricCalculator {
+    static func calculateEhba1c(avgGlucoseInDisplayUnits: Double?) -> Double? {
+        guard let avgGlucoseInDisplayUnits else { return nil }
+
+        switch UnitSettingsStore.shared.glycemicOutputUnit {
+        case .mmolMol:
+            let avgMmolL = UnitSettingsStore.shared.glucoseUnit == .mmolL
+                ? avgGlucoseInDisplayUnits
+                : avgGlucoseInDisplayUnits * GlucoseConversion.mgDlToMmolL
+            return avgMmolL * 6.936514699616532 - 6.628248828291433
+        case .percent:
+            let avgMgdL = UnitSettingsStore.shared.convertDisplayToMgdl(avgGlucoseInDisplayUnits)
+            return (avgMgdL + 46.7) / 28.7
+        }
+    }
+
+    static func calculateGMI(avgGlucoseInDisplayUnits: Double?) -> Double? {
+        guard let avgGlucoseInDisplayUnits else { return nil }
+
+        switch UnitSettingsStore.shared.glycemicOutputUnit {
+        case .percent:
+            let avgMgdL = UnitSettingsStore.shared.convertDisplayToMgdl(avgGlucoseInDisplayUnits)
+            return 3.31 + (0.02392 * avgMgdL)
+        case .mmolMol:
+            let avgMmolL = UnitSettingsStore.shared.glucoseUnit == .mmolL
+                ? avgGlucoseInDisplayUnits
+                : avgGlucoseInDisplayUnits * GlucoseConversion.mgDlToMmolL
+            return 12.71 + (4.70587 * avgMmolL)
+        }
+    }
+}
+
 class Localizer {
     static func getPreferredUnit() -> HKUnit {
-        let unitString = Storage.shared.units.value
-        switch unitString {
-        case "mmol/L":
+        switch UnitSettingsStore.shared.glucoseUnit {
+        case .mmolL:
             return .millimolesPerLiter
-        default:
+        case .mgdL:
             return .milligramsPerDeciliter
         }
     }
@@ -50,14 +197,9 @@ class Localizer {
         let numberFormatter = NumberFormatter()
         numberFormatter.numberStyle = .decimal
 
-        let units = unit ?? Storage.shared.units.value
-
-        if units == "mg/dL" {
-            numberFormatter.maximumFractionDigits = 0 // No decimal places for mg/dL
-        } else {
-            numberFormatter.maximumFractionDigits = 1 // Always one decimal place for mmol/L
-            numberFormatter.minimumFractionDigits = 1 // This ensures even .0 is displayed
-        }
+        let units = GlucoseDisplayUnit(rawValue: unit ?? UnitSettingsStore.shared.glucoseUnit.rawValue) ?? .mgdL
+        numberFormatter.maximumFractionDigits = units.fractionDigits
+        numberFormatter.minimumFractionDigits = units.fractionDigits
 
         numberFormatter.locale = Locale.current
 
@@ -68,18 +210,14 @@ class Localizer {
     static func toDisplayUnits(_ value: String) -> String {
         let numberFormatter = NumberFormatter()
         numberFormatter.numberStyle = .decimal
-
-        if Storage.shared.units.value == "mg/dL" {
-            numberFormatter.maximumFractionDigits = 0 // No decimal places for mg/dL
-        } else {
-            numberFormatter.maximumFractionDigits = 1 // Always one decimal place for mmol/L
-            numberFormatter.minimumFractionDigits = 1 // This ensures even .0 is displayed
-        }
+        let units = UnitSettingsStore.shared.glucoseUnit
+        numberFormatter.maximumFractionDigits = units.fractionDigits
+        numberFormatter.minimumFractionDigits = units.fractionDigits
 
         numberFormatter.locale = Locale.current
 
         if let number = Float(value) {
-            if Storage.shared.units.value == "mg/dL" {
+            if units == .mgdL {
                 let numberValue = NSNumber(value: number)
                 return numberFormatter.string(from: numberValue) ?? value
             } else {

+ 66 - 15
LoopFollow/Nightscout/NightscoutSettingsView.swift

@@ -5,21 +5,32 @@ import SwiftUI
 
 struct NightscoutSettingsView: View {
     @ObservedObject var viewModel: NightscoutSettingsViewModel
+    var usesModalCloseButton: Bool = false
+    var onContinueToUnits: (() -> Void)? = nil
+    var onImportSettings: (() -> Void)? = nil
+    @State private var showUnitsSetup = false
+    @Environment(\.dismiss) private var dismiss
 
     var body: some View {
-        NavigationView {
-            Form {
-                urlSection
-                tokenSection
-                statusSection
-                importSection
+        Form {
+            urlSection
+            tokenSection
+            statusSection
+
+            if viewModel.isFreshSetup {
+                continueSection
             }
-            .onDisappear {
-                viewModel.dismiss()
+
+            importSection
+        }
+        .navigationDestination(isPresented: $showUnitsSetup) {
+            UnitsOnboardingView {
+                dismiss()
             }
         }
-        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .navigationBarTitle("Nightscout Settings", displayMode: .inline)
+        .navigationBarBackButtonHidden(usesModalCloseButton)
+        .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
     }
 
     // MARK: - Subviews / Computed Properties
@@ -52,17 +63,57 @@ struct NightscoutSettingsView: View {
 
     private var statusSection: some View {
         Section(header: Text("Status")) {
-            Text(viewModel.nightscoutStatus)
+            HStack {
+                Text(viewModel.nightscoutStatus)
+                if viewModel.isConnected {
+                    Spacer()
+                    Image(systemName: "checkmark.circle.fill")
+                        .foregroundColor(.green)
+                }
+            }
+        }
+    }
+
+    private var continueSection: some View {
+        Section {
+            Button(action: {
+                if let onContinueToUnits {
+                    onContinueToUnits()
+                } else {
+                    showUnitsSetup = true
+                }
+            }) {
+                HStack {
+                    Spacer()
+                    Text("Continue")
+                        .fontWeight(.semibold)
+                    Spacer()
+                }
+            }
+            .buttonStyle(.borderedProminent)
+            .disabled(!viewModel.isConnected)
+            .listRowBackground(Color.clear)
         }
     }
 
     private var importSection: some View {
         Section(header: Text("Import Settings")) {
-            NavigationLink(destination: ImportExportSettingsView()) {
-                HStack {
-                    Image(systemName: "square.and.arrow.down")
-                        .foregroundColor(.blue)
-                    Text("Import Settings from QR Code")
+            if let onImportSettings {
+                Button(action: onImportSettings) {
+                    HStack {
+                        Image(systemName: "square.and.arrow.down")
+                            .foregroundColor(.blue)
+                        Text("Import Settings from QR Code")
+                            .foregroundColor(.primary)
+                    }
+                }
+            } else {
+                NavigationLink(destination: ImportExportSettingsView()) {
+                    HStack {
+                        Image(systemName: "square.and.arrow.down")
+                            .foregroundColor(.blue)
+                        Text("Import Settings from QR Code")
+                    }
                 }
             }
         }

+ 9 - 0
LoopFollow/Nightscout/NightscoutSettingsViewModel.swift

@@ -14,6 +14,12 @@ class NightscoutSettingsViewModel: ObservableObject {
     private var initialURL: String
     private var initialToken: String
 
+    /// Whether the Nightscout connection is successfully verified
+    @Published var isConnected: Bool = false
+
+    /// Whether this is a fresh setup (URL was empty when view appeared)
+    private(set) var isFreshSetup: Bool = false
+
     @Published var nightscoutURL: String = Storage.shared.url.value {
         willSet {
             if newValue != nightscoutURL {
@@ -41,6 +47,7 @@ class NightscoutSettingsViewModel: ObservableObject {
     init() {
         initialURL = Storage.shared.url.value
         initialToken = Storage.shared.token.value
+        isFreshSetup = initialURL.isEmpty
 
         setupDebounce()
         checkNightscoutStatus()
@@ -107,6 +114,7 @@ class NightscoutSettingsViewModel: ObservableObject {
 
     func updateStatusLabel(error: NightscoutUtils.NightscoutError?) {
         if let error = error {
+            isConnected = false
             switch error {
             case .invalidURL:
                 nightscoutStatus = "Invalid URL"
@@ -124,6 +132,7 @@ class NightscoutSettingsViewModel: ObservableObject {
                 nightscoutStatus = "Address Empty"
             }
         } else {
+            isConnected = true
             let authStatus: String
             if Storage.shared.nsAdminAuth.value {
                 authStatus = "Admin"

+ 53 - 23
LoopFollow/Settings/DexcomSettingsView.swift

@@ -5,40 +5,70 @@ import SwiftUI
 
 struct DexcomSettingsView: View {
     @ObservedObject var viewModel: DexcomSettingsViewModel
+    var usesModalCloseButton: Bool = false
+    var onContinueToUnits: (() -> Void)? = nil
+    @State private var showUnitsSetup = false
+    @Environment(\.dismiss) private var dismiss
 
     var body: some View {
-        NavigationView {
-            Form {
-                Section(header: Text("Dexcom Settings")) {
-                    HStack {
-                        Text("User Name")
-                        TextField("Enter User Name", text: $viewModel.userName)
-                            .autocapitalization(.none)
-                            .disableAutocorrection(true)
-                            .multilineTextAlignment(.trailing)
-                    }
+        Form {
+            Section(header: Text("Dexcom Settings")) {
+                HStack {
+                    Text("User Name")
+                    TextField("Enter User Name", text: $viewModel.userName)
+                        .autocapitalization(.none)
+                        .disableAutocorrection(true)
+                        .multilineTextAlignment(.trailing)
+                }
 
-                    HStack {
-                        Text("Password")
-                        TogglableSecureInput(
-                            placeholder: "Enter Password",
-                            text: $viewModel.password,
-                            style: .singleLine
-                        )
-                    }
+                HStack {
+                    Text("Password")
+                    TogglableSecureInput(
+                        placeholder: "Enter Password",
+                        text: $viewModel.password,
+                        style: .singleLine
+                    )
+                }
 
-                    Picker("Server", selection: $viewModel.server) {
-                        Text("US").tag("US")
-                        Text("NON-US").tag("NON-US")
+                Picker("Server", selection: $viewModel.server) {
+                    Text("US").tag("US")
+                    Text("NON-US").tag("NON-US")
+                }
+                .pickerStyle(SegmentedPickerStyle())
+            }
+
+            if viewModel.isFreshSetup {
+                Section {
+                    Button(action: {
+                        if let onContinueToUnits {
+                            onContinueToUnits()
+                        } else {
+                            showUnitsSetup = true
+                        }
+                    }) {
+                        HStack {
+                            Spacer()
+                            Text("Continue")
+                                .fontWeight(.semibold)
+                            Spacer()
+                        }
                     }
-                    .pickerStyle(SegmentedPickerStyle())
+                    .buttonStyle(.borderedProminent)
+                    .disabled(!viewModel.hasCredentials)
+                    .listRowBackground(Color.clear)
                 }
+            }
 
-                importSection
+            importSection
+        }
+        .navigationDestination(isPresented: $showUnitsSetup) {
+            UnitsOnboardingView {
+                dismiss()
             }
         }
         .preferredColorScheme(Storage.shared.appearanceMode.value.colorScheme)
         .navigationBarTitle("Dexcom Settings", displayMode: .inline)
+        .navigationBarBackButtonHidden(usesModalCloseButton)
     }
 
     private var importSection: some View {

+ 11 - 1
LoopFollow/Settings/DexcomSettingsViewModel.swift

@@ -5,6 +5,9 @@ import Combine
 import Foundation
 
 class DexcomSettingsViewModel: ObservableObject {
+    /// Whether this is a fresh setup (credentials were empty when view appeared)
+    private(set) var isFreshSetup: Bool = false
+
     @Published var userName: String = Storage.shared.shareUserName.value {
         willSet {
             if newValue != userName {
@@ -29,5 +32,12 @@ class DexcomSettingsViewModel: ObservableObject {
         }
     }
 
-    init() {}
+    /// Whether credentials are filled in
+    var hasCredentials: Bool {
+        !userName.isEmpty && !password.isEmpty
+    }
+
+    init() {
+        isFreshSetup = Storage.shared.shareUserName.value.isEmpty
+    }
 }

+ 0 - 2
LoopFollow/Settings/GeneralSettingsView.swift

@@ -8,7 +8,6 @@ struct GeneralSettingsView: View {
     @ObservedObject var appBadge = Storage.shared.appBadge
     @ObservedObject var appearanceMode = Storage.shared.appearanceMode
     @ObservedObject var showStats = Storage.shared.showStats
-    @ObservedObject var useIFCC = Storage.shared.useIFCC
     @ObservedObject var showSmallGraph = Storage.shared.showSmallGraph
     @ObservedObject var screenlockSwitchState = Storage.shared.screenlockSwitchState
     @ObservedObject var showDisplayName = Storage.shared.showDisplayName
@@ -47,7 +46,6 @@ struct GeneralSettingsView: View {
                         }
                     }
                     Toggle("Display Stats", isOn: $showStats.value)
-                    Toggle("Use IFCC A1C", isOn: $useIFCC.value)
                     Toggle("Display Small Graph", isOn: $showSmallGraph.value)
                     Toggle("Color BG Text", isOn: $colorBGText.value)
                     Toggle("Keep Screen Active", isOn: $screenlockSwitchState.value)

+ 0 - 15
LoopFollow/Settings/GraphSettingsView.swift

@@ -19,8 +19,6 @@ struct GraphSettingsView: View {
     @ObservedObject private var predictionDisplayType = Storage.shared.predictionDisplayType
     @ObservedObject private var minBasalScale = Storage.shared.minBasalScale
     @ObservedObject private var minBGScale = Storage.shared.minBGScale
-    @ObservedObject private var lowLine = Storage.shared.lowLine
-    @ObservedObject private var highLine = Storage.shared.highLine
     @ObservedObject private var downloadDays = Storage.shared.downloadDays
 
     private var nightscoutEnabled: Bool { IsNightscoutEnabled() }
@@ -115,19 +113,6 @@ struct GraphSettingsView: View {
                     }
                 }
 
-                // ── Target lines ─────────────────────────────────────────────
-                Section("Target Lines") {
-                    BGPicker(title: "Low BG Line",
-                             range: 40 ... 120,
-                             value: $lowLine.value)
-                        .onChange(of: lowLine.value) { _ in markDirty() }
-
-                    BGPicker(title: "High BG Line",
-                             range: 120 ... 400,
-                             value: $highLine.value)
-                        .onChange(of: highLine.value) { _ in markDirty() }
-                }
-
                 // ── History window ───────────────────────────────────────────
                 if nightscoutEnabled {
                     Section("History") {

+ 84 - 1
LoopFollow/Settings/ImportExport/ExportableSettings.swift

@@ -11,14 +11,78 @@ struct NightscoutSettingsExport: Codable {
     let url: String
     let token: String
     let units: String
+    let glycemicMetricMode: String
+    let glycemicOutputUnit: String
+    let timeInRangeMode: String
+    let lowLine: Double?
+    let highLine: Double?
+    let variabilityMetricMode: String
+
+    enum CodingKeys: String, CodingKey {
+        case version
+        case url
+        case token
+        case units
+        case glycemicMetricMode
+        case glycemicOutputUnit
+        case timeInRangeMode
+        case lowLine
+        case highLine
+        case variabilityMetricMode
+    }
+
+    init(
+        version: String,
+        url: String,
+        token: String,
+        units: String,
+        glycemicMetricMode: String,
+        glycemicOutputUnit: String,
+        timeInRangeMode: String,
+        lowLine: Double?,
+        highLine: Double?,
+        variabilityMetricMode: String
+    ) {
+        self.version = version
+        self.url = url
+        self.token = token
+        self.units = units
+        self.glycemicMetricMode = glycemicMetricMode
+        self.glycemicOutputUnit = glycemicOutputUnit
+        self.timeInRangeMode = timeInRangeMode
+        self.lowLine = lowLine
+        self.highLine = highLine
+        self.variabilityMetricMode = variabilityMetricMode
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+        version = try container.decode(String.self, forKey: .version)
+        url = try container.decode(String.self, forKey: .url)
+        token = try container.decode(String.self, forKey: .token)
+        units = try container.decodeIfPresent(String.self, forKey: .units) ?? GlucoseDisplayUnit.mgdL.rawValue
+        glycemicMetricMode = try container.decodeIfPresent(String.self, forKey: .glycemicMetricMode) ?? GlycemicMetricMode.gmi.rawValue
+        glycemicOutputUnit = try container.decodeIfPresent(String.self, forKey: .glycemicOutputUnit) ?? GlycemicOutputUnit.percent.rawValue
+        timeInRangeMode = try container.decodeIfPresent(String.self, forKey: .timeInRangeMode) ?? TimeInRangeDisplayMode.tir.rawValue
+        lowLine = try container.decodeIfPresent(Double.self, forKey: .lowLine)
+        highLine = try container.decodeIfPresent(Double.self, forKey: .highLine)
+        variabilityMetricMode = try container.decodeIfPresent(String.self, forKey: .variabilityMetricMode) ?? VariabilityMetricMode.stdDeviation.rawValue
+    }
 
     static func fromCurrentStorage() -> NightscoutSettingsExport {
         let storage = Storage.shared
+        let unitSettings = UnitSettingsStore.shared
         return NightscoutSettingsExport(
             version: AppVersionManager().version(),
             url: storage.url.value,
             token: storage.token.value,
-            units: storage.units.value
+            units: storage.units.value,
+            glycemicMetricMode: unitSettings.glycemicMetricMode.rawValue,
+            glycemicOutputUnit: unitSettings.glycemicOutputUnit.rawValue,
+            timeInRangeMode: unitSettings.timeInRangeMode.rawValue,
+            lowLine: storage.lowLine.value,
+            highLine: storage.highLine.value,
+            variabilityMetricMode: unitSettings.variabilityMetricMode.rawValue
         )
     }
 
@@ -27,6 +91,25 @@ struct NightscoutSettingsExport: Codable {
         storage.url.value = url
         storage.token.value = token
         storage.units.value = units
+
+        if let glycemicMetricMode = GlycemicMetricMode(rawValue: glycemicMetricMode) {
+            UnitSettingsStore.shared.glycemicMetricMode = glycemicMetricMode
+        }
+        if let glycemicOutputUnit = GlycemicOutputUnit(rawValue: glycemicOutputUnit) {
+            UnitSettingsStore.shared.glycemicOutputUnit = glycemicOutputUnit
+        }
+        if let timeInRangeMode = TimeInRangeDisplayMode(rawValue: timeInRangeMode) {
+            UnitSettingsStore.shared.timeInRangeMode = timeInRangeMode
+        }
+        if let lowLine = lowLine {
+            storage.lowLine.value = lowLine
+        }
+        if let highLine = highLine {
+            storage.highLine.value = highLine
+        }
+        if let variabilityMetricMode = VariabilityMetricMode(rawValue: variabilityMetricMode) {
+            UnitSettingsStore.shared.variabilityMetricMode = variabilityMetricMode
+        }
     }
 
     func encodeToJSON() -> String? {

+ 8 - 10
LoopFollow/Settings/SettingsMenuView.swift

@@ -31,6 +31,7 @@ struct SettingsMenuView: View {
                     {
                         settingsPath.value.append(Sheet.general)
                     }
+
                     NavigationRow(title: "Graph",
                                   icon: "chart.xyaxis.line")
                     {
@@ -44,6 +45,11 @@ struct SettingsMenuView: View {
                             settingsPath.value.append(Sheet.infoDisplay)
                         }
                     }
+                    NavigationRow(title: "Units and Metrics",
+                                  icon: "scalemass")
+                    {
+                        settingsPath.value.append(Sheet.units)
+                    }
 
                     NavigationRow(title: "Tabs",
                                   icon: "rectangle.3.group")
@@ -138,16 +144,6 @@ struct SettingsMenuView: View {
     @ViewBuilder
     private var dataSection: some View {
         Section("Data Settings") {
-            Picker("Units",
-                   selection: Binding(
-                       get: { Storage.shared.units.value },
-                       set: { Storage.shared.units.value = $0 }
-                   )) {
-                Text("mg/dL").tag("mg/dL")
-                Text("mmol/L").tag("mmol/L")
-            }
-            .pickerStyle(.segmented)
-
             NavigationRow(title: "Nightscout",
                           icon: "network")
             {
@@ -166,6 +162,7 @@ struct SettingsMenuView: View {
 // MARK: – Sheet routing
 
 private enum Sheet: Hashable, Identifiable {
+    case units
     case nightscout, dexcom
     case backgroundRefresh
     case general, graph
@@ -187,6 +184,7 @@ private enum Sheet: Hashable, Identifiable {
     @ViewBuilder
     var destination: some View {
         switch self {
+        case .units: UnitsSettingsView()
         case .nightscout: NightscoutSettingsView(viewModel: .init())
         case .dexcom: DexcomSettingsView(viewModel: .init())
         case .backgroundRefresh: BackgroundRefreshSettingsView(viewModel: .init())

+ 120 - 0
LoopFollow/Settings/UnitsConfigurationView.swift

@@ -0,0 +1,120 @@
+// LoopFollow
+// UnitsConfigurationView.swift
+
+import SwiftUI
+
+/// Reusable view for configuring units and metrics.
+/// Can be embedded in Forms or used standalone during onboarding.
+struct UnitsConfigurationView: View {
+    @State private var rangeMode = UnitSettingsStore.shared.timeInRangeMode
+    @State private var glucoseUnit = UnitSettingsStore.shared.glucoseUnit
+    @State private var lowValue = Storage.shared.lowLine.value
+    @State private var highValue = Storage.shared.highLine.value
+
+    var body: some View {
+        Group {
+            Section("Glucose") {
+                Picker("Glucose Unit", selection: $glucoseUnit) {
+                    Text("mg/dL").tag(GlucoseDisplayUnit.mgdL)
+                    Text("mmol/L").tag(GlucoseDisplayUnit.mmolL)
+                }
+                .pickerStyle(.segmented)
+                .onChange(of: glucoseUnit) { newValue in
+                    UnitSettingsStore.shared.glucoseUnit = newValue
+                }
+            }
+
+            Section("Range") {
+                Picker("Range Mode", selection: $rangeMode) {
+                    Text("TIR").tag(TimeInRangeDisplayMode.tir)
+                    Text("TITR").tag(TimeInRangeDisplayMode.titr)
+                    Text("Custom").tag(TimeInRangeDisplayMode.custom)
+                }
+                .pickerStyle(.segmented)
+                .onChange(of: rangeMode) { newValue in
+                    UnitSettingsStore.shared.timeInRangeMode = newValue
+                    Observable.shared.chartSettingsChanged.value = true
+                }
+
+                if rangeMode == .custom {
+                    BGPicker(
+                        title: "Low",
+                        range: 40 ... 120,
+                        value: $lowValue
+                    )
+                    .id(glucoseUnit)
+                    .onChange(of: lowValue) { newValue in
+                        Storage.shared.lowLine.value = newValue
+                        Observable.shared.chartSettingsChanged.value = true
+                    }
+                    BGPicker(
+                        title: "High",
+                        range: 120 ... 400,
+                        value: $highValue
+                    )
+                    .id(glucoseUnit)
+                    .onChange(of: highValue) { newValue in
+                        Storage.shared.highLine.value = newValue
+                        Observable.shared.chartSettingsChanged.value = true
+                    }
+                }
+            }
+
+            Section("Glycemic Metrics") {
+                Picker("Metric", selection: Binding(
+                    get: { UnitSettingsStore.shared.glycemicMetricMode },
+                    set: { UnitSettingsStore.shared.glycemicMetricMode = $0 }
+                )) {
+                    Text("eHbA1c").tag(GlycemicMetricMode.ehba1c)
+                    Text("GMI").tag(GlycemicMetricMode.gmi)
+                }
+                .pickerStyle(.segmented)
+
+                Picker("Output Unit", selection: Binding(
+                    get: { UnitSettingsStore.shared.glycemicOutputUnit },
+                    set: { UnitSettingsStore.shared.glycemicOutputUnit = $0 }
+                )) {
+                    Text("%").tag(GlycemicOutputUnit.percent)
+                    Text("mmol/mol").tag(GlycemicOutputUnit.mmolMol)
+                }
+                .pickerStyle(.segmented)
+            }
+
+            Section("Variability") {
+                Picker("Metric", selection: Binding(
+                    get: { UnitSettingsStore.shared.variabilityMetricMode },
+                    set: { UnitSettingsStore.shared.variabilityMetricMode = $0 }
+                )) {
+                    Text("Std Dev").tag(VariabilityMetricMode.stdDeviation)
+                    Text("CV").tag(VariabilityMetricMode.cv)
+                }
+                .pickerStyle(.segmented)
+            }
+        }
+    }
+}
+
+/// Standalone page for units configuration during onboarding.
+/// Shows a checkmark button in the toolbar to complete setup.
+struct UnitsOnboardingView: View {
+    let onComplete: () -> Void
+
+    var body: some View {
+        Form {
+            UnitsConfigurationView()
+        }
+        .navigationTitle("Set Up Units")
+        .navigationBarTitleDisplayMode(.inline)
+        .toolbar {
+            ToolbarItem(placement: .navigationBarTrailing) {
+                Button(action: {
+                    Storage.shared.hasConfiguredUnits.value = true
+                    onComplete()
+                }) {
+                    Image(systemName: "checkmark")
+                        .fontWeight(.semibold)
+                }
+            }
+        }
+    }
+}

+ 14 - 0
LoopFollow/Settings/UnitsSettingsView.swift

@@ -0,0 +1,14 @@
+// LoopFollow
+// UnitsSettingsView.swift
+
+import SwiftUI
+
+struct UnitsSettingsView: View {
+    var body: some View {
+        Form {
+            UnitsConfigurationView()
+        }
+        .navigationTitle("Units and Metrics")
+        .navigationBarTitleDisplayMode(.inline)
+    }
+}

+ 40 - 29
LoopFollow/Stats/AggregatedStatsView.swift

@@ -21,8 +21,8 @@ struct AggregatedStatsView: View {
     init(viewModel: AggregatedStatsViewModel, onDismiss: (() -> Void)? = nil) {
         self.viewModel = viewModel
         self.onDismiss = onDismiss
-        _showGMI = State(initialValue: Storage.shared.showGMI.value)
-        _showStdDev = State(initialValue: Storage.shared.showStdDev.value)
+        _showGMI = State(initialValue: UnitSettingsStore.shared.glycemicMetricMode == .gmi)
+        _showStdDev = State(initialValue: UnitSettingsStore.shared.variabilityMetricMode == .stdDeviation)
 
         let calendar = dateTimeUtils.displayCalendar()
         let startOfToday = calendar.startOfDay(for: Date())
@@ -162,12 +162,32 @@ struct AggregatedStatsView: View {
             timeoutTimer = nil
         }
         .onChange(of: showGMI) { newValue in
-            Storage.shared.showGMI.value = newValue
+            UnitSettingsStore.shared.glycemicMetricMode = newValue ? .gmi : .ehba1c
         }
         .onChange(of: showStdDev) { newValue in
-            Storage.shared.showStdDev.value = newValue
+            UnitSettingsStore.shared.variabilityMetricMode = newValue ? .stdDeviation : .cv
+        }
+        .onReceive(Storage.shared.showGMI.$value) { newValue in
+            showGMI = newValue
+        }
+        .onReceive(Storage.shared.showStdDev.$value) { newValue in
+            showStdDev = newValue
+        }
+        .onReceive(Storage.shared.units.$value) { _ in
+            refreshVisibleStats()
+        }
+        .onReceive(Storage.shared.useIFCC.$value) { _ in
+            refreshVisibleStats()
+        }
+        .onReceive(Storage.shared.timeInRangeModeRaw.$value) { _ in
+            viewModel.tirStats.calculateTIR()
         }
     }
+
+    private func refreshVisibleStats() {
+        guard !isLoadingData else { return }
+        viewModel.calculateStats()
+    }
 }
 
 struct AggregatedStatsContentView: View {
@@ -319,9 +339,9 @@ struct StatsGridView: View {
                     showGMI.toggle()
                 }) {
                     StatCard(
-                        title: showGMI ? "GMI" : "eHbA1c",
-                        value: showGMI ? formatGMI(simpleStats.gmi) : formatEhbA1c(simpleStats.avgGlucose),
-                        unit: showGMI ? "%" : (Storage.shared.units.value == "mg/dL" ? "%" : "mmol/mol"),
+                        title: showGMI ? "GMI" : "Est. A1C",
+                        value: showGMI ? formatGMI(simpleStats.avgGlucose) : formatEhbA1c(simpleStats.avgGlucose),
+                        unit: UnitSettingsStore.shared.glycemicOutputUnit.rawValue,
                         color: .blue,
                         isInteractive: true
                     )
@@ -331,7 +351,7 @@ struct StatsGridView: View {
                 StatCard(
                     title: "Avg Glucose",
                     value: formatGlucose(simpleStats.avgGlucose),
-                    unit: Storage.shared.units.value,
+                    unit: UnitSettingsStore.shared.glucoseUnit.rawValue,
                     color: Color(red: 0.20, green: 0.99, blue: 0.70)
                 )
 
@@ -341,7 +361,7 @@ struct StatsGridView: View {
                     StatCard(
                         title: showStdDev ? "Std Deviation" : "CV",
                         value: showStdDev ? formatStdDev(simpleStats.stdDeviation) : formatCV(simpleStats.coefficientOfVariation),
-                        unit: showStdDev ? Storage.shared.units.value : "%",
+                        unit: showStdDev ? UnitSettingsStore.shared.glucoseUnit.rawValue : "%",
                         color: .orange,
                         isInteractive: true
                     )
@@ -403,34 +423,25 @@ struct StatsGridView: View {
         }
     }
 
-    private func formatGMI(_ value: Double?) -> String {
-        guard let value = value else { return "---" }
+    private func formatGMI(_ avgGlucose: Double?) -> String {
+        guard let value = GlycemicMetricCalculator.calculateGMI(avgGlucoseInDisplayUnits: avgGlucose) else { return "---" }
+        if UnitSettingsStore.shared.glycemicOutputUnit == .mmolMol {
+            return String(format: "%.0f", value)
+        }
         return String(format: "%.1f", value)
     }
 
     private func formatEhbA1c(_ avgGlucose: Double?) -> String {
-        guard let avgGlucose = avgGlucose else { return "---" }
-
-        let avgGlucoseMgdL: Double
-        if Storage.shared.units.value == "mg/dL" {
-            avgGlucoseMgdL = avgGlucose
-        } else {
-            avgGlucoseMgdL = avgGlucose * 18.0182
-        }
-
-        let ehba1cPercent = (avgGlucoseMgdL + 46.7) / 28.7
-
-        if Storage.shared.units.value == "mg/dL" {
-            return String(format: "%.1f", ehba1cPercent)
-        } else {
-            let ehba1cMmolMol = (ehba1cPercent - 2.15) * 10.929
-            return String(format: "%.0f", ehba1cMmolMol)
+        guard let value = GlycemicMetricCalculator.calculateEhba1c(avgGlucoseInDisplayUnits: avgGlucose) else { return "---" }
+        if UnitSettingsStore.shared.glycemicOutputUnit == .mmolMol {
+            return String(format: "%.0f", value)
         }
+        return String(format: "%.1f", value)
     }
 
     private func formatGlucose(_ value: Double?) -> String {
         guard let value = value else { return "---" }
-        if Storage.shared.units.value == "mg/dL" {
+        if UnitSettingsStore.shared.glucoseUnit == .mgdL {
             return String(format: "%.0f", value)
         } else {
             return String(format: "%.1f", value)
@@ -439,7 +450,7 @@ struct StatsGridView: View {
 
     private func formatStdDev(_ value: Double?) -> String {
         guard let value = value else { return "---" }
-        if Storage.shared.units.value == "mg/dL" {
+        if UnitSettingsStore.shared.glucoseUnit == .mgdL {
             return String(format: "%.0f", value)
         } else {
             return String(format: "%.1f", value)

+ 4 - 3
LoopFollow/Stats/SimpleStatsViewModel.swift

@@ -38,21 +38,22 @@ class SimpleStatsViewModel: ObservableObject {
     }
 
     func calculateStats() {
+        let unitSettings = UnitSettingsStore.shared
         let bgData = dataService.getBGData()
         guard !bgData.isEmpty else { return }
 
         let totalGlucose = bgData.reduce(0) { $0 + $1.sgv }
         let avgBGmgdL = Double(totalGlucose) / Double(bgData.count)
-        avgGlucose = Storage.shared.units.value == "mg/dL" ? avgBGmgdL : avgBGmgdL * GlucoseConversion.mgDlToMmolL
+        avgGlucose = unitSettings.convertMgdlToDisplay(avgBGmgdL)
 
         let variance = bgData.reduce(0.0) { sum, reading in
             let diff = Double(reading.sgv) - avgBGmgdL
             return sum + (diff * diff)
         }
         let stdDevMgdL = sqrt(variance / Double(bgData.count))
-        stdDeviation = Storage.shared.units.value == "mg/dL" ? stdDevMgdL : stdDevMgdL * GlucoseConversion.mgDlToMmolL
+        stdDeviation = unitSettings.convertMgdlToDisplay(stdDevMgdL)
 
-        gmi = 3.31 + (0.02392 * avgBGmgdL)
+        gmi = GlycemicMetricCalculator.calculateGMI(avgGlucoseInDisplayUnits: avgGlucose)
 
         if avgBGmgdL > 0 {
             coefficientOfVariation = (stdDevMgdL / avgBGmgdL) * 100.0

+ 4 - 3
LoopFollow/Stats/TIR/TIRCalculator.swift

@@ -4,12 +4,13 @@
 import Foundation
 
 class TIRCalculator {
-    static func calculate(bgData: [ShareGlucoseData], useTightRange: Bool = false) -> [TIRDataPoint] {
+    static func calculate(bgData: [ShareGlucoseData]) -> [TIRDataPoint] {
         guard !bgData.isEmpty else { return [] }
 
+        let thresholds = UnitSettingsStore.shared.effectiveThresholds()
         let veryLowThreshold = 54.0
-        let lowThreshold = 70.0
-        let highThreshold = useTightRange ? 140.0 : 180.0
+        let lowThreshold = thresholds.low
+        let highThreshold = thresholds.high
         let veryHighThreshold = 250.0
         var periodData: [TIRPeriod: [Double]] = [:]
         let calendar = dateTimeUtils.displayCalendar()

+ 37 - 36
LoopFollow/Stats/TIR/TIRView.swift

@@ -13,7 +13,7 @@ struct TIRView: View {
             ZStack(alignment: .topTrailing) {
                 VStack(alignment: .leading, spacing: 12) {
                     HStack {
-                        Text(viewModel.showTITR ? "Time in Tight Range" : "Time in Range")
+                        Text(tirTitle)
                             .font(.caption)
                             .foregroundColor(.secondary)
                         Spacer()
@@ -32,7 +32,7 @@ struct TIRView: View {
 
                         VStack(alignment: .leading, spacing: 8) {
                             if let average = viewModel.tirData.first(where: { $0.period == .average }) {
-                                Text("Cutoffs in \(Storage.shared.units.value)")
+                                Text("Cutoffs in \(UnitSettingsStore.shared.glucoseUnit.rawValue)")
                                     .foregroundColor(.secondary)
 
                                 TIRLegendItem(
@@ -84,19 +84,41 @@ struct TIRView: View {
         .buttonStyle(PlainButtonStyle())
     }
 
-    private func formatRange(_: Double) -> String {
-        let lowThreshold: Double
-        let highThreshold: Double
-
-        if Storage.shared.units.value == "mg/dL" {
-            lowThreshold = 70.0
-            highThreshold = viewModel.showTITR ? 140.0 : 180.0
-        } else {
-            lowThreshold = 3.9
-            highThreshold = viewModel.showTITR ? 7.8 : 10.0
+    private var tirTitle: String {
+        switch UnitSettingsStore.shared.timeInRangeMode {
+        case .tir:
+            return "Time in Range"
+        case .titr:
+            return "Time in Tight Range"
+        case .custom:
+            return "Custom Range"
         }
+    }
+
+    private func formatRange(_: Double) -> String {
+        let thresholds = UnitSettingsStore.shared.effectiveThresholds()
+        let glucoseUnit = UnitSettingsStore.shared.glucoseUnit
+        let low = UnitSettingsStore.shared.convertMgdlToDisplay(thresholds.low)
+        let high = UnitSettingsStore.shared.convertMgdlToDisplay(thresholds.high)
+        let digits = glucoseUnit.fractionDigits
+        let fmt = "%.\(digits)f – %.\(digits)f %@"
+        return String(format: fmt, low, high, glucoseUnit.rawValue)
+    }
+
+    private var lowThreshold: Double {
+        UnitSettingsStore.shared.convertMgdlToDisplay(UnitSettingsStore.shared.effectiveThresholds().low)
+    }
 
-        return String(format: "%.1f – %.1f %@", lowThreshold, highThreshold, Storage.shared.units.value)
+    private var highThreshold: Double {
+        UnitSettingsStore.shared.convertMgdlToDisplay(UnitSettingsStore.shared.effectiveThresholds().high)
+    }
+
+    private var veryLowThreshold: Double {
+        UnitSettingsStore.shared.convertMgdlToDisplay(54.0)
+    }
+
+    private var veryHighThreshold: Double {
+        UnitSettingsStore.shared.convertMgdlToDisplay(250.0)
     }
 
     private var veryLowCutoffText: String {
@@ -119,30 +141,9 @@ struct TIRView: View {
         "> \(formatThreshold(veryHighThreshold))"
     }
 
-    private var lowThreshold: Double {
-        Storage.shared.units.value == "mg/dL" ? 70.0 : 3.9
-    }
-
-    private var highThreshold: Double {
-        if Storage.shared.units.value == "mg/dL" {
-            return viewModel.showTITR ? 140.0 : 180.0
-        }
-        return viewModel.showTITR ? 7.8 : 10.0
-    }
-
-    private var veryLowThreshold: Double {
-        Storage.shared.units.value == "mg/dL" ? 54.0 : 3.0
-    }
-
-    private var veryHighThreshold: Double {
-        Storage.shared.units.value == "mg/dL" ? 250.0 : 13.9
-    }
-
     private func formatThreshold(_ value: Double) -> String {
-        if Storage.shared.units.value == "mg/dL" {
-            return String(format: "%.0f", value)
-        }
-        return String(format: "%.1f", value)
+        let digits = UnitSettingsStore.shared.glucoseUnit.fractionDigits
+        return String(format: "%.\(digits)f", value)
     }
 }
 

+ 10 - 8
LoopFollow/Stats/TIR/TIRViewModel.swift

@@ -6,27 +6,29 @@ import Foundation
 
 class TIRViewModel: ObservableObject {
     @Published var tirData: [TIRDataPoint] = []
-    @Published var showTITR: Bool {
-        didSet {
-            Storage.shared.showTITR.value = showTITR
-        }
-    }
 
     private let dataService: StatsDataService
 
     init(dataService: StatsDataService) {
         self.dataService = dataService
-        showTITR = Storage.shared.showTITR.value
         calculateTIR()
     }
 
     func calculateTIR() {
         let bgData = dataService.getBGData()
-        tirData = TIRCalculator.calculate(bgData: bgData, useTightRange: showTITR)
+        tirData = TIRCalculator.calculate(bgData: bgData)
     }
 
     func toggleTIRMode() {
-        showTITR.toggle()
+        let mode = UnitSettingsStore.shared.timeInRangeMode
+        switch mode {
+        case .tir:
+            UnitSettingsStore.shared.timeInRangeMode = .titr
+        case .titr:
+            UnitSettingsStore.shared.timeInRangeMode = .custom
+        case .custom:
+            UnitSettingsStore.shared.timeInRangeMode = .tir
+        }
         calculateTIR()
     }
 

+ 12 - 0
LoopFollow/Storage/Storage+Migrate.swift

@@ -33,6 +33,18 @@ extension Storage {
         }
     }
 
+    func migrateStep8() {
+        // Migrate showTITR bool + custom lowLine/highLine into timeInRangeModeRaw.
+        LogManager.shared.log(category: .general, message: "Running migrateStep8 — timeInRangeMode migration")
+
+        if showTITR.value {
+            timeInRangeModeRaw.value = TimeInRangeDisplayMode.titr.rawValue
+        } else if lowLine.value != 70.0 || highLine.value != 180.0 {
+            timeInRangeModeRaw.value = TimeInRangeDisplayMode.custom.rawValue
+        }
+        // else: default "TIR" is already set
+    }
+
     func migrateStep7() {
         // Cancel notifications scheduled with old hardcoded identifiers.
         // Replaced with bundle-ID-scoped identifiers for multi-instance support.

+ 3 - 0
LoopFollow/Storage/Storage.swift

@@ -197,6 +197,7 @@ class Storage {
     var hideInfoTable = StorageValue<Bool>(key: "hideInfoTable", defaultValue: false)
     var token = StorageValue<String>(key: "token", defaultValue: "")
     var units = StorageValue<String>(key: "units", defaultValue: "mg/dL")
+    var hasConfiguredUnits = StorageValue<Bool>(key: "hasConfiguredUnits", defaultValue: false)
 
     var infoSort = StorageValue<[Int]>(key: "infoSort", defaultValue: InfoType.allCases.map(\.sortOrder))
     var infoVisible = StorageValue<[Bool]>(key: "infoVisible", defaultValue: InfoType.allCases.map(\.defaultVisible))
@@ -232,6 +233,7 @@ class Storage {
     var showGMI = StorageValue<Bool>(key: "showGMI", defaultValue: true)
     var showStdDev = StorageValue<Bool>(key: "showStdDev", defaultValue: true)
     var showTITR = StorageValue<Bool>(key: "showTITR", defaultValue: false)
+    var timeInRangeModeRaw = StorageValue<String>(key: "timeInRangeMode", defaultValue: "TIR")
 
     static let shared = Storage()
     private init() {}
@@ -421,6 +423,7 @@ class Storage {
         showGMI.reload()
         showStdDev.reload()
         showTITR.reload()
+        timeInRangeModeRaw.reload()
     }
 
     // MARK: - Tab Position Helpers

+ 53 - 8
LoopFollow/ViewControllers/MainViewController.swift

@@ -42,7 +42,9 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
     @IBOutlet var statsInRangePercent: UILabel!
     @IBOutlet var statsHighPercent: UILabel!
     @IBOutlet var statsAvgBG: UILabel!
+    @IBOutlet var statsEstA1CTitle: UILabel!
     @IBOutlet var statsEstA1C: UILabel!
+    @IBOutlet var statsStdDevTitle: UILabel!
     @IBOutlet var statsStdDev: UILabel!
     @IBOutlet var serverText: UILabel!
     @IBOutlet var statsView: UIView!
@@ -290,10 +292,23 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
             }
             .store(in: &cancellables)
 
-        Storage.shared.useIFCC.$value
+        Publishers.MergeMany(
+            Storage.shared.units.$value.map { _ in () }.eraseToAnyPublisher(),
+            Storage.shared.useIFCC.$value.map { _ in () }.eraseToAnyPublisher(),
+            Storage.shared.showGMI.$value.map { _ in () }.eraseToAnyPublisher(),
+            Storage.shared.showStdDev.$value.map { _ in () }.eraseToAnyPublisher()
+        )
+        .receive(on: DispatchQueue.main)
+        .sink { [weak self] _ in
+            self?.updateStats()
+        }
+        .store(in: &cancellables)
+
+        Storage.shared.timeInRangeModeRaw.$value
             .receive(on: DispatchQueue.main)
             .sink { [weak self] _ in
-                self?.updateStats()
+                self?.updateBGGraphSettings()
+                self?.updateBGGraph()
             }
             .store(in: &cancellables)
 
@@ -1056,6 +1071,11 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
             Storage.shared.migrateStep7()
             Storage.shared.migrationStep.value = 7
         }
+
+        if Storage.shared.migrationStep.value < 8 {
+            Storage.shared.migrateStep8()
+            Storage.shared.migrationStep.value = 8
+        }
     }
 
     @objc func appDidBecomeActive() {
@@ -1224,10 +1244,11 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
             let latestBG = bgData[bgData.count - 1].sgv
             var color = NSUIColor.label
             if Storage.shared.colorBGText.value {
-                if Double(latestBG) >= Storage.shared.highLine.value {
+                let thresholds = UnitSettingsStore.shared.effectiveThresholds()
+                if Double(latestBG) >= thresholds.high {
                     color = NSUIColor.systemYellow
                     Observable.shared.bgTextColor.value = .yellow
-                } else if Double(latestBG) <= Storage.shared.lowLine.value {
+                } else if Double(latestBG) <= thresholds.low {
                     color = NSUIColor.systemRed
                     Observable.shared.bgTextColor.value = .red
                 } else {
@@ -1566,15 +1587,30 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
     }
 
     @objc private func setupNightscoutTapped() {
-        let nightscoutSettingsView = NightscoutSettingsView(viewModel: .init())
+        let navController = UINavigationController()
+        let nightscoutSettingsView = NightscoutSettingsView(viewModel: .init(), usesModalCloseButton: true, onContinueToUnits: { [weak navController] in
+            let unitsView = UnitsOnboardingView {
+                navController?.dismiss(animated: true)
+            }
+            let unitsController = UIHostingController(rootView: unitsView)
+            let style = Storage.shared.appearanceMode.value.userInterfaceStyle
+            unitsController.overrideUserInterfaceStyle = style
+            navController?.pushViewController(unitsController, animated: true)
+        }, onImportSettings: { [weak navController] in
+            let importSettingsView = ImportExportSettingsView()
+            let importSettingsController = UIHostingController(rootView: importSettingsView)
+            let style = Storage.shared.appearanceMode.value.userInterfaceStyle
+            importSettingsController.overrideUserInterfaceStyle = style
+            navController?.pushViewController(importSettingsController, animated: true)
+        })
         let hostingController = UIHostingController(rootView: nightscoutSettingsView)
-        let navController = UINavigationController(rootViewController: hostingController)
 
         // Apply appearance mode
         let style = Storage.shared.appearanceMode.value.userInterfaceStyle
         hostingController.overrideUserInterfaceStyle = style
         navController.overrideUserInterfaceStyle = style
 
+        navController.setViewControllers([hostingController], animated: false)
         hostingController.navigationItem.rightBarButtonItem = makeCloseBarButtonItem()
 
         navController.modalPresentationStyle = .pageSheet
@@ -1582,15 +1618,24 @@ class MainViewController: UIViewController, UITableViewDataSource, ChartViewDele
     }
 
     @objc private func setupDexcomTapped() {
-        let dexcomSettingsView = DexcomSettingsView(viewModel: .init())
+        let navController = UINavigationController()
+        let dexcomSettingsView = DexcomSettingsView(viewModel: .init(), usesModalCloseButton: true, onContinueToUnits: { [weak navController] in
+            let unitsView = UnitsOnboardingView {
+                navController?.dismiss(animated: true)
+            }
+            let unitsController = UIHostingController(rootView: unitsView)
+            let style = Storage.shared.appearanceMode.value.userInterfaceStyle
+            unitsController.overrideUserInterfaceStyle = style
+            navController?.pushViewController(unitsController, animated: true)
+        })
         let hostingController = UIHostingController(rootView: dexcomSettingsView)
-        let navController = UINavigationController(rootViewController: hostingController)
 
         // Apply appearance mode
         let style = Storage.shared.appearanceMode.value.userInterfaceStyle
         hostingController.overrideUserInterfaceStyle = style
         navController.overrideUserInterfaceStyle = style
 
+        navController.setViewControllers([hostingController], animated: false)
         hostingController.navigationItem.rightBarButtonItem = makeCloseBarButtonItem()
 
         navController.modalPresentationStyle = .pageSheet