Преглед изворни кода

Surface CGM lifecycle on the bobble: 48h arc/tag gate, warmup countdown, timer-driven status refresh

trioneer пре 2 недеља
родитељ
комит
599955c42b

+ 2 - 7
Trio/Sources/APS/CGM/PluginSource.swift

@@ -27,6 +27,8 @@ final class PluginSource: GlucoseSource {
         cgmManager = glucoseManager.cgmManager
         cgmManager?.delegateQueue = processQueue
         cgmManager?.cgmManagerDelegate = self
+        // didSet doesn't fire from the defining class's own init.
+        publishCGMStatus()
     }
 
     /// Republishes the manager's lifecycle/highlight to the subjects.
@@ -140,11 +142,6 @@ extension PluginSource: CGMManagerDelegate {
                 debug(.deviceManager, "CGM PLUGIN - unable to read CGM result")
             }
 
-            // New reading means `latestReading` (and thus lifecycle /
-            // highlight) advanced — republish so the home arc + tag pick
-            // it up. `cgmManagerDidUpdateState` only fires on config-level
-            // changes, which means cold-start (no reading yet) would leave
-            // the subjects stuck at nil until something else triggered.
             self.publishCGMStatus()
 
             debug(.deviceManager, "CGM PLUGIN - Direct return done")
@@ -197,8 +194,6 @@ extension PluginSource: CGMManagerDelegate {
                 newManager: cgmManager as? CGMManagerUI
             )
 
-            // Pick up manager-internal state changes (sensor swap, warmup,
-            // expiry crossover) when the instance itself stays the same.
             self.publishCGMStatus()
         }
     }

+ 49 - 19
Trio/Sources/Modules/Home/HomeStateModel.swift

@@ -30,7 +30,7 @@ extension Home {
             CGMSettings.StateModel.shared
         }
 
-        private let timer = DispatchTimer(timeInterval: 5)
+        private let timer = DispatchTimer(timeInterval: 30)
         private(set) var filteredHours = 24
         var startMarker = Date(timeIntervalSinceNow: TimeInterval(hours: -24))
         var endMarker = Date(timeIntervalSinceNow: TimeInterval(hours: 3))
@@ -113,6 +113,7 @@ extension Home {
         var cgmDisplayState: CgmDisplayState?
         var cgmProgressHighlight: DeviceLifecycleProgress?
         var cgmSensorExpiresAt: Date?
+        var cgmWarmupEndsAt: Date?
         var listOfCGM: [CGMModel] = []
         var cgmCurrent = cgmDefaultModel
         var pumpInitialSettings = PumpConfig.PumpInitialSettings.default
@@ -327,7 +328,28 @@ extension Home {
 
             timer.eventHandler = {
                 DispatchQueue.main.async { [weak self] in
-                    self?.timerDate = Date()
+                    guard let self else { return }
+                    self.timerDate = Date()
+                    // The publisher only re-emits on state changes; re-pull
+                    // so the arc + countdowns + status text advance during
+                    // warmup / stabilizing / expiry.
+                    let manager = self.fetchGlucoseManager.cgmManager
+                    let progress = manager?.cgmLifecycleProgress
+                    self.cgmProgressHighlight = progress
+                    if let highlight = manager?.cgmStatusHighlight {
+                        self.cgmDisplayState = CgmDisplayState(
+                            localizedMessage: highlight.localizedMessage,
+                            status: CgmDisplayStatus.from(highlight.state)
+                        )
+                    } else {
+                        self.cgmDisplayState = nil
+                    }
+                    self.cgmSensorExpiresAt = Self.resolveSensorExpiresAt(
+                        manager: manager,
+                        glucoseSource: self.fetchGlucoseManager.glucoseSource,
+                        lifecycle: progress
+                    )
+                    self.cgmWarmupEndsAt = Self.resolveWarmupEndsAt(manager: manager)
                 }
             }
             timer.resume()
@@ -346,6 +368,9 @@ extension Home {
                         glucoseSource: self.fetchGlucoseManager.glucoseSource,
                         lifecycle: progress
                     )
+                    self.cgmWarmupEndsAt = Self.resolveWarmupEndsAt(
+                        manager: self.fetchGlucoseManager.cgmManager
+                    )
                 }
                 .store(in: &lifetime)
 
@@ -666,23 +691,9 @@ extension Home {
             }
         }
 
