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

Merge branch 'dev' of github.com:nightscout/Trio into core-data-fixes

# Conflicts:
#	CGMBLEKit
#	Trio/Sources/APS/APSManager.swift
#	Trio/Sources/APS/Storage/PumpHistoryStorage.swift
Marvin Polscheit 4 дней назад
Родитель
Сommit
f848bbf50a
26 измененных файлов с 27259 добавлено и 7989 удалено
  1. 1 1
      CGMBLEKit
  2. 2 2
      Config.xcconfig
  3. 20 0
      Trio.xcodeproj/project.pbxproj
  4. 17 26
      Trio.xcworkspace/xcshareddata/swiftpm/Package.resolved
  5. 98 1
      Trio/Sources/APS/CGM/AppGroupSource.swift
  6. 228 0
      Trio/Sources/APS/CGM/GlucoseSimulatorSource.swift
  7. 4 0
      Trio/Sources/APS/CGM/GlucoseSource.swift
  8. 27 1
      Trio/Sources/APS/CGM/PluginSource.swift
  9. 3 0
      Trio/Sources/APS/DeviceDataManager.swift
  10. 23 1
      Trio/Sources/APS/FetchGlucoseManager.swift
  11. 0 1
      Trio/Sources/APS/Storage/PumpHistoryStorage.swift
  12. 26242 7862
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  13. 39 0
      Trio/Sources/Modules/CGMSettings/CGMSettingsDataFlow.swift
  14. 35 0
      Trio/Sources/Modules/CGMSettings/View/CustomCGMOptionsView.swift
  15. 128 2
      Trio/Sources/Modules/Home/HomeStateModel.swift
  16. 233 72
      Trio/Sources/Modules/Home/View/Header/CurrentGlucoseView.swift
  17. 22 0
      Trio/Sources/Modules/Home/View/Header/SensorLifecycle/CGMSensorDisplayState.swift
  18. 55 0
      Trio/Sources/Modules/Home/View/Header/SensorLifecycle/SensorLifecycleArcView.swift
  19. 56 0
      Trio/Sources/Modules/Home/View/Header/SensorLifecycle/SensorStatusTagView.swift
  20. 17 13
      Trio/Sources/Modules/Home/View/HomeRootView.swift
  21. 1 1
      Trio/Sources/Modules/Onboarding/View/OnboardingView+Util.swift
  22. 1 2
      Trio/Sources/Services/BolusCalculator/BolusCalculationManager.swift
  23. 1 2
      Trio/Sources/Services/LiveActivity/Data/DataManager.swift
  24. 4 0
      Trio/Sources/Services/Network/Nightscout/NightscoutManager.swift
  25. 1 2
      Trio/Sources/Services/WatchManager/AppleWatchManager.swift
  26. 1 0
      Trio/Sources/Views/BolusProgressBar.swift

+ 1 - 1
CGMBLEKit

@@ -1 +1 @@
-Subproject commit ba5d0b7daf83d282b587c8ff0e835162b8c75846
+Subproject commit 26ad72115d3b98b05668d57b817b1bf961f8a721

+ 2 - 2
Config.xcconfig

@@ -18,8 +18,8 @@ BUNDLE_IDENTIFIER = org.nightscout.$(DEVELOPMENT_TEAM).trio
 TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group
 TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group
 
 
 // The developers set the version numbers, please leave them alone
 // The developers set the version numbers, please leave them alone
-APP_VERSION = 0.8.3
-APP_DEV_VERSION = 0.8.3.9
+APP_VERSION = 0.8.4
+APP_DEV_VERSION = 0.8.4.2
 APP_BUILD_NUMBER = 1
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 COPYRIGHT_NOTICE =
 
 

+ 20 - 0
Trio.xcodeproj/project.pbxproj

