Quellcode durchsuchen

Merge branch 'feat/move-snooze-all-to-settings' of github.com:trioneer-dev/Trio into feat/move-snooze-all-to-settings

trioneer vor 1 Woche
Ursprung
Commit
e83a252fca

+ 1 - 1
CGMBLEKit

@@ -1 +1 @@
-Subproject commit 98fae7929c8c8e4e849d18a70c1f249dd6c09e5f
+Subproject commit 26ad72115d3b98b05668d57b817b1bf961f8a721

+ 1 - 1
Config.xcconfig

@@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group
 
 // The developers set the version numbers, please leave them alone
 APP_VERSION = 0.8.3
-APP_DEV_VERSION = 0.8.3.8
+APP_DEV_VERSION = 0.8.3.10
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 

+ 20 - 0
Model/CoreDataStack.swift

@@ -113,11 +113,31 @@ class CoreDataStack: ObservableObject {
     func fetchPersistentHistory() async {
         do {
             try await fetchPersistentHistoryTransactionsAndChanges()
+        } catch let error as NSError where error.code == NSPersistentHistoryTokenExpiredError {
+            debug(.coreData, "Persistent history token expired; clearing token and replaying available history.")
+            await recoverFromExpiredHistoryToken()
         } catch {
             debug(.coreData, "\(error)")
         }
     }
 
+    /// Recovers the change-merge pipeline after the persistent history token expires.
+    private func recoverFromExpiredHistoryToken() async {
+        lastToken = nil
+        do {
+            try await fetchPersistentHistoryTransactionsAndChanges()
+        } catch {
+            debug(.coreData, "Replay after token reset failed; jumping to current token. \(error)")
+            let viewContext = persistentContainer.viewContext
+            let currentToken = persistentContainer.persistentStoreCoordinator
+                .currentPersistentHistoryToken(fromStores: nil)
+            lastToken = currentToken
+            await viewContext.perform {
+                viewContext.refreshAllObjects()
+            }
+        }
+    }
+
     private func fetchPersistentHistoryTransactionsAndChanges() async throws {
         let taskContext = newTaskContext()
         taskContext.name = "persistentHistoryContext"

+ 20 - 0
Trio.xcodeproj/project.pbxproj

@@ -136,6 +136,9 @@
 		38A504A425DD9C4000C5B9E8 /* UserDefaultsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A5049125DD9C4000C5B9E8 /* UserDefaultsExtensions.swift */; };
 		38A9260525F012D8009E3739 /* CarbRatios.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A9260425F012D8009E3739 /* CarbRatios.swift */; };
 		38AAF85525FFF846004AF583 /* CurrentGlucoseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AAF85425FFF846004AF583 /* CurrentGlucoseView.swift */; };
+		BD11C001000000000000C001 /* CGMSensorDisplayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11C000000000000000C001 /* CGMSensorDisplayState.swift */; };
+		BD11C001000000000000C003 /* SensorLifecycleArcView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11C000000000000000C003 /* SensorLifecycleArcView.swift */; };
+		BD11C001000000000000C004 /* SensorStatusTagView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11C000000000000000C004 /* SensorStatusTagView.swift */; };
 		38AEE73D25F0200C0013F05B /* TrioSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AEE73C25F0200C0013F05B /* TrioSettings.swift */; };
 		38AEE75225F022080013F05B /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AEE75125F022080013F05B /* SettingsManager.swift */; };
 		38AEE75725F0F18E0013F05B /* CarbsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AEE75625F0F18E0013F05B /* CarbsStorage.swift */; };
@@ -1150,6 +1153,9 @@
 		38A5049125DD9C4000C5B9E8 /* UserDefaultsExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserDefaultsExtensions.swift; sourceTree = "<group>"; };
 		38A9260425F012D8009E3739 /* CarbRatios.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbRatios.swift; sourceTree = "<group>"; };
 		38AAF85425FFF846004AF583 /* CurrentGlucoseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentGlucoseView.swift; sourceTree = "<group>"; };
+		BD11C000000000000000C001 /* CGMSensorDisplayState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGMSensorDisplayState.swift; sourceTree = "<group>"; };
+		BD11C000000000000000C003 /* SensorLifecycleArcView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorLifecycleArcView.swift; sourceTree = "<group>"; };
+		BD11C000000000000000C004 /* SensorStatusTagView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SensorStatusTagView.swift; sourceTree = "<group>"; };
 		38AEE73C25F0200C0013F05B /* TrioSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioSettings.swift; sourceTree = "<group>"; };
 		38AEE75125F022080013F05B /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = "<group>"; };
 		38AEE75625F0F18E0013F05B /* CarbsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbsStorage.swift; sourceTree = "<group>"; };
@@ -2631,11 +2637,22 @@
 				DDA6E2842D2361F800C2988C /* LoopStatusView.swift */,
 				383420D525FFE38C002D46C1 /* LoopView.swift */,
 				38AAF85425FFF846004AF583 /* CurrentGlucoseView.swift */,
+				BD11C002000000000000C000 /* SensorLifecycle */,
 				38DAB27F260CBB7F00F74C1A /* PumpView.swift */,
 			);
 			path = Header;
 			sourceTree = "<group>";
 		};