-        /// Pulls `cgmStatusHighlight` + `cgmLifecycleProgress` off the active
-        /// `CGMManagerUI` and derives the bobble's outer-arc/tag display state.
-        /// Called from the home timer (every 5s) and on subscribe. Cheap — both
-        /// LoopKit properties are computed accessors backed by the manager's
-        /// in-memory sensor state.
-        ///
-        /// Falls back to `GlucoseSimulatorSource`'s synthetic values when no
-        /// Best available `sensorExpiresAt` for the home label:
-        /// 1. Some managers expose it directly (G7 `sensorExpiresAt`, G5/G6
-        ///    `latestReading.sessionExpDate`).
-        /// 2. Others only expose `activatedAt` — combined with
-        ///    `lifecycle.percentComplete` we reverse-derive total lifetime.
-        ///    Critically `activatedAt` must be the *session* start, not the
-        ///    transmitter activation date, or the math blows up (an Anubis-
-        ///    modded G6 with `Glucose.activationDate` months old produced
-        ///    "7337d remaining" — bug, fixed by reading session start instead).
-        /// 3. Falls through to nil — UI drops the time label.
+        /// Sensor expiration for the home label. Prefers manager-reported
+        /// dates; reverse-derives from `lifecycle.percentComplete` when not.
+        /// `activatedAt` must be session start, not transmitter activation.
         private static func resolveSensorExpiresAt(
             manager: CGMManagerUI?,
             glucoseSource: GlucoseSource?,
@@ -713,6 +724,25 @@ extension Home {
             guard elapsed > 0 else { return nil }
             return activatedAt.addingTimeInterval(elapsed / lifecycle.percentComplete)
         }
+
+        /// Wall-clock end of the sensor's warmup window; `nil` when not warming up.
+        private static func resolveWarmupEndsAt(manager: CGMManagerUI?) -> Date? {
+            guard let manager else { return nil }
+            if let g7 = manager as? G7CGMManager {
+                guard let ends = g7.sensorFinishesWarmupAt, ends > Date() else { return nil }
+                return ends
+            }
+            if let g6 = manager as? G6CGMManager, let start = g6.latestReading?.sessionStartDate {
+                let window: TimeInterval = g6.isAnubis ? 50 * 60 : 2 * 60 * 60
+                let ends = start.addingTimeInterval(window)
+                return ends > Date() ? ends : nil
+            }
+            if let g5 = manager as? G5CGMManager, let start = g5.latestReading?.sessionStartDate {
+                let ends = start.addingTimeInterval(2 * 60 * 60)
+                return ends > Date() ? ends : nil
+            }
+            return nil
+        }
     }
 }
 

+ 47 - 16
Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift

@@ -19,6 +19,8 @@ struct CurrentGlucoseView: View {
     var cgmStatus: CgmDisplayState?
     /// Sensor expiration — fallback tag when `cgmStatus` is nil.
     var cgmSensorExpiresAt: Date?
+    /// Wall-clock end of the warmup window. Drives the warmup countdown tag.
+    var cgmWarmupEndsAt: Date?
 
     @State private var rotationDegrees: Double = 0.0
     @State private var angularGradient = AngularGradient(colors: [
@@ -52,7 +54,7 @@ struct CurrentGlucoseView: View {
 
         if cgmAvailable {
             ZStack {
-                if let progress = cgmProgress {
+                if let progress = cgmProgress, shouldShowArc {
                     SensorLifecycleArcView(
                         progress: progress.percentComplete,
                         progressState: progress.progressState
@@ -67,13 +69,10 @@ struct CurrentGlucoseView: View {
                 }
             }
             .overlay(alignment: .bottom) {
-                // Tag floats outside the bobble's frame so it doesn't push
-                // chart / stats / tab bar down. When the trend triangle
-                // rotates into the lower half (`rotationDegrees >= 45`) it
-                // ends up around 6 o'clock and collides with the tag —
-                // hide the SensorStatusTagView to avoid collision
+                // Overlay (not VStack) so the tag doesn't push siblings down;
+                // hidden when the trend arrow rotates toward 6 o'clock.
                 if let tag = tagLabel, !trendIsDownward {
-                    SensorStatusTagView(text: tag.text, theme: tag.theme)
+                    SensorStatusTagView(text: tag.text, theme: tag.theme, iconSystemName: tag.icon)
                         .offset(y: 14)
                         .zIndex(1)
                 }
@@ -171,17 +170,49 @@ struct CurrentGlucoseView: View {
         return Date().timeIntervalSince(date) < 12 * 60
     }
 
-    /// True when the trend arrow is rotated into the lower half of the
-    /// circle — used to decide whether the bottom tag needs to dodge the
-    /// triangle by sliding up onto the bobble's rim.
-    private var trendIsDownward: Bool { rotationDegrees >= 45 }
+    private var trendIsDownward: Bool { rotationDegrees >= 90 }
 
-    /// Status highlight wins; otherwise fall back to remaining-time.
-    private var tagLabel: (text: String, theme: SensorStatusTagTheme)? {
+    /// Arc shown for warmup, the last 48 h of a time-based sensor (incl.
+    /// grace period), or any non-normal state from a battery-based manager.
+    private var shouldShowArc: Bool {
+        if isInWarmup { return true }
+        if let expiresAt = cgmSensorExpiresAt {
+            return expiresAt.timeIntervalSinceNow <= 48 * 60 * 60
+        }
+        return cgmProgress?.progressState != .normalCGM
+    }
+
+    /// String sniff — loopandlearn LoopKit has no structural warmup flag.
+    private var isInWarmup: Bool {
+        guard let message = cgmStatus?.localizedMessage else { return false }
+        let lowered = message.lowercased()
+        return lowered.contains("warming up") || lowered.contains("warmup")
+    }
+
+    private var isStabilizing: Bool {
+        cgmStatus?.localizedMessage.lowercased().contains("stabilizing") ?? false
+    }
+
+    /// Warmup → hourglass + countdown; stabilizing → hourglass + "Stabilizing"
+    /// (no countdown — duration is sensor-driven); otherwise status verbatim,
+    /// otherwise time-to-expiry.
+    private var tagLabel: (text: String, theme: SensorStatusTagTheme, icon: String?)? {
+        if isInWarmup {
+            let text: String
+            if let endsAt = cgmWarmupEndsAt {
+                text = SensorRemainingTimeFormatter.format(until: endsAt)
+            } else {
+                text = "Warming up"
+            }
+            return (text, .orange, "hourglass")
+        }
+        if isStabilizing {
+            return ("Stabilizing", .orange, "hourglass")
+        }
         if let status = cgmStatus {
-            return (status.localizedMessage, theme(for: status.status))
+            return (status.localizedMessage, theme(for: status.status), nil)
         }
-        if let expiresAt = cgmSensorExpiresAt {
+        if let expiresAt = cgmSensorExpiresAt, expiresAt.timeIntervalSinceNow <= 48 * 60 * 60 {
             let text = SensorRemainingTimeFormatter.format(until: expiresAt)
             let theme: SensorStatusTagTheme
             switch cgmProgress?.progressState {
@@ -189,7 +220,7 @@ struct CurrentGlucoseView: View {
             case .warning: theme = .orange
             default: theme = .green
             }
-            return (text, theme)
+            return (text, theme, nil)
         }
         return nil
     }

+ 9 - 5
Trio/Sources/Modules/Home/View/Header/SensorLifecycle/CGMSensorDisplayState.swift

@@ -6,13 +6,17 @@ import Foundation
 enum SensorRemainingTimeFormatter {
     static func format(until expiresAt: Date, now: Date = Date()) -> String {
         let remaining = max(0, expiresAt.timeIntervalSince(now))
-        let totalMinutes = Int(remaining / 60)
+        let totalMinutes = Int(remaining) / 60
         let days = totalMinutes / (60 * 24)
         let hours = (totalMinutes / 60) % 24
         let minutes = totalMinutes % 60
-        if days > 0, hours > 0 { return "\(days)d \(hours)h" }
-        if days > 0 { return "\(days)d" }
-        if hours > 0 { return "\(hours)h" }
-        return "\(minutes)m"
+        let d = String(localized: "d", comment: "Abbreviation for Days")
+        let h = String(localized: "h", comment: "Abbreviation for Hours")
+        let m = String(localized: "m", comment: "Abbreviation for Minutes")
+        if days > 0, hours > 0 { return "\(days)\(d) \(hours)\(h)" }
+        if days > 0 { return "\(days)\(d)" }
+        if hours > 0 { return "\(hours)\(h)" }
+        if minutes > 0 { return "\(minutes)\(m)" }
+        return "<" + "\u{00A0}" + "1" + "\u{00A0}" + m
     }
 }

+ 8 - 1
Trio/Sources/Modules/Home/View/Header/SensorLifecycle/SensorStatusTagView.swift

@@ -4,9 +4,16 @@ import SwiftUI
 struct SensorStatusTagView: View {
     let text: String
     let theme: SensorStatusTagTheme
+    var iconSystemName: String?
 
     var body: some View {
-        HStack {
+        HStack(spacing: 4) {
+            if let iconSystemName {
+                Image(systemName: iconSystemName)
+                    .font(.callout)
+                    .fontWeight(.bold)
+                    .foregroundStyle(theme.textColor)
+            }
             Text(text)
                 .font(.callout)
                 .fontWeight(.bold)

+ 2 - 1
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -136,7 +136,8 @@ extension Home {
                 glucose: state.latestTwoGlucoseValues,
                 cgmProgress: state.cgmProgressHighlight,
                 cgmStatus: state.cgmDisplayState,
-                cgmSensorExpiresAt: state.cgmSensorExpiresAt
+                cgmSensorExpiresAt: state.cgmSensorExpiresAt,
+                cgmWarmupEndsAt: state.cgmWarmupEndsAt
             ).scaleEffect(0.9)
                 .onTapGesture {
                     if !state.cgmAvailable {