Procházet zdrojové kódy

Add glucose color gradient preview to UI settings

Mike Plante před 6 dny
rodič
revize
dc418dc3e7

+ 8 - 0
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -111043,6 +111043,9 @@
         }
       }
     },
+    "Dynamic (Default):" : {
+
+    },
     "Dynamic Insulin Sensitivity" : {
       "localizations" : {
         "bg" : {
@@ -112169,6 +112172,7 @@
       }
     },
     "Dynamic:" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {
@@ -222383,6 +222387,9 @@
         }
       }
     },
+    "Purple = Above Range" : {
+
+    },
     "Quantity Carbs" : {
       "localizations" : {
         "bg" : {
@@ -314392,6 +314399,7 @@
       }
     },
     "Yellow = Above Range" : {
+      "extractionState" : "stale",
       "localizations" : {
         "bg" : {
           "stringUnit" : {

+ 3 - 1
Trio/Sources/Modules/UserInterfaceSettings/UserInterfaceSettingsDataFlow.swift

@@ -2,4 +2,6 @@ enum UserInterfaceSettings {
     enum Config {}
 }
 
-protocol UserInterfaceSettingsProvider: Provider {}
+protocol UserInterfaceSettingsProvider: Provider {
+    func getBGTargets() async -> BGTargets
+}

+ 7 - 1
Trio/Sources/Modules/UserInterfaceSettings/UserInterfaceSettingsProvider.swift

@@ -1,3 +1,9 @@
 extension UserInterfaceSettings {
-    final class Provider: BaseProvider, UserInterfaceSettingsProvider {}
+    final class Provider: BaseProvider, UserInterfaceSettingsProvider {
+        func getBGTargets() async -> BGTargets {
+            await storage.retrieveAsync(OpenAPS.Settings.bgTargets, as: BGTargets.self)
+                ?? BGTargets(from: OpenAPS.defaults(for: OpenAPS.Settings.bgTargets))
+                ?? BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: [])
+        }
+    }
 }

+ 48 - 0
Trio/Sources/Modules/UserInterfaceSettings/UserInterfaceSettingsStateModel.swift

@@ -15,6 +15,7 @@ extension UserInterfaceSettings {
         @Published var eA1cDisplayUnit: EstimatedA1cDisplayUnit = .percent
         @Published var timeInRangeType: TimeInRangeType = .timeInTightRange
         @Published var requireAdjustmentsConfirmation: Bool = false
+        @Published var currentGlucoseTarget: Decimal = 100
 
         var units: GlucoseUnits = .mgdL
 
@@ -48,6 +49,53 @@ extension UserInterfaceSettings {
 
             subscribeSetting(\.requireAdjustmentsConfirmation, on: $requireAdjustmentsConfirmation) {
                 requireAdjustmentsConfirmation = $0 }
+
+            Task { await getCurrentGlucoseTarget() }
+        }
+
+        /// Resolves the glucose target active right now from the BG target schedule.
+        /// NOTE: same logic as HomeStateModel/AdjustmentsStateModel — candidate for a shared helper.
+        func getCurrentGlucoseTarget() async {
+            let now = Date()
+            let calendar = Calendar.current
+            let dateFormatter = DateFormatter()
+            dateFormatter.dateFormat = "HH:mm:ss"
+            dateFormatter.timeZone = TimeZone.current
+
+            let bgTargets = await provider.getBGTargets()
+            let entries: [(start: String, value: Decimal)] = bgTargets.targets.map { ($0.start, $0.low) }
+
+            for (index, entry) in entries.enumerated() {
+                guard let entryTime = dateFormatter.date(from: entry.start) else { continue }
+
+                let entryComponents = calendar.dateComponents([.hour, .minute, .second], from: entryTime)
+                let entryStartTime = calendar.date(
+                    bySettingHour: entryComponents.hour!,
+                    minute: entryComponents.minute!,
+                    second: entryComponents.second!,
+                    of: now
+                )!
+
+                let entryEndTime: Date
+                if index < entries.count - 1,
+                   let nextEntryTime = dateFormatter.date(from: entries[index + 1].start)
+                {
+                    let nextEntryComponents = calendar.dateComponents([.hour, .minute, .second], from: nextEntryTime)
+                    entryEndTime = calendar.date(
+                        bySettingHour: nextEntryComponents.hour!,
+                        minute: nextEntryComponents.minute!,
+                        second: nextEntryComponents.second!,
+                        of: now
+                    )!
+                } else {
+                    entryEndTime = calendar.date(byAdding: .day, value: 1, to: entryStartTime)!
+                }
+
+                if now >= entryStartTime, now < entryEndTime {
+                    await MainActor.run { currentGlucoseTarget = entry.value }
+                    return
+                }
+            }
         }
     }
 }

+ 93 - 1
Trio/Sources/Modules/UserInterfaceSettings/View/UserInterfaceSettingsRootView.swift

@@ -132,15 +132,22 @@ extension UserInterfaceSettings {
                                                     Text(
                                                         "Gradient Red = As readings approach and exceed below target, they gradually become more red."
                                                     )
+                                                    .fixedSize(horizontal: false, vertical: true)
                                                     Text(
                                                         "Gradient Purple = As readings approach and exceed above target, they become more purple."
                                                     )
+                                                    .fixedSize(horizontal: false, vertical: true)
+                                                    GlucoseColorGradientPreview(
+                                                        units: state.units,
+                                                        target: state.currentGlucoseTarget
+                                                    )
+                                                    .padding(.top, 6)
                                                 }
                                                 VStack(alignment: .leading, spacing: 5) {
                                                     Text("Static:").bold()
                                                     Text("Red = Below Range")
                                                     Text("Green = In Range")
-                                                    Text("Yellow = Above Range")
+                                                    Text("Purple = Above Range")
                                                 }
                                             }
                                         )
@@ -607,3 +614,88 @@ extension UserInterfaceSettings {
         }
     }
 }