@@ -138,6 +138,9 @@
 		38A504A425DD9C4000C5B9E8 /* UserDefaultsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A5049125DD9C4000C5B9E8 /* UserDefaultsExtensions.swift */; };
 		38A504A425DD9C4000C5B9E8 /* UserDefaultsExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A5049125DD9C4000C5B9E8 /* UserDefaultsExtensions.swift */; };
 		38A9260525F012D8009E3739 /* CarbRatios.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A9260425F012D8009E3739 /* CarbRatios.swift */; };
 		38A9260525F012D8009E3739 /* CarbRatios.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38A9260425F012D8009E3739 /* CarbRatios.swift */; };
 		38AAF85525FFF846004AF583 /* CurrentGlucoseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AAF85425FFF846004AF583 /* CurrentGlucoseView.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 */; };
 		38AEE73D25F0200C0013F05B /* TrioSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AEE73C25F0200C0013F05B /* TrioSettings.swift */; };
 		38AEE75225F022080013F05B /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AEE75125F022080013F05B /* SettingsManager.swift */; };
 		38AEE75225F022080013F05B /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AEE75125F022080013F05B /* SettingsManager.swift */; };
 		38AEE75725F0F18E0013F05B /* CarbsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AEE75625F0F18E0013F05B /* CarbsStorage.swift */; };
 		38AEE75725F0F18E0013F05B /* CarbsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38AEE75625F0F18E0013F05B /* CarbsStorage.swift */; };
@@ -1100,6 +1103,9 @@
 		38A5049125DD9C4000C5B9E8 /* UserDefaultsExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserDefaultsExtensions.swift; sourceTree = "<group>"; };
 		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>"; };
 		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>"; };
 		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>"; };
 		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>"; };
 		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>"; };
 		38AEE75625F0F18E0013F05B /* CarbsStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbsStorage.swift; sourceTree = "<group>"; };
@@ -2489,11 +2495,22 @@
 				DDA6E2842D2361F800C2988C /* LoopStatusView.swift */,
 				DDA6E2842D2361F800C2988C /* LoopStatusView.swift */,
 				383420D525FFE38C002D46C1 /* LoopView.swift */,
 				383420D525FFE38C002D46C1 /* LoopView.swift */,
 				38AAF85425FFF846004AF583 /* CurrentGlucoseView.swift */,
 				38AAF85425FFF846004AF583 /* CurrentGlucoseView.swift */,
+				BD11C002000000000000C000 /* SensorLifecycle */,
 				38DAB27F260CBB7F00F74C1A /* PumpView.swift */,
 				38DAB27F260CBB7F00F74C1A /* PumpView.swift */,
 			);
 			);
 			path = Header;
 			path = Header;
 			sourceTree = "<group>";
 			sourceTree = "<group>";
 		};
 		};