+		BD11C002000000000000C000 /* SensorLifecycle */ = {
+			isa = PBXGroup;
+			children = (
+				BD11C000000000000000C001 /* CGMSensorDisplayState.swift */,
+				BD11C000000000000000C003 /* SensorLifecycleArcView.swift */,
+				BD11C000000000000000C004 /* SensorStatusTagView.swift */,
+			);
+			path = SensorLifecycle;
+			sourceTree = "<group>";
+		};
 		3856933F270B57A00002C50D /* CGM */ = {
 			isa = PBXGroup;
 			children = (
@@ -5433,6 +5450,9 @@
 				BDF34F932C10D0E100D51995 /* LiveActivityAttributes+Helper.swift in Sources */,
 				E0D4F80527513ECF00BDF1FE /* HealthKitSample.swift in Sources */,
 				38AAF85525FFF846004AF583 /* CurrentGlucoseView.swift in Sources */,
+				BD11C001000000000000C001 /* CGMSensorDisplayState.swift in Sources */,
+				BD11C001000000000000C003 /* SensorLifecycleArcView.swift in Sources */,
+				BD11C001000000000000C004 /* SensorStatusTagView.swift in Sources */,
 				041D1E995A6AE92E9289DC49 /* TreatmentsDataFlow.swift in Sources */,
 				DD32CF9E2CC824C5003686D6 /* TrioRemoteControl+Override.swift in Sources */,
 				BD249D922D42FC5300412DEB /* GlucoseSectorChart.swift in Sources */,

+ 17 - 26
Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved

@@ -1,5 +1,5 @@
 {
-  "originHash" : "29c2d0190b27ae86bc9534bbc8a3bba9693842515e38f5e89a3dbf80573f962f",
+  "originHash" : "af604046297daf839c0c212e73929960a33a0fecfdb97b64b9356d8a98b7bd60",
   "pins" : [
     {
       "identity" : "abseil-cpp-binary",
@@ -69,17 +69,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/firebase/firebase-ios-sdk.git",
       "state" : {
-        "revision" : "fdc352fabaf5916e7faa1f96ad02b1957e93e5a5",
-        "version" : "11.15.0"
-      }
-    },
-    {
-      "identity" : "google-ads-on-device-conversion-ios-sdk",
-      "kind" : "remoteSourceControl",
-      "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
-      "state" : {
-        "revision" : "a2d0f1f1666de591eb1a811f40b1706f5c63a2ed",
-        "version" : "2.3.0"
+        "revision" : "d1f7c7e8eaa74d7e44467184dc5f592268247d33",
+        "version" : "11.11.0"
       }
     },
     {
@@ -87,8 +78,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/google/GoogleAppMeasurement.git",
       "state" : {
-        "revision" : "45ce435e9406d3c674dd249a042b932bee006f60",
-        "version" : "11.15.0"
+        "revision" : "dd89fc79a77183830742a16866d87e4e54785734",
+        "version" : "11.11.0"
       }
     },
     {
@@ -105,8 +96,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/google/GoogleUtilities.git",
       "state" : {
-        "revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
-        "version" : "8.1.0"
+        "revision" : "53156c7ec267db846e6b64c9f4c4e31ba4cf75eb",
+        "version" : "8.0.2"
       }
     },
     {
@@ -114,8 +105,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/google/grpc-binary.git",
       "state" : {
-        "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
-        "version" : "1.69.1"
+        "revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71",
+        "version" : "1.69.0"
       }
     },
     {
@@ -123,8 +114,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/google/gtm-session-fetcher.git",
       "state" : {
-        "revision" : "c756a29784521063b6a1202907e2cc47f41b667c",
-        "version" : "4.5.0"
+        "revision" : "4d70340d55d7d07cc2fdf8e8125c4c126c1d5f35",
+        "version" : "4.4.0"
       }
     },
     {
@@ -222,8 +213,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/apple/swift-log.git",
       "state" : {
-        "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523",
-        "version" : "1.10.1"
+        "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2",
+        "version" : "1.6.4"
       }
     },
     {
@@ -240,8 +231,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/apple/swift-protobuf.git",
       "state" : {
-        "revision" : "a008af1a102ff3dd6cc3764bb69bf63226d0f5f6",
-        "version" : "1.36.1"
+        "revision" : "d72aed98f8253ec1aa9ea1141e28150f408cf17f",
+        "version" : "1.29.0"
       }
     },
     {
@@ -267,8 +258,8 @@
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/Swinject/Swinject",
       "state" : {
-        "revision" : "b685b549fe4d8ae265fc7a2f27d0789720425d69",
-        "version" : "2.10.0"
+        "revision" : "be9dbcc7b86811bc131539a20c6f9c2d3e56919f",
+        "version" : "2.9.1"
       }
     },
     {

+ 98 - 1
Trio/Sources/APS/CGM/AppGroupSource.swift

@@ -30,6 +30,9 @@ struct AppGroupSource: GlucoseSource {
     let from: String
     var cgmType: CGMType
 
+    let cgmDisplayState = CurrentValueSubject<CgmDisplayState?, Never>(nil)
+    let cgmProgressHighlight = CurrentValueSubject<LoopKit.DeviceLifecycleProgress?, Never>(nil)
+
     func fetch(_ heartbeat: DispatchTimer?) -> AnyPublisher<[BloodGlucose], Never> {
         guard let suiteName = Bundle.main.appGroupSuiteName,
               let sharedDefaults = UserDefaults(suiteName: suiteName)
@@ -52,7 +55,21 @@ struct AppGroupSource: GlucoseSource {
         HeartBeatManager.shared.checkCGMBluetoothTransmitter(sharedUserDefaults: sharedDefaults, heartbeat: heartbeat)
         debug(.deviceManager, "APPGROUP : START FETCH LAST BG ")
         let decoded = try? JSONSerialization.jsonObject(with: sharedData, options: [])
-        guard let sgvs = decoded as? [AnyObject] else {
+
+        // Two shapes accepted:
+        //   Legacy (xDrip4iOS today): top-level array of reading dicts.
+        //   Rich (xDrip4iOS extended for CGM lifecycle):
+        //   top-level dict carrying readings under
+        //   `recentReadings` plus sibling keys for CGM status, sensor
+        //   lifecycle, and transmitter info — see `applyRichState`.
+        let sgvs: [AnyObject]
+        if let dict = decoded as? [String: Any] {
+            applyRichState(dict)
+            sgvs = (dict["recentReadings"] as? [AnyObject]) ?? []
+        } else if let arr = decoded as? [AnyObject] {
+            applyRichState(nil)
+            sgvs = arr
+        } else {
             return []
         }
 
@@ -101,6 +118,81 @@ struct AppGroupSource: GlucoseSource {
         return results
     }
 
+    /// Reads the rich top-level dict from xDrip4iOS (when present) and
+    /// pushes status + lifecycle into the publishers HomeStateModel
+    /// subscribes to. Defensive on every key — xdrip ships partial dicts
+    /// during warmup / failure / between sensors.
+    private func applyRichState(_ payload: [String: Any]?) {
+        guard let payload else {
+            cgmDisplayState.value = nil
+            cgmProgressHighlight.value = nil
+            return
+        }
+
+        let cgm = payload["cgm"] as? [String: Any]
+        cgmDisplayState.value = parseStatus(cgm?["status"] as? [String: Any])
+        cgmProgressHighlight.value = parseSensorLifecycle(cgm?["sensor"] as? [String: Any])
+    }
+
+    private func parseStatus(_ status: [String: Any]?) -> CgmDisplayState? {
+        guard let status,
+              let message = status["localizedMessage"] as? String,
+              !message.isEmpty
+        else { return nil }
+        return CgmDisplayState(
+            localizedMessage: message,
+            imageName: (status["imageName"] as? String) ?? "",
+            status: cgmDisplayStatus(forCode: status["displayState"] as? String ?? status["code"] as? String)
+        )
+    }
+
+    /// xDrip4iOS sends a free-form code string (e.g. "normal", "warning",
+    /// "critical", "warmup", "calibration_needed", "sensor_failed"). We
+    /// fold anything unfamiliar into `.warning` so unknown future codes
+    /// surface visibly instead of going silent.
+    private func cgmDisplayStatus(forCode code: String?) -> CgmDisplayStatus {
+        switch code?.lowercased() {
+        case nil,
+             "normal",
+             "ok": return .normal
+        case "critical",
+             "expired",
+             "sensor_failed",
+             "session_failed",
+             "stopped": return .critical
+        default: return .warning
+        }
+    }
+
+    private func parseSensorLifecycle(_ sensor: [String: Any]?) -> DeviceLifecycleProgress? {
+        guard let sensor else { return nil }
+        let percent = (sensor["percentComplete"] as? NSNumber)?.doubleValue
+        guard let percent else { return nil }
+        let progressState = lifecycleProgressState(
+            for: sensor["progressState"] as? String,
+            isInWarmup: sensor["isInWarmup"] as? Bool ?? false,
+            isExpired: sensor["isExpired"] as? Bool ?? false
+        )
+        return AppGroupLifecycleProgress(
+            percentComplete: max(0, min(1, percent)),
+            progressState: progressState
+        )
+    }
+
+    private func lifecycleProgressState(
+        for code: String?,
+        isInWarmup: Bool,
+        isExpired: Bool
+    ) -> DeviceLifecycleProgressState {
+        if isExpired { return .critical }
+        if isInWarmup { return .normalCGM }
+        switch code?.lowercased() {
+        case "critical": return .critical
+        case "warning": return .warning
+        default: return .normalCGM
+        }
+    }
+
     private func parseDate(_ timestamp: String) -> Date? {
         // timestamp looks like "/Date(1462404576000)/"
         guard let re = try? NSRegularExpression(pattern: "\\((.*)\\)"),
@@ -119,6 +211,11 @@ struct AppGroupSource: GlucoseSource {
     }
 }
 
+private struct AppGroupLifecycleProgress: DeviceLifecycleProgress {
+    let percentComplete: Double
+    let progressState: DeviceLifecycleProgressState
+}
+
 public extension Bundle {
     var appGroupSuiteName: String? {
         object(forInfoDictionaryKey: "AppGroupID") as? String

+ 228 - 0
Trio/Sources/APS/CGM/GlucoseSimulatorSource.swift

@@ -13,7 +13,9 @@
 ///  - OscillatingGenerator: BloodGlucoseGenerator - Generates sinusoidal glucose values around a center point
 
 import Combine
+import CoreData
 import Foundation
+import LoopKit
 import LoopKitUI
 
 // MARK: - Glucose simulator
@@ -25,6 +27,9 @@ final class GlucoseSimulatorSource: GlucoseSource {
     var cgmManager: CGMManagerUI?
     var glucoseManager: FetchGlucoseManager?
 
+    let cgmDisplayState = CurrentValueSubject<CgmDisplayState?, Never>(nil)
+    let cgmProgressHighlight = CurrentValueSubject<DeviceLifecycleProgress?, Never>(nil)
+
     private enum Config {
         /// Minimum time period between data publications (in seconds)
         static let workInterval: TimeInterval = 300
@@ -49,6 +54,57 @@ final class GlucoseSimulatorSource: GlucoseSource {
             }
             lastFetchDate = lastDate
         }
+        publishSimulatedState()
+    }
+
+    /// Republishes synthetic lifecycle/highlight from `simulatedScenario`.
+    func publishSimulatedState() {
+        cgmProgressHighlight.value = cgmLifecycleProgress
+        if let highlight = cgmStatusHighlight {
+            cgmDisplayState.value = CgmDisplayState(
+                localizedMessage: highlight.localizedMessage,
+                imageName: highlight.imageName,
+                status: CgmDisplayStatus.from(highlight.state)
+            )
+        } else {
+            cgmDisplayState.value = nil
+        }
+    }
+
+    /// Picker entry point — change scenario + propagate immediately. When
+    /// flipping to a state where a real sensor wouldn't be delivering fresh
+    /// readings, drop any GlucoseStored rows inside the home view's 12 min
+    /// freshness window so the bobble switches to its compact stale view
+    /// right away instead of waiting for organic aging.
+    func applySimulatedScenario(_ scenario: SimulatedSensorScenario) {
+        simulatedScenario = scenario
+        if !scenario.deliversFreshGlucose {
+            clearRecentSimulatorReadings()
+        }
+        publishSimulatedState()
+    }
+
+    /// Deletes GlucoseStored rows newer than the home view's 12 min
+    /// freshness window. Dev-only — only invoked from the simulator's
+    /// scenario picker, which is itself gated to simulator mode.
+    private func clearRecentSimulatorReadings() {
+        let context = CoreDataStack.shared.newTaskContext()
+        let cutoff = Date().addingTimeInterval(-12 * 60)
+        context.perform {
+            let request = GlucoseStored.fetchRequest()
+            request.predicate = NSPredicate(format: "date > %@", cutoff as NSDate)
+            do {
+                let recent = try context.fetch(request)
+                for row in recent {
+                    context.delete(row)
+                }
+                if context.hasChanges {
+                    try context.save()
+                }
+            } catch {
+                print("GlucoseSimulatorSource: clearRecentSimulatorReadings failed: \(error)")
+            }
+        }
     }
 
     /// The glucose generator used to create simulated values
@@ -74,6 +130,14 @@ final class GlucoseSimulatorSource: GlucoseSource {
         guard canGenerateNewValues else {
             return Just([]).eraseToAnyPublisher()
         }
+        // Match real CGM behavior: scenarios where a physical sensor wouldn't
+        // be delivering readings (warmup, calibration, expired, failed) also
+        // stop the simulator from emitting fresh values. Existing readings
+        // then age out of the 12 min freshness window, the bobble flips to
+        // its compact symbol view, and the highlight's imageName surfaces.
+        guard simulatedScenario.deliversFreshGlucose else {
+            return Just([]).eraseToAnyPublisher()
+        }
 
         let glucoses = generator.getBloodGlucoses(
             startDate: lastFetchDate,
@@ -94,6 +158,170 @@ final class GlucoseSimulatorSource: GlucoseSource {
     func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> {
         fetch(nil)
     }
+
+    // MARK: - Simulated sensor lifecycle / status (dev-only)
+
+    //
+    // The simulator doesn't own a real `CGMManagerUI`, so `cgmStatusHighlight`
+    // and `cgmLifecycleProgress` would be nil on the home screen. To make the
+    // outer arc + tag indicator actually exercisable without Libre/Dexcom
+    // hardware, the simulator exposes synthetic values driven by
+    // `simulatedScenario`. Flip the persisted enum at runtime (debug menu or
+    // by editing the default below) and the home view picks up the change on
+    // its next 5-second refresh tick.
+
+    /// Which pre-canned sensor state the simulator should advertise. Plain
+    /// `UserDefaults` string access (not `@Persisted`) so the CGM-settings
+    /// picker — which writes via `UserDefaults.standard.set(_:forKey:)` — and
+    /// the read here use the same encoding. `@Persisted` stores values as
+    /// JSON-wrapped `Data`, which would silently break the picker round-trip.
+    static let simulatedScenarioKey = "GlucoseSimulator.simulatedScenario"
+
+    var simulatedScenario: SimulatedSensorScenario {
+        get {
+            let raw = UserDefaults.standard.string(forKey: Self.simulatedScenarioKey)
+            return raw.flatMap(SimulatedSensorScenario.init(rawValue:)) ?? .runningNormally
+        }
+        set {
+            UserDefaults.standard.set(newValue.rawValue, forKey: Self.simulatedScenarioKey)
+        }
+    }
+
+    /// Synthetic expiration date that mirrors what a real CGM would expose.
+    /// Picked so the remaining-time label matches the scenario's
+    /// `percentComplete` against a 10-day total (Dexcom-like).
+    var simulatedSensorExpiresAt: Date? {
+        guard let progress = cgmLifecycleProgress else { return nil }
+        let totalLifetime: TimeInterval = 10 * 24 * 60 * 60
+        let remaining = (1.0 - progress.percentComplete) * totalLifetime
+        return Date().addingTimeInterval(remaining)
+    }
+
+    /// Synthetic outer-arc data — nil for scenarios where lifetime is moot
+    /// (warmup, hardware fault). Matches the production `DeviceLifecycleProgress`
+    /// shape so the home state model can treat both sources identically.
+    var cgmLifecycleProgress: DeviceLifecycleProgress? {
+        switch simulatedScenario {
+        case .runningNormally:
+            return SimulatedLifecycleProgress(percentComplete: 0.45, progressState: .normalCGM)
+        case .expiringSoon:
+            return SimulatedLifecycleProgress(percentComplete: 0.94, progressState: .warning)
+        case .warmup:
+            return nil
+        case .calibrationRequired:
+            return SimulatedLifecycleProgress(percentComplete: 0.30, progressState: .normalCGM)
+        case .expired:
+            return SimulatedLifecycleProgress(percentComplete: 1.0, progressState: .critical)
+        case .sensorFailed:
+            return nil
+        }
+    }
+
+    /// Synthetic status highlight. nil for `.runningNormally` and
+    /// `.expiringSoon` (those run off lifecycle only).
+    var cgmStatusHighlight: DeviceStatusHighlight? {
+        switch simulatedScenario {
+        case .expiringSoon,
+             .runningNormally:
+            return nil
+        case .warmup:
+            return SimulatedStatusHighlight(
+                localizedMessage: "Sensor warming up",
+                imageName: "hourglass",
+                state: .warning
+            )
+        case .calibrationRequired:
+            return SimulatedStatusHighlight(
+                localizedMessage: "Calibrate",
+                imageName: "drop.fill",
+                state: .warning
+            )
+        case .expired:
+            return SimulatedStatusHighlight(
+                localizedMessage: "Sensor expired",
+                imageName: "exclamationmark.circle.fill",
+                state: .critical
+            )
+        case .sensorFailed:
+            return SimulatedStatusHighlight(
+                localizedMessage: "Replace Sensor",
+                imageName: "exclamationmark.triangle.fill",
+                state: .critical
+            )
+        }
+    }
+}
+
+/// Pre-canned sensor scenarios surfaced by `GlucoseSimulatorSource`. One per
+/// home-screen state so flipping this drives the indicator
+/// through every visual state.
+enum SimulatedSensorScenario: String, CaseIterable, Identifiable {
+    case runningNormally
+    case expiringSoon
+    case warmup
+    case calibrationRequired
+    case expired
+    case sensorFailed
+
+    var id: String { rawValue }
+
+    var displayName: String {
+        switch self {
+        case .runningNormally: return "Running normally"
+        case .expiringSoon: return "Expiring soon"
+        case .warmup: return "Warmup"
+        case .calibrationRequired: return "Calibration required"
+        case .expired: return "Expired"
+        case .sensorFailed: return "Sensor failed"
+        }
+    }
+
+    /// Whether a real CGM would still be delivering fresh glucose readings
+    /// while in this state. Drives the simulator's `fetch()` gate so non-
+    /// active scenarios stop emitting and the home view sees stale data
+    /// the same way it would from a real sensor.
+    var deliversFreshGlucose: Bool {
+        switch self {
+        case .expiringSoon,
+             .runningNormally:
+            return true
+        case .calibrationRequired,
+             .expired,
+             .sensorFailed,
+             .warmup:
+            return false
+        }
+    }
+
+    /// Short blurb shown under the picker so dev users know what each
+    /// scenario renders on the home screen.
+    var devNotes: String {
+        switch self {
+        case .runningNormally:
+            return "Teal outer ring at ~45%, tag shows time remaining."
+        case .expiringSoon:
+            return "Amber outer ring at ~94%, tag shows time remaining with \"left\" suffix."
+        case .warmup:
+            return "Arc hidden, pulsing amber tag, glucose shown as \"– –\"."
+        case .calibrationRequired:
+            return "Arc visible, amber tag with \"Calibrate\" message, glucose still shown."
+        case .expired:
+            return "Red full ring, red tag \"sensor expired\", glucose shown as \"– –\"."
+        case .sensorFailed:
+            return "Arc hidden, pulsing red tag, glucose shown as \"– –\"."
+        }
+    }
+}
+
+private struct SimulatedLifecycleProgress: DeviceLifecycleProgress {
+    let percentComplete: Double
+    let progressState: DeviceLifecycleProgressState
+}
+
+private struct SimulatedStatusHighlight: DeviceStatusHighlight {
+    let localizedMessage: String
+    let imageName: String
+    let state: DeviceStatusHighlightState
 }
 
 // MARK: - Glucose generator

+ 4 - 0
Trio/Sources/APS/CGM/GlucoseSource.swift

@@ -11,6 +11,10 @@ protocol GlucoseSource: SourceInfoProvider {
     func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never>
     var glucoseManager: FetchGlucoseManager? { get set }
     var cgmManager: CGMManagerUI? { get set }
+    /// Mirrors `CGMManagerUI.cgmStatusHighlight`; republished on manager change.
+    var cgmDisplayState: CurrentValueSubject<CgmDisplayState?, Never> { get }
+    /// Mirrors `CGMManagerUI.cgmLifecycleProgress`.
+    var cgmProgressHighlight: CurrentValueSubject<LoopKit.DeviceLifecycleProgress?, Never> { get }
 }
 
 extension GlucoseSource {

+ 27 - 1
Trio/Sources/APS/CGM/PluginSource.swift

@@ -11,7 +11,12 @@ final class PluginSource: GlucoseSource {
     private let glucoseStorage: GlucoseStorage!
     var glucoseManager: FetchGlucoseManager?
 
-    var cgmManager: CGMManagerUI?
+    let cgmDisplayState = CurrentValueSubject<CgmDisplayState?, Never>(nil)
+    let cgmProgressHighlight = CurrentValueSubject<DeviceLifecycleProgress?, Never>(nil)
+
+    var cgmManager: CGMManagerUI? {
+        didSet { publishCGMStatus() }
+    }
 
     var cgmHasValidSensorSession: Bool = false
 
@@ -22,6 +27,23 @@ 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.
+    /// Called from `cgmManager.didSet` and after delegate state updates.
+    func publishCGMStatus() {
+        if let highlight = cgmManager?.cgmStatusHighlight {
+            cgmDisplayState.value = CgmDisplayState(
+                localizedMessage: highlight.localizedMessage,
+                imageName: highlight.imageName,
+                status: CgmDisplayStatus.from(highlight.state)
+            )
+        } else {
+            cgmDisplayState.value = nil
+        }
+        cgmProgressHighlight.value = cgmManager?.cgmLifecycleProgress
     }
 
     /// Function that fetches blood glucose data
@@ -145,6 +167,8 @@ extension PluginSource: CGMManagerDelegate {
                 debug(.deviceManager, "CGM PLUGIN - unable to read CGM result")
             }
 
+            self.publishCGMStatus()
+
             debug(.deviceManager, "CGM PLUGIN - Direct return done")
         }
     }
@@ -194,6 +218,8 @@ extension PluginSource: CGMManagerDelegate {
                 cgmGlucosePluginId: fetchGlucoseManager.settingsManager.settings.cgmPluginIdentifier,
                 newManager: cgmManager as? CGMManagerUI
             )
+
+            self.publishCGMStatus()
         }
     }
 

+ 3 - 0
Trio/Sources/APS/DeviceDataManager.swift

@@ -347,6 +347,9 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
     var cgmManager: CGMManagerUI?
     var cgmType: CGMType = .enlite
 
+    let cgmDisplayState = CurrentValueSubject<CgmDisplayState?, Never>(nil)
+    let cgmProgressHighlight = CurrentValueSubject<DeviceLifecycleProgress?, Never>(nil)
+
     func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> {
         fetch(nil)
     }

+ 23 - 1
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -19,6 +19,8 @@ protocol FetchGlucoseManager: SourceInfoProvider {
     var cgmGlucosePluginId: String { get }
     var settingsManager: SettingsManager! { get }
     var shouldSyncToRemoteService: Bool { get }
+    var cgmDisplayState: CurrentValueSubject<CgmDisplayState?, Never> { get }
+    var cgmProgressHighlight: CurrentValueSubject<DeviceLifecycleProgress?, Never> { get }
     /// Routes CGMManager-issued alerts (sensor failure, signal loss, expiry,
     /// etc.) into the unified `TrioAlertManager` pipeline. Read by
     /// `PluginSource.issueAlert` / `retractAlert`.
@@ -153,7 +155,27 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         }
     }
 
-    var glucoseSource: GlucoseSource?
+    let cgmDisplayState = CurrentValueSubject<CgmDisplayState?, Never>(nil)
+    let cgmProgressHighlight = CurrentValueSubject<DeviceLifecycleProgress?, Never>(nil)
+    private var cgmStatusSubscriptions = Set<AnyCancellable>()
+
+    var glucoseSource: GlucoseSource? {
+        didSet {
+            // Drop prior subscriptions so source swaps don't dupe emissions.
+            cgmStatusSubscriptions.removeAll()
+            cgmDisplayState.value = glucoseSource?.cgmDisplayState.value
+            cgmProgressHighlight.value = glucoseSource?.cgmProgressHighlight.value
+            guard let glucoseSource else { return }
+            glucoseSource.cgmDisplayState
+                .receive(on: DispatchQueue.main)
+                .sink { [weak self] state in self?.cgmDisplayState.value = state }
+                .store(in: &cgmStatusSubscriptions)
+            glucoseSource.cgmProgressHighlight
+                .receive(on: DispatchQueue.main)
+                .sink { [weak self] progress in self?.cgmProgressHighlight.value = progress }
+                .store(in: &cgmStatusSubscriptions)
+        }
+    }
 
     func removeCalibrations() {
         calibrationService.removeAllCalibrations()

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

@@ -4822,6 +4822,9 @@
         }
       }
     },
+    "– –" : {
+
+    },
     ", %lld%%" : {
       "localizations" : {
         "bg" : {
@@ -101309,6 +101312,9 @@
         }
       }
     },
+    "Drives the outer-ring + tag on the home screen's glucose bobble." : {
+
+    },
     "Due to how the curve is calculated when using the Sigmoid Formula, increasing this setting has a different impact on the steepness of the curve than in the standard logarithmic Dynamic ISF calculation. Use caution when adjusting this setting." : {
       "localizations" : {
         "bg" : {
@@ -214968,6 +214974,9 @@
         }
       }
     },
+    "Scenario" : {
+
+    },
     "Schedule " : {
       "extractionState" : "manual",
       "localizations" : {
@@ -220158,6 +220167,9 @@
         }
       }
     },
+    "Sensor Lifecycle Scenario" : {
+
+    },
     "Sensor might have temporarily stopped, fallen off or is too cold or too warm" : {
       "comment" : "ensor might have temporarily stopped, fallen off or is too cold or too warm",
       "extractionState" : "manual",

+ 39 - 0
Trio/Sources/Modules/CGMSettings/CGMSettingsDataFlow.swift

@@ -1,5 +1,44 @@
+import LoopKit
+import SwiftUI
+
 enum CGMSettings {
     enum Config {}
 }
 
 protocol CGMSettingsProvider: Provider {}
+
+/// `cgmStatusHighlight` reduced to message + tier — what the home UI needs.
+/// `imageName` is the SF Symbol name the manager wants to associate with this
+/// state (G6/G7/LibreLoop all surface this); empty string when the manager
+/// doesn't provide one.
+struct CgmDisplayState: Equatable {
+    let localizedMessage: String
+    let imageName: String
+    let status: CgmDisplayStatus
+}
+
+enum CgmDisplayStatus {
+    case normal
+    case warning
+    case critical
+
+    var color: Color {
+        switch self {
+        case .critical: return .critical
+        case .warning: return .warning
+        case .normal: return .loopAccent
+        }
+    }
+
+    static func from(_ state: LoopKit.DeviceStatusHighlightState) -> CgmDisplayStatus {
+        switch state {
+        case .normalCGM,
+             .normalPump:
+            return .normal
+        case .warning:
+            return .warning
+        case .critical:
+            return .critical
+        }
+    }
+}

+ 35 - 0
Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift

@@ -22,6 +22,10 @@ extension CGMSettings {
         @State private var period: Double = UserDefaults.standard.double(forKey: "GlucoseSimulator_Period")
         @State private var noiseAmplitude: Double = UserDefaults.standard.double(forKey: "GlucoseSimulator_NoiseAmplitude")
         @State private var produceStaleValues: Bool = UserDefaults.standard.bool(forKey: "GlucoseSimulator_ProduceStaleValues")
+      
+        /// Drives the synthetic `cgmStatusHighlight`
+        @State private var simulatedScenarioRaw: String = UserDefaults.standard
+            .string(forKey: "GlucoseSimulator.simulatedScenario") ?? SimulatedSensorScenario.runningNormally.rawValue
 
         /// Routes "open URL failed" warnings through `TrioAlertManager` so
         /// they share the same in-app banner UI as the rest of the alert
@@ -282,6 +286,37 @@ extension CGMSettings {
                     }
                 }.listRowBackground(Color.chart)
 
+                Section(
+                    header: Text("Sensor Lifecycle Scenario"),
+                    footer: Text(
+                        "Drives the outer-ring + tag on the home screen's glucose bobble."
+                    )
+                ) {
+                    Picker("Scenario", selection: $simulatedScenarioRaw) {
+                        ForEach(SimulatedSensorScenario.allCases) { scenario in
+                            Text(scenario.displayName).tag(scenario.rawValue)
+                        }
+                    }
+                    .pickerStyle(.menu)
+                    .onChange(of: simulatedScenarioRaw) { _, newValue in
+                        UserDefaults.standard.set(newValue, forKey: "GlucoseSimulator.simulatedScenario")
+                        // Push the change through the active simulator
+                        // instance so subjects emit immediately.
+                        if let scenario = SimulatedSensorScenario(rawValue: newValue),
+                           let sim = resolver.resolve(FetchGlucoseManager.self)?.glucoseSource as? GlucoseSimulatorSource
+                        {
+                            sim.applySimulatedScenario(scenario)
+                        }
+                    }
+
+                    if let scenario = SimulatedSensorScenario(rawValue: simulatedScenarioRaw) {
+                        Text(scenario.devNotes)
+                            .font(.footnote)
+                            .foregroundStyle(Color.secondary)
+                            .lineLimit(nil)
+                    }
+                }.listRowBackground(Color.chart)
+
                 if !produceStaleValues {
                     Section {
                         VStack(alignment: .leading, spacing: 10) {

+ 128 - 2
Trio/Sources/Modules/Home/HomeStateModel.swift

@@ -1,7 +1,10 @@
+import CGMBLEKit
 import CGMBLEKitUI
 import Combine
 import CoreData
 import Foundation
+import G7SensorKit
+import LibreTransmitter
 import LoopKit
 import LoopKitUI
 import Observation
@@ -27,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))
@@ -107,6 +110,10 @@ extension Home {
         var pumpStatusBadgeImage: UIImage?
         var pumpStatusBadgeColor: Color?
         var cgmAvailable: Bool = false
+        var cgmDisplayState: CgmDisplayState?
+        var cgmProgressHighlight: DeviceLifecycleProgress?
+        var cgmSensorExpiresAt: Date?
+        var cgmWarmupEndsAt: Date?
         var listOfCGM: [CGMModel] = []
         var cgmCurrent = cgmDefaultModel
         var pumpInitialSettings = PumpConfig.PumpInitialSettings.default
@@ -321,11 +328,68 @@ 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. Simulator has no
+                    // CGMManager, so fall back to reading its synthetic
+                    // lifecycle / highlight so the bobble sees the same
+                    // data shape a real CGM would deliver.
+                    let manager = self.fetchGlucoseManager.cgmManager
+                    let source = self.fetchGlucoseManager.glucoseSource
+                    let progress: DeviceLifecycleProgress?
+                    let highlight: DeviceStatusHighlight?
+                    if let manager {
+                        progress = manager.cgmLifecycleProgress
+                        highlight = manager.cgmStatusHighlight
+                    } else if let sim = source as? GlucoseSimulatorSource {
+                        progress = sim.cgmLifecycleProgress
+                        highlight = sim.cgmStatusHighlight
+                    } else {
+                        progress = nil
+                        highlight = nil
+                    }
+                    self.cgmProgressHighlight = progress
+                    if let highlight {
+                        self.cgmDisplayState = CgmDisplayState(
+                            localizedMessage: highlight.localizedMessage,
+                            imageName: highlight.imageName,
+                            status: CgmDisplayStatus.from(highlight.state)
+                        )
+                    } else {
+                        self.cgmDisplayState = nil
+                    }
+                    self.cgmSensorExpiresAt = Self.resolveSensorExpiresAt(
+                        manager: manager,
+                        glucoseSource: source,
+                        lifecycle: progress
+                    )
+                    self.cgmWarmupEndsAt = Self.resolveWarmupEndsAt(manager: manager)
                 }
             }
             timer.resume()
 
+            fetchGlucoseManager.cgmDisplayState
+                .receive(on: DispatchQueue.main)
+                .sink { [weak self] state in self?.cgmDisplayState = state }
+                .store(in: &lifetime)
+            fetchGlucoseManager.cgmProgressHighlight
+                .receive(on: DispatchQueue.main)
+                .sink { [weak self] progress in
+                    guard let self else { return }
+                    self.cgmProgressHighlight = progress
+                    self.cgmSensorExpiresAt = Self.resolveSensorExpiresAt(
+                        manager: self.fetchGlucoseManager.cgmManager,
+                        glucoseSource: self.fetchGlucoseManager.glucoseSource,
+                        lifecycle: progress
+                    )
+                    self.cgmWarmupEndsAt = Self.resolveWarmupEndsAt(
+                        manager: self.fetchGlucoseManager.cgmManager
+                    )
+                }
+                .store(in: &lifetime)
+
             apsManager.isLooping
                 .receive(on: DispatchQueue.main)
                 .weakAssign(to: \.isLooping, on: self)
@@ -642,6 +706,68 @@ extension Home {
                 }
             }
         }
+
+        /// 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?,
+            lifecycle: DeviceLifecycleProgress?
+        ) -> Date? {
+            if let sim = glucoseSource as? GlucoseSimulatorSource {
+                return sim.simulatedSensorExpiresAt
+            }
+            guard let manager else { return nil }
+            // Once a G7 enters grace period, `sensorExpiresAt` is in the past
+            // and would collapse the bobble countdown to "<1m" while the arc
+            // (driven by lifecycle.percentComplete against `sensorEndsAt`) is
+            // still mid-progress. Fall back to `sensorEndsAt` so bobble and
+            // arc agree, and the user sees grace-period time remaining.
+            if let g7 = manager as? G7CGMManager {
+                let now = Date()
+                if let exp = g7.sensorExpiresAt, exp > now { return exp }
+                return g7.sensorEndsAt ?? g7.sensorExpiresAt
+            }
+            if let g6 = manager as? G6CGMManager, let exp = g6.latestReading?.sessionExpDate { return exp }
+            if let g5 = manager as? G5CGMManager, let exp = g5.latestReading?.sessionExpDate { return exp }
+
+            let activatedAt: Date?
+            if let g7 = manager as? G7CGMManager {
+                activatedAt = g7.sensorActivatedAt
+            } else if let libre = manager as? LibreTransmitterManagerV3 {
+                activatedAt = libre.sensorInfoObservable.activatedAt
+            } else {
+                activatedAt = nil
+            }
+
+            guard let activatedAt,
+                  let lifecycle,
+                  lifecycle.percentComplete > 0.001
+            else { return nil }
+            let elapsed = Date().timeIntervalSince(activatedAt)
+            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
+        }
     }
 }
 

+ 233 - 72
Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift

@@ -1,4 +1,5 @@
 import CoreData
+import LoopKit
 import SwiftUI
 
 struct CurrentGlucoseView: View {
@@ -11,6 +12,16 @@ struct CurrentGlucoseView: View {
     var currentGlucoseTarget: Decimal
     let glucoseColorScheme: GlucoseColorScheme
     let glucose: [GlucoseStored] // This contains the last two glucose values, no matter if its manual or a cgm reading
+
+    /// Drives the outer ring.
+    var cgmProgress: DeviceLifecycleProgress?
+    /// CGM status highlight, rendered verbatim.
+    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: [
         Color(red: 0.7215686275, green: 0.3411764706, blue: 1),
@@ -42,81 +53,28 @@ struct CurrentGlucoseView: View {
         let triangleColor = Color(red: 0.262745098, green: 0.7333333333, blue: 0.9137254902)
 
         if cgmAvailable {
-            ZStack {
-                TrendShape(gradient: angularGradient, color: triangleColor)
-                    .rotationEffect(.degrees(rotationDegrees))
-
-                VStack(alignment: .center) {
+            if let stale = stalenessState {
+                // Compact error/transition state — same styles as the
+                // empty-state "Add CGM" layout below, just colored by tier.
+                VStack(alignment: .center, spacing: 12) {
                     HStack {
-                        if let glucoseValue = glucose.last?.glucose {
-                            let displayGlucose = units == .mgdL ? Decimal(glucoseValue).description : Decimal(glucoseValue)
-                                .formattedAsMmolL
-
-                            var glucoseDisplayColor = Color.primary
-
-                            // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
-                            let hardCodedLow = Decimal(55)
-                            let hardCodedHigh = Decimal(220)
-                            let isDynamicColorScheme = glucoseColorScheme == .dynamicColor
-
-                            if Decimal(glucoseValue) <= lowGlucose || Decimal(glucoseValue) >= highGlucose {
-                                glucoseDisplayColor = Trio.getDynamicGlucoseColor(
-                                    glucoseValue: Decimal(glucoseValue),
-                                    highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : highGlucose,
-                                    lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : lowGlucose,
-                                    targetGlucose: currentGlucoseTarget,
-                                    glucoseColorScheme: glucoseColorScheme
-                                )
-                            }
-
-                            return Text(
-                                glucoseValue == 400 ? "HIGH" : displayGlucose
-                            )
-                            .font(.system(size: 40, weight: .bold, design: .rounded))
-                            .foregroundStyle(glucoseDisplayColor)
-                        } else {
-                            return Text("--")
-                                .font(.system(size: 40, weight: .bold, design: .rounded))
-                                .foregroundStyle(.secondary)
-                        }
+                        Image(systemName: stale.imageName)
+                            .font(.body)
+                            .imageScale(.large)
+                            .foregroundStyle(stale.color)
                     }
                     HStack {
-                        let minutesAgoString = TimeAgoFormatter.minutesAgo(from: glucose.last?.date)
-                        Group {
-                            Text(minutesAgoString)
-                            Text(delta)
-                        }
-                        .font(.callout).fontWeight(.bold)
-                        .foregroundStyle(colorScheme == .dark ? Color.white.opacity(0.9) : Color.secondary)
-                    }
-                    .frame(alignment: .top)
-                }
-            }
-            .onChange(of: glucose.last?.directionEnum) {
-                withAnimation {
-                    switch glucose.last?.directionEnum {
-                    case .doubleUp,
-                         .singleUp,
-                         .tripleUp:
-                        rotationDegrees = -90
-                    case .fortyFiveUp:
-                        rotationDegrees = -45
-                    case .flat:
-                        rotationDegrees = 0
-                    case .fortyFiveDown:
-                        rotationDegrees = 45
-                    case .doubleDown,
-                         .singleDown,
-                         .tripleDown:
-                        rotationDegrees = 90
-                    case nil,
-                         .notComputable,
-                         .rateOutOfRange:
-                        rotationDegrees = 0
-                    default:
-                        rotationDegrees = 0
+                        Text(stale.label)
+                            .font(.caption).bold()
+                            .foregroundStyle(stale.color)
                     }
-                }
+                }.frame(alignment: .top)
+            } else {
+                // Bobble renders at 0.9 to leave breathing room for the right
+                // panel + pump view siblings; the compact and empty-state
+                // branches above and below already fit at 1.0.
+                bobbleAndTag(triangleColor: triangleColor)
+                    .scaleEffect(0.9)
             }
         } else {
             VStack(alignment: .center, spacing: 12) {
@@ -132,6 +90,59 @@ struct CurrentGlucoseView: View {
         }
     }
 
+    @ViewBuilder private func bobbleAndTag(triangleColor: Color) -> some View {
+        ZStack {
+            if let progress = cgmProgress, shouldShowArc {
+                SensorLifecycleArcView(
+                    progress: progress.percentComplete,
+                    progressState: progress.progressState
+                )
+            }
+
+            TrendShape(gradient: angularGradient, color: triangleColor, showArrow: true)
+                .rotationEffect(.degrees(rotationDegrees))
+
+            VStack(alignment: .center) {
+                bobbleContent()
+            }
+        }
+        .overlay(alignment: .bottom) {
+            // 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, iconSystemName: tag.icon)
+                    .offset(y: 14)
+                    .zIndex(1)
+            }
+        }
+        .onChange(of: glucose.last?.directionEnum) {
+            withAnimation {
+                switch glucose.last?.directionEnum {
+                case .doubleUp,
+                     .singleUp,
+                     .tripleUp:
+                    rotationDegrees = -90
+                case .fortyFiveUp:
+                    rotationDegrees = -45
+                case .flat:
+                    rotationDegrees = 0
+                case .fortyFiveDown:
+                    rotationDegrees = 45
+                case .doubleDown,
+                     .singleDown,
+                     .tripleDown:
+                    rotationDegrees = 90
+                case nil,
+                     .notComputable,
+                     .rateOutOfRange:
+                    rotationDegrees = 0
+                default:
+                    rotationDegrees = 0
+                }
+            }
+        }
+    }
+
     private var delta: String {
         guard glucose.count >= 2 else {
             return "--"
@@ -146,6 +157,153 @@ struct CurrentGlucoseView: View {
         let delta = lastGlucose - secondLastGlucose
         return deltaFormatter.string(from: delta as NSNumber) ?? "--"
     }
+
+    @ViewBuilder private func bobbleContent() -> some View {
+        HStack {
+            if let glucoseValue = glucose.last?.glucose, isReadingFresh {
+                let displayGlucose = units == .mgdL
+                    ? Decimal(glucoseValue).description
+                    : Decimal(glucoseValue).formattedAsMmolL
+                Text(glucoseValue == 400 ? "HIGH" : displayGlucose)
+                    .font(.system(size: 40, weight: .bold, design: .rounded))
+                    .foregroundStyle(glucoseColor(for: glucoseValue))
+            } else {
+                Text("– –")
+                    .font(.system(size: 40, weight: .bold, design: .rounded))
+                    .foregroundStyle(.secondary)
+            }
+        }
+        if isReadingFresh {
+            HStack {
+                let minutesAgoString = TimeAgoFormatter.minutesAgo(from: glucose.last?.date)
+                Group {
+                    Text(minutesAgoString)
+                    Text(delta)
+                }
+                .font(.callout).fontWeight(.bold)
+                .foregroundStyle(colorScheme == .dark ? Color.white.opacity(0.9) : Color.secondary)
+            }
+            .frame(alignment: .top)
+        }
+    }
+
+    /// Matches `APSManager`'s loop-input freshness gate — readings older than
+    /// 12 minutes (one missed CGM transmission on a 5-min schedule) get
+    /// masked to dashes. Handles warmup + sensor failure naturally: no
+    /// fresh data → no number on the bobble.
+    private var isReadingFresh: Bool {
+        guard let date = glucose.last?.date else { return false }
+        return Date().timeIntervalSince(date) < 12 * 60
+    }
+
+    private var trendIsDownward: Bool { rotationDegrees >= 90 }
+
+    /// Error/transition states (sensor expired, sensor failure, signal loss,
+    /// stabilizing, etc.) collapse the bobble to a compact symbol-over-label
+    /// view — the bobble's purpose is to show glucose, so a fresh-reading-less
+    /// bobble with arc + dashes obscures rather than informs. Warmup is the
+    /// exception: it's a short, expected lifecycle phase and the arc + tag
+    /// countdown carry useful info, so the bobble stays.
+    private var stalenessState: (imageName: String, label: String, color: Color)? {
+        guard !isReadingFresh,
+              !isInWarmup,
+              let status = cgmStatus,
+              !status.imageName.isEmpty
+        else { return nil }
+        let color: Color
+        switch status.status {
+        case .critical: color = .loopRed
+        case .warning: color = .orange
+        case .normal: color = .secondary
+        }
+        // LibreLoop/G7 split labels with "\n" for their two-line native pills;
+        // collapse to spaces so the compact label reads cleanly.
+        let oneLine = status.localizedMessage.replacingOccurrences(of: "\n", with: " ")
+        return (status.imageName, oneLine, color)
+    }
+
+    /// 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); outside warmup/stabilizing
+    /// the tag is gated to the same 48h window as the arc.
+    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")
+        }
+        guard shouldShowArc else { return nil }
+        if let status = cgmStatus {
+            // LibreLoop's cgmStatusHighlight uses "\n" to split two-line pill
+            // labels ("Signal\nLoss", "Sensor\nWarmup"); we render in a
+            // single-line tag, so collapse newlines to spaces here.
+            let oneLine = status.localizedMessage.replacingOccurrences(of: "\n", with: " ")
+            return (oneLine, theme(for: status.status), nil)
+        }
+        if let expiresAt = cgmSensorExpiresAt {
+            let text = SensorRemainingTimeFormatter.format(until: expiresAt)
+            let theme: SensorStatusTagTheme
+            switch cgmProgress?.progressState {
+            case .critical: theme = .red
+            case .warning: theme = .orange
+            default: theme = .green
+            }
+            return (text, theme, nil)
+        }
+        return nil
+    }
+
+    private func theme(for status: CgmDisplayStatus) -> SensorStatusTagTheme {
+        switch status {
+        case .critical: return .red
+        case .warning: return .orange
+        case .normal: return .secondary
+        }
+    }
+
+    private func glucoseColor(for glucoseValue: Int16) -> Color {
+        // TODO: workaround for now: set low value to 55, to have dynamic color shades between 55 and user-set low (approx. 70); same for high glucose
+        let hardCodedLow = Decimal(55)
+        let hardCodedHigh = Decimal(220)
+        let isDynamicColorScheme = glucoseColorScheme == .dynamicColor
+        guard Decimal(glucoseValue) <= lowGlucose || Decimal(glucoseValue) >= highGlucose else {
+            return Color.primary
+        }
+        return Trio.getDynamicGlucoseColor(
+            glucoseValue: Decimal(glucoseValue),
+            highGlucoseColorValue: isDynamicColorScheme ? hardCodedHigh : highGlucose,
+            lowGlucoseColorValue: isDynamicColorScheme ? hardCodedLow : lowGlucose,
+            targetGlucose: currentGlucoseTarget,
+            glucoseColorScheme: glucoseColorScheme
+        )
+    }
 }
 
 struct Triangle: Shape {
@@ -168,13 +326,16 @@ struct TrendShape: View {
 
     let gradient: AngularGradient
     let color: Color
+    var showArrow: Bool = true
 
     var body: some View {
         HStack(alignment: .center) {
             ZStack {
                 Group {
                     CircleShape(gradient: gradient)
-                    TriangleShape(color: color)
+                    if showArrow {
+                        TriangleShape(color: color)
+                    }
                 }.shadow(color: Color.black.opacity(colorScheme == .dark ? 0.75 : 0.33), radius: colorScheme == .dark ? 5 : 3)
                 CircleShape(gradient: gradient)
             }

+ 22 - 0
Trio/Sources/Modules/Home/View/Header/SensorLifecycle/CGMSensorDisplayState.swift

@@ -0,0 +1,22 @@
+import Foundation
+
+/// Compact `5d 14h` / `22h` / `45m` remaining-time formatter. Used as a
+/// fallback tag label when the CGM doesn't surface a `cgmStatusHighlight`
+/// but the lifecycle progress lets us derive an expiration date.
+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 days = totalMinutes / (60 * 24)
+        let hours = (totalMinutes / 60) % 24
+        let minutes = totalMinutes % 60
+        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
+    }
+}

+ 55 - 0
Trio/Sources/Modules/Home/View/Header/SensorLifecycle/SensorLifecycleArcView.swift

@@ -0,0 +1,55 @@
+import LoopKit
+import LoopKitUI
+import SwiftUI
+
+/// Concentric outer ring around the glucose bobble that drains clockwise from
+/// 12 o'clock as the sensor session ages. Always renders a faint track behind
+/// the fill so the indicator's footprint is visible even at `progress == 0`.
+struct SensorLifecycleArcView: View {
+    let progress: Double
+    let progressState: DeviceLifecycleProgressState
+
+    /// Outer diameter — bobble is ~130pt; arc sits 10pt outside the ring stroke.
+    static let diameter: CGFloat = 146
+    static let strokeWidth: CGFloat = 4
+
+    private var arcColor: Color {
+        switch progressState {
+        case .critical:
+            return Color.loopRed
+        case .warning:
+            return Color.orange
+        case .dimmed,
+             .normalCGM,
+             .normalPump:
+            return Color.loopGreen
+        }
+    }
+
+    var body: some View {
+        ZStack {
+            Circle()
+                .stroke(Color.secondary.opacity(0.4), lineWidth: Self.strokeWidth)
+
+            Circle()
+                .trim(from: 0, to: max(0, min(1, progress)))
+                .stroke(arcColor, style: StrokeStyle(lineWidth: Self.strokeWidth, lineCap: .round))
+                .rotationEffect(.degrees(-90))
+                .animation(.easeInOut(duration: 0.25), value: progress)
+        }
+        .frame(width: Self.diameter, height: Self.diameter)
+        .allowsHitTesting(false)
+    }
+}
+
+#Preview("Arc — progress states") {
+    VStack(spacing: 24) {
+        SensorLifecycleArcView(progress: 0.15, progressState: .normalCGM)
+        SensorLifecycleArcView(progress: 0.55, progressState: .normalCGM)
+        SensorLifecycleArcView(progress: 0.92, progressState: .warning)
+        SensorLifecycleArcView(progress: 1.0, progressState: .critical)
+    }
+    .padding(40)
+    .background(Color.black)
+    .preferredColorScheme(.dark)
+}

+ 56 - 0
Trio/Sources/Modules/Home/View/Header/SensorLifecycle/SensorStatusTagView.swift

@@ -0,0 +1,56 @@
+import SwiftUI
+
+/// Status badge below the glucose bobble — text-only, no fill or border.
+struct SensorStatusTagView: View {
+    let text: String
+    let theme: SensorStatusTagTheme
+    var iconSystemName: String?
+
+    var body: some View {
+        HStack(spacing: 4) {
+            if let iconSystemName {
+                Image(systemName: iconSystemName)
+                    .font(.callout)
+                    .fontWeight(.bold)
+                    .foregroundStyle(theme.textColor)
+            }
+            Text(text)
+                .font(.callout)
+                .fontWeight(.bold)
+                .fontDesign(.rounded)
+                .foregroundStyle(theme.textColor)
+                .lineLimit(1)
+        }
+        .padding(.bottom, -16)
+    }
+}
+
+enum SensorStatusTagTheme {
+    case green
+    case orange
+    case red
+    case secondary
+
+    var textColor: Color {
+        switch self {
+        case .green: return Color.loopGreen
+        case .orange: return Color.orange
+        case .red: return Color.loopRed
+        case .secondary: return Color.secondary
+        }
+    }
+}
+
+#Preview("Tag — all themes") {
+    VStack(spacing: 12) {
+        SensorStatusTagView(text: "5d 14h", theme: .green)
+        SensorStatusTagView(text: "22h left", theme: .orange)
+        SensorStatusTagView(text: "warming up", theme: .secondary)
+        SensorStatusTagView(text: "calibrate", theme: .secondary)
+        SensorStatusTagView(text: "sensor expired", theme: .red)
+        SensorStatusTagView(text: "sensor error", theme: .red)
+    }
+    .padding(40)
+    .background(Color.black)
+    .preferredColorScheme(.dark)
+}

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

@@ -134,8 +134,12 @@ extension Home {
                 cgmAvailable: state.cgmAvailable,
                 currentGlucoseTarget: state.currentGlucoseTarget,
                 glucoseColorScheme: state.glucoseColorScheme,
-                glucose: state.latestTwoGlucoseValues
-            ).scaleEffect(0.9)
+                glucose: state.latestTwoGlucoseValues,
+                cgmProgress: state.cgmProgressHighlight,
+                cgmStatus: state.cgmDisplayState,
+                cgmSensorExpiresAt: state.cgmSensorExpiresAt,
+                cgmWarmupEndsAt: state.cgmWarmupEndsAt
+            )
                 .onTapGesture {
                     if !state.cgmAvailable {
                         showCGMSelection.toggle()

+ 4 - 0
Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift

@@ -1,6 +1,7 @@
 import Combine
 import CoreData
 import Foundation
+import LoopKit
 import LoopKitUI
 import Swinject
 import UIKit
@@ -265,6 +266,9 @@ final class BaseNightscoutManager: NightscoutManager, Injectable {
     var glucoseManager: FetchGlucoseManager?
     var cgmManager: CGMManagerUI?
 
+    let cgmDisplayState = CurrentValueSubject<CgmDisplayState?, Never>(nil)
+    let cgmProgressHighlight = CurrentValueSubject<DeviceLifecycleProgress?, Never>(nil)
+
     func fetch(_: DispatchTimer?) -> AnyPublisher<[BloodGlucose], Never> {
         Future { promise in
             Task {