+
+/// Illustrative bar that samples `getDynamicGlucoseColor` across the glucose range,
+/// so the hint shows exactly how the dynamic scheme colors readings from low to high.
+private struct GlucoseColorGradientPreview: View {
+    let units: GlucoseUnits
+    // User's current glucose target — the green peak of the dynamic sweep.
+    let target: Decimal
+
+    private let minGlucose: Decimal = 40
+    private let maxGlucose: Decimal = 250
+
+    // Fixed anchors the dynamic scheme uses on the glucose graph: solid red at/below,
+    // solid purple at/above, sweeping red → green → purple between them.
+    private let hardCodedLow: Decimal = 55
+    private let hardCodedHigh: Decimal = 220
+
+    private var markerValues: [Decimal] {
+        [hardCodedLow, target, hardCodedHigh]
+    }
+
+    private var gradientStops: [Gradient.Stop] {
+        let steps = 60
+        return (0 ... steps).map { step in
+            let location = Double(step) / Double(steps)
+            let value = minGlucose + (maxGlucose - minGlucose) * Decimal(location)
+            return Gradient.Stop(
+                color: getDynamicGlucoseColor(
+                    glucoseValue: value,
+                    highGlucoseColorValue: hardCodedHigh,
+                    lowGlucoseColorValue: hardCodedLow,
+                    targetGlucose: target,
+                    glucoseColorScheme: .dynamicColor
+                ),
+                location: location
+            )
+        }
+    }
+
+    private func fraction(of value: Decimal) -> CGFloat {
+        let raw = (value - minGlucose) / (maxGlucose - minGlucose)
+        return min(max(CGFloat(truncating: raw as NSNumber), 0), 1)
+    }
+
+    private func axisValue(_ value: Decimal) -> String {
+        let display = units == .mgdL ? value : value.asMmolL
+        return "\(display)"
+    }
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 3) {
+            RoundedRectangle(cornerRadius: 8)
+                .fill(LinearGradient(
+                    gradient: Gradient(stops: gradientStops),
+                    startPoint: .leading,
+                    endPoint: .trailing
+                ))
+                .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(Color.primary.opacity(0.12), lineWidth: 1))
+                .frame(height: 26)
+                .shadow(color: .black.opacity(0.18), radius: 3, y: 1)
+
+            GeometryReader { geo in
+                ZStack(alignment: .topLeading) {
+                    ForEach(markerValues, id: \.self) { value in
+                        marker(value, at: fraction(of: value), width: geo.size.width)
+                    }
+                }
+            }
+            .frame(height: 22)
+        }
+        .accessibilityHidden(true)
+    }
+
+    private func marker(_ value: Decimal, at fraction: CGFloat, width: CGFloat) -> some View {
+        VStack(spacing: 1) {
+            Image(systemName: "arrowtriangle.up.fill")
+                .font(.system(size: 7))
+                .foregroundColor(.primary.opacity(0.55))
+            Text(axisValue(value))
+                .font(.caption2)
+                .foregroundColor(.secondary)
+        }
+        .fixedSize()
+        .position(x: min(max(width * fraction, 12), width - 12), y: 11)
+    }
+}