+		BD11C002000000000000C000 /* SensorLifecycle */ = {
+			isa = PBXGroup;
+			children = (
+				BD11C000000000000000C001 /* CGMSensorDisplayState.swift */,
+				BD11C000000000000000C003 /* SensorLifecycleArcView.swift */,
+				BD11C000000000000000C004 /* SensorStatusTagView.swift */,
+			);
+			path = SensorLifecycle;
+			sourceTree = "<group>";
+		};
 		3856933F270B57A00002C50D /* CGM */ = {
 		3856933F270B57A00002C50D /* CGM */ = {
 			isa = PBXGroup;
 			isa = PBXGroup;
 			children = (
 			children = (
@@ -5157,6 +5174,9 @@
 				BDF34F932C10D0E100D51995 /* LiveActivityAttributes+Helper.swift in Sources */,
 				BDF34F932C10D0E100D51995 /* LiveActivityAttributes+Helper.swift in Sources */,
 				E0D4F80527513ECF00BDF1FE /* HealthKitSample.swift in Sources */,
 				E0D4F80527513ECF00BDF1FE /* HealthKitSample.swift in Sources */,
 				38AAF85525FFF846004AF583 /* CurrentGlucoseView.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 */,
 				041D1E995A6AE92E9289DC49 /* TreatmentsDataFlow.swift in Sources */,
 				DD32CF9E2CC824C5003686D6 /* TrioRemoteControl+Override.swift in Sources */,
 				DD32CF9E2CC824C5003686D6 /* TrioRemoteControl+Override.swift in Sources */,
 				BD249D922D42FC5300412DEB /* GlucoseSectorChart.swift in Sources */,
 				BD249D922D42FC5300412DEB /* GlucoseSectorChart.swift in Sources */,

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

@@ -1,5 +1,5 @@
 {
 {
-  "originHash" : "1e72c1cdf8ea5ec9fe527ebfab01ea55fca9e8651fe3252338fd3d4ea2cb327a",
+  "originHash" : "af604046297daf839c0c212e73929960a33a0fecfdb97b64b9356d8a98b7bd60",
   "pins" : [
   "pins" : [
     {
     {
       "identity" : "abseil-cpp-binary",
       "identity" : "abseil-cpp-binary",
@@ -69,17 +69,8 @@
       "kind" : "remoteSourceControl",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/firebase/firebase-ios-sdk.git",
       "location" : "https://github.com/firebase/firebase-ios-sdk.git",
       "state" : {
       "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",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/google/GoogleAppMeasurement.git",
       "location" : "https://github.com/google/GoogleAppMeasurement.git",
       "state" : {
       "state" : {
-        "revision" : "45ce435e9406d3c674dd249a042b932bee006f60",
-        "version" : "11.15.0"
+        "revision" : "dd89fc79a77183830742a16866d87e4e54785734",
+        "version" : "11.11.0"
       }
       }
     },
     },
     {
     {
@@ -105,8 +96,8 @@
       "kind" : "remoteSourceControl",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/google/GoogleUtilities.git",
       "location" : "https://github.com/google/GoogleUtilities.git",
       "state" : {
       "state" : {
-        "revision" : "60da361632d0de02786f709bdc0c4df340f7613e",
-        "version" : "8.1.0"
+        "revision" : "53156c7ec267db846e6b64c9f4c4e31ba4cf75eb",
+        "version" : "8.0.2"
       }
       }
     },
     },
     {
     {
@@ -114,8 +105,8 @@
       "kind" : "remoteSourceControl",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/google/grpc-binary.git",
       "location" : "https://github.com/google/grpc-binary.git",
       "state" : {
       "state" : {
-        "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6",
-        "version" : "1.69.1"
+        "revision" : "cc0001a0cf963aa40501d9c2b181e7fc9fd8ec71",
+        "version" : "1.69.0"
       }
       }
     },
     },
     {
     {
@@ -123,8 +114,8 @@
       "kind" : "remoteSourceControl",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/google/gtm-session-fetcher.git",
       "location" : "https://github.com/google/gtm-session-fetcher.git",
       "state" : {
       "state" : {
-        "revision" : "c756a29784521063b6a1202907e2cc47f41b667c",
-        "version" : "4.5.0"
+        "revision" : "4d70340d55d7d07cc2fdf8e8125c4c126c1d5f35",
+        "version" : "4.4.0"
       }
       }
     },
     },
     {
     {
@@ -222,8 +213,8 @@
       "kind" : "remoteSourceControl",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/apple/swift-log.git",
       "location" : "https://github.com/apple/swift-log.git",
       "state" : {
       "state" : {
-        "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523",
-        "version" : "1.10.1"
+        "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2",
+        "version" : "1.6.4"
       }
       }
     },
     },
     {
     {
@@ -240,8 +231,8 @@
       "kind" : "remoteSourceControl",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/apple/swift-protobuf.git",
       "location" : "https://github.com/apple/swift-protobuf.git",
       "state" : {
       "state" : {
-        "revision" : "a008af1a102ff3dd6cc3764bb69bf63226d0f5f6",
-        "version" : "1.36.1"
+        "revision" : "d72aed98f8253ec1aa9ea1141e28150f408cf17f",
+        "version" : "1.29.0"
       }
       }
     },
     },
     {
     {
@@ -276,8 +267,8 @@
       "kind" : "remoteSourceControl",
       "kind" : "remoteSourceControl",
       "location" : "https://github.com/Swinject/Swinject",
       "location" : "https://github.com/Swinject/Swinject",
       "state" : {
       "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
     let from: String
     var cgmType: CGMType
     var cgmType: CGMType
 
 
+    let cgmDisplayState = CurrentValueSubject<CgmDisplayState?, Never>(nil)
+    let cgmProgressHighlight = CurrentValueSubject<LoopKit.DeviceLifecycleProgress?, Never>(nil)
+
     func fetch(_ heartbeat: DispatchTimer?) -> AnyPublisher<[BloodGlucose], Never> {
     func fetch(_ heartbeat: DispatchTimer?) -> AnyPublisher<[BloodGlucose], Never> {
         guard let suiteName = Bundle.main.appGroupSuiteName,
         guard let suiteName = Bundle.main.appGroupSuiteName,
               let sharedDefaults = UserDefaults(suiteName: suiteName)
               let sharedDefaults = UserDefaults(suiteName: suiteName)
@@ -52,7 +55,21 @@ struct AppGroupSource: GlucoseSource {
         HeartBeatManager.shared.checkCGMBluetoothTransmitter(sharedUserDefaults: sharedDefaults, heartbeat: heartbeat)
         HeartBeatManager.shared.checkCGMBluetoothTransmitter(sharedUserDefaults: sharedDefaults, heartbeat: heartbeat)
         debug(.deviceManager, "APPGROUP : START FETCH LAST BG ")
         debug(.deviceManager, "APPGROUP : START FETCH LAST BG ")
         let decoded = try? JSONSerialization.jsonObject(with: sharedData, options: [])
         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 []
             return []
         }
         }
 
 
@@ -101,6 +118,81 @@ struct AppGroupSource: GlucoseSource {
         return results
         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? {
     private func parseDate(_ timestamp: String) -> Date? {
         // timestamp looks like "/Date(1462404576000)/"
         // timestamp looks like "/Date(1462404576000)/"
         guard let re = try? NSRegularExpression(pattern: "\\((.*)\\)"),
         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 {
 public extension Bundle {
     var appGroupSuiteName: String? {
     var appGroupSuiteName: String? {
         object(forInfoDictionaryKey: "AppGroupID") as? 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
 ///  - OscillatingGenerator: BloodGlucoseGenerator - Generates sinusoidal glucose values around a center point
 
 
 import Combine
 import Combine
+import CoreData
 import Foundation
 import Foundation
+import LoopKit
 import LoopKitUI
 import LoopKitUI
 
 
 // MARK: - Glucose simulator
 // MARK: - Glucose simulator
@@ -25,6 +27,9 @@ final class GlucoseSimulatorSource: GlucoseSource {
     var cgmManager: CGMManagerUI?
     var cgmManager: CGMManagerUI?
     var glucoseManager: FetchGlucoseManager?
     var glucoseManager: FetchGlucoseManager?
 
 
+    let cgmDisplayState = CurrentValueSubject<CgmDisplayState?, Never>(nil)
+    let cgmProgressHighlight = CurrentValueSubject<DeviceLifecycleProgress?, Never>(nil)
+
     private enum Config {
     private enum Config {
         /// Minimum time period between data publications (in seconds)
         /// Minimum time period between data publications (in seconds)
         static let workInterval: TimeInterval = 300
         static let workInterval: TimeInterval = 300
@@ -49,6 +54,57 @@ final class GlucoseSimulatorSource: GlucoseSource {
             }
             }
             lastFetchDate = lastDate
             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
     /// The glucose generator used to create simulated values
@@ -74,6 +130,14 @@ final class GlucoseSimulatorSource: GlucoseSource {
         guard canGenerateNewValues else {
         guard canGenerateNewValues else {
             return Just([]).eraseToAnyPublisher()
             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(
         let glucoses = generator.getBloodGlucoses(
             startDate: lastFetchDate,
             startDate: lastFetchDate,
@@ -94,6 +158,170 @@ final class GlucoseSimulatorSource: GlucoseSource {
     func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> {
     func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> {
         fetch(nil)
         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
 // MARK: - Glucose generator

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

@@ -11,6 +11,10 @@ protocol GlucoseSource: SourceInfoProvider {
     func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never>
     func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never>
     var glucoseManager: FetchGlucoseManager? { get set }
     var glucoseManager: FetchGlucoseManager? { get set }
     var cgmManager: CGMManagerUI? { 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 {
 extension GlucoseSource {

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

@@ -11,7 +11,12 @@ final class PluginSource: GlucoseSource {
     private let glucoseStorage: GlucoseStorage!
     private let glucoseStorage: GlucoseStorage!
     var glucoseManager: FetchGlucoseManager?
     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
     var cgmHasValidSensorSession: Bool = false
 
 
@@ -22,6 +27,23 @@ final class PluginSource: GlucoseSource {
         cgmManager = glucoseManager.cgmManager
         cgmManager = glucoseManager.cgmManager
         cgmManager?.delegateQueue = processQueue
         cgmManager?.delegateQueue = processQueue
         cgmManager?.cgmManagerDelegate = self
         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
     /// Function that fetches blood glucose data
@@ -121,6 +143,8 @@ extension PluginSource: CGMManagerDelegate {
                 debug(.deviceManager, "CGM PLUGIN - unable to read CGM result")
                 debug(.deviceManager, "CGM PLUGIN - unable to read CGM result")
             }
             }
 
 
+            self.publishCGMStatus()
+
             debug(.deviceManager, "CGM PLUGIN - Direct return done")
             debug(.deviceManager, "CGM PLUGIN - Direct return done")
         }
         }
     }
     }
@@ -170,6 +194,8 @@ extension PluginSource: CGMManagerDelegate {
                 cgmGlucosePluginId: fetchGlucoseManager.settingsManager.settings.cgmPluginIdentifier,
                 cgmGlucosePluginId: fetchGlucoseManager.settingsManager.settings.cgmPluginIdentifier,
                 newManager: cgmManager as? CGMManagerUI
                 newManager: cgmManager as? CGMManagerUI
             )
             )
+
+            self.publishCGMStatus()
         }
         }
     }
     }
 
 

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

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

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

@@ -19,6 +19,8 @@ protocol FetchGlucoseManager: SourceInfoProvider {
     var cgmGlucosePluginId: String { get }
     var cgmGlucosePluginId: String { get }
     var settingsManager: SettingsManager! { get }
     var settingsManager: SettingsManager! { get }
     var shouldSyncToRemoteService: Bool { get }
     var shouldSyncToRemoteService: Bool { get }
+    var cgmDisplayState: CurrentValueSubject<CgmDisplayState?, Never> { get }
+    var cgmProgressHighlight: CurrentValueSubject<DeviceLifecycleProgress?, Never> { get }
 }
 }
 
 
 extension FetchGlucoseManager {
 extension FetchGlucoseManager {
@@ -146,7 +148,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() {
     func removeCalibrations() {
         calibrationService.removeAllCalibrations()
         calibrationService.removeAllCalibrations()

+ 0 - 1
Trio/Sources/APS/Storage/PumpHistoryStorage.swift

@@ -237,7 +237,6 @@ final class BasePumpHistoryStorage: PumpHistoryStorage, Injectable {
             predicate: NSPredicate.pumpHistoryLast24h,
             predicate: NSPredicate.pumpHistoryLast24h,
             key: "timestamp",
             key: "timestamp",
             ascending: false,
             ascending: false,
-            fetchLimit: 288,
             relationshipKeyPathsForPrefetching: ["bolus", "tempBasal"]
             relationshipKeyPathsForPrefetching: ["bolus", "tempBasal"]
         )
         )
 
 

Разница между файлами не показана из-за своего большого размера
+ 26242 - 7862
Trio/Sources/Localizations/Main/Localizable.xcstrings


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

@@ -1,5 +1,44 @@
+import LoopKit
+import SwiftUI
+
 enum CGMSettings {
 enum CGMSettings {
     enum Config {}
     enum Config {}
 }
 }
 
 
 protocol CGMSettingsProvider: Provider {}
 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 noiseAmplitude: Double = UserDefaults.standard.double(forKey: "GlucoseSimulator_NoiseAmplitude")
         @State private var noiseAmplitude: Double = UserDefaults.standard.double(forKey: "GlucoseSimulator_NoiseAmplitude")
         @State private var produceStaleValues: Bool = UserDefaults.standard.bool(forKey: "GlucoseSimulator_ProduceStaleValues")
         @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
+
         // Initialize state variables with defaults if needed
         // Initialize state variables with defaults if needed
         private func initializeSimulatorSettings() {
         private func initializeSimulatorSettings() {
             if centerValue == 0 {
             if centerValue == 0 {
@@ -261,6 +265,37 @@ extension CGMSettings {
                     }
                     }
                 }.listRowBackground(Color.chart)
                 }.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 {
                 if !produceStaleValues {
                     Section {
                     Section {
                         VStack(alignment: .leading, spacing: 10) {
                         VStack(alignment: .leading, spacing: 10) {

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

@@ -1,7 +1,10 @@
+import CGMBLEKit
 import CGMBLEKitUI
 import CGMBLEKitUI
 import Combine
 import Combine
 import CoreData
 import CoreData
 import Foundation
 import Foundation
+import G7SensorKit
+import LibreTransmitter
 import LoopKit
 import LoopKit
 import LoopKitUI
 import LoopKitUI
 import Observation
 import Observation
@@ -27,7 +30,7 @@ extension Home {
             CGMSettings.StateModel.shared
             CGMSettings.StateModel.shared
         }
         }
 
 
-        private let timer = DispatchTimer(timeInterval: 5)
+        private let timer = DispatchTimer(timeInterval: 30)
         private(set) var filteredHours = 24
         private(set) var filteredHours = 24
         var startMarker = Date(timeIntervalSinceNow: TimeInterval(hours: -24))
         var startMarker = Date(timeIntervalSinceNow: TimeInterval(hours: -24))
         var endMarker = Date(timeIntervalSinceNow: TimeInterval(hours: 3))
         var endMarker = Date(timeIntervalSinceNow: TimeInterval(hours: 3))
@@ -107,6 +110,10 @@ extension Home {
         var pumpStatusBadgeImage: UIImage?
         var pumpStatusBadgeImage: UIImage?
         var pumpStatusBadgeColor: Color?
         var pumpStatusBadgeColor: Color?
         var cgmAvailable: Bool = false
         var cgmAvailable: Bool = false
+        var cgmDisplayState: CgmDisplayState?
+        var cgmProgressHighlight: DeviceLifecycleProgress?
+        var cgmSensorExpiresAt: Date?
+        var cgmWarmupEndsAt: Date?
         var listOfCGM: [CGMModel] = []
         var listOfCGM: [CGMModel] = []
         var cgmCurrent = cgmDefaultModel
         var cgmCurrent = cgmDefaultModel
         var pumpInitialSettings = PumpConfig.PumpInitialSettings.default
         var pumpInitialSettings = PumpConfig.PumpInitialSettings.default
@@ -421,11 +428,68 @@ extension Home {
 
 
             timer.eventHandler = {
             timer.eventHandler = {
                 DispatchQueue.main.async { [weak self] in
                 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()
             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
             apsManager.isLooping
                 .receive(on: DispatchQueue.main)
                 .receive(on: DispatchQueue.main)
                 .weakAssign(to: \.isLooping, on: self)
                 .weakAssign(to: \.isLooping, on: self)
@@ -742,6 +806,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 CoreData
+import LoopKit
 import SwiftUI
 import SwiftUI
 
 
 struct CurrentGlucoseView: View {
 struct CurrentGlucoseView: View {
@@ -11,6 +12,16 @@ struct CurrentGlucoseView: View {
     var currentGlucoseTarget: Decimal
     var currentGlucoseTarget: Decimal
     let glucoseColorScheme: GlucoseColorScheme
     let glucoseColorScheme: GlucoseColorScheme
     let glucose: [GlucoseStored] // This contains the last two glucose values, no matter if its manual or a cgm reading
     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 rotationDegrees: Double = 0.0
     @State private var angularGradient = AngularGradient(colors: [
     @State private var angularGradient = AngularGradient(colors: [
         Color(red: 0.7215686275, green: 0.3411764706, blue: 1),
         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)
         let triangleColor = Color(red: 0.262745098, green: 0.7333333333, blue: 0.9137254902)
 
 
         if cgmAvailable {
         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 {
                     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 {
                     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 {
         } else {
             VStack(alignment: .center, spacing: 12) {
             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 {
     private var delta: String {
         guard glucose.count >= 2 else {
         guard glucose.count >= 2 else {
             return "--"
             return "--"
@@ -146,6 +157,153 @@ struct CurrentGlucoseView: View {
         let delta = lastGlucose - secondLastGlucose
         let delta = lastGlucose - secondLastGlucose
         return deltaFormatter.string(from: delta as NSNumber) ?? "--"
         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 {
 struct Triangle: Shape {
@@ -168,13 +326,16 @@ struct TrendShape: View {
 
 
     let gradient: AngularGradient
     let gradient: AngularGradient
     let color: Color
     let color: Color
+    var showArrow: Bool = true
 
 
     var body: some View {
     var body: some View {
         HStack(alignment: .center) {
         HStack(alignment: .center) {
             ZStack {
             ZStack {
                 Group {
                 Group {
                     CircleShape(gradient: gradient)
                     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)
                 }.shadow(color: Color.black.opacity(colorScheme == .dark ? 0.75 : 0.33), radius: colorScheme == .dark ? 5 : 3)
                 CircleShape(gradient: gradient)
                 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)
+}

+ 17 - 13
Trio/Sources/Modules/Home/View/HomeRootView.swift

@@ -133,20 +133,24 @@ extension Home {
                 cgmAvailable: state.cgmAvailable,
                 cgmAvailable: state.cgmAvailable,
                 currentGlucoseTarget: state.currentGlucoseTarget,
                 currentGlucoseTarget: state.currentGlucoseTarget,
                 glucoseColorScheme: state.glucoseColorScheme,
                 glucoseColorScheme: state.glucoseColorScheme,
-                glucose: state.latestTwoGlucoseValues
-            ).scaleEffect(0.9)
-                .onTapGesture {
-                    if !state.cgmAvailable {
-                        showCGMSelection.toggle()
-                    } else {
-                        state.shouldDisplayCGMSetupSheet.toggle()
-                    }
-                }
-                .onLongPressGesture {
-                    let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
-                    impactHeavy.impactOccurred()
-                    state.showModal(for: .snooze)
+                glucose: state.latestTwoGlucoseValues,
+                cgmProgress: state.cgmProgressHighlight,
+                cgmStatus: state.cgmDisplayState,
+                cgmSensorExpiresAt: state.cgmSensorExpiresAt,
+                cgmWarmupEndsAt: state.cgmWarmupEndsAt
+            )
+            .onTapGesture {
+                if !state.cgmAvailable {
+                    showCGMSelection.toggle()
+                } else {
+                    state.shouldDisplayCGMSetupSheet.toggle()
                 }
                 }
+            }
+            .onLongPressGesture {
+                let impactHeavy = UIImpactFeedbackGenerator(style: .heavy)
+                impactHeavy.impactOccurred()
+                state.showModal(for: .snooze)
+            }
         }
         }
 
 
         var pumpView: some View {
         var pumpView: some View {

+ 1 - 1
Trio/Sources/Modules/Onboarding/View/OnboardingView+Util.swift

@@ -553,7 +553,7 @@ enum PumpOptionForOnboardingUnits: String, Equatable, CaseIterable, Identifiable
         case .minimed:
         case .minimed:
             return "Medtronic"
             return "Medtronic"
         case .omni:
         case .omni:
-            return "All Omnipod Types"
+            return "Omnipod"
         case .dana:
         case .dana:
             return "Dana (RS/-i)"
             return "Dana (RS/-i)"
         case .medtrum:
         case .medtrum:

+ 1 - 2
Trio/Sources/Services/BolusCalculator/BolusCalculationManager.swift

@@ -195,8 +195,7 @@ final class BaseBolusCalculationManager: BolusCalculationManager, Injectable {
             onContext: context,
             onContext: context,
             predicate: NSPredicate.glucose,
             predicate: NSPredicate.glucose,
             key: "date",
             key: "date",
-            ascending: false,
-            fetchLimit: 288
+            ascending: false
         )
         )
 
 
         return try await context.perform {
         return try await context.perform {

+ 1 - 2
Trio/Sources/Services/LiveActivity/Data/DataManager.swift

@@ -13,8 +13,7 @@ extension LiveActivityManager {
             onContext: context,
             onContext: context,
             predicate: NSPredicate.predicateForSixHoursAgo,
             predicate: NSPredicate.predicateForSixHoursAgo,
             key: "date",
             key: "date",
-            ascending: false,
-            fetchLimit: 72
+            ascending: false
         )
         )
 
 
         return try await context.perform {
         return try await context.perform {

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

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

+ 1 - 2
Trio/Sources/Services/WatchManager/AppleWatchManager.swift

@@ -383,8 +383,7 @@ final class BaseWatchManager: NSObject, WCSessionDelegate, Injectable, WatchMana
             onContext: context,
             onContext: context,
             predicate: NSPredicate.glucose,
             predicate: NSPredicate.glucose,
             key: "date",
             key: "date",
-            ascending: false,
-            fetchLimit: 288
+            ascending: false
         )
         )
 
 
         return try await context.perform {
         return try await context.perform {

+ 1 - 0
Trio/Sources/Views/BolusProgressBar.swift

@@ -19,6 +19,7 @@ struct BolusProgressBar: View {
                         .mask(alignment: .leading) {
                         .mask(alignment: .leading) {
                             RoundedRectangle(cornerRadius: 15)
                             RoundedRectangle(cornerRadius: 15)
                                 .frame(width: geo.size.width * CGFloat(progress))
                                 .frame(width: geo.size.width * CGFloat(progress))
+                                .animation(.easeInOut(duration: 0.25), value: progress)
                         }
                         }
                 )
                 )
         }
         }