Browse Source

Merge branch 'dev' into feat/o5

Deniz Cengiz 3 days ago
parent
commit
ead23763bc
28 changed files with 385 additions and 56 deletions
  1. 3 0
      .gitmodules
  2. 1 1
      Config.xcconfig
  3. 1 0
      EversenseKit
  4. 5 1
      LiveActivity/LiveActivity.swift
  5. 97 4
      LiveActivity/Views/LiveActivityChartView.swift
  6. 10 0
      Trio.xcodeproj/project.pbxproj
  7. 3 0
      Trio.xcworkspace/contents.xcworkspacedata
  8. 9 3
      Trio/Sources/APS/APSManager.swift
  9. 9 6
      Trio/Sources/APS/DeviceDataManager.swift
  10. 6 0
      Trio/Sources/APS/PluginManager.swift
  11. 1 0
      Trio/Sources/Helpers/CGMOptions.swift
  12. 25 0
      Trio/Sources/Localizations/Main/Localizable.xcstrings
  13. 7 0
      Trio/Sources/Models/BolusStatus.swift
  14. 5 0
      Trio/Sources/Models/TrioSettings.swift
  15. 9 0
      Trio/Sources/Modules/Home/HomeStateModel.swift
  16. 13 7
      Trio/Sources/Modules/Home/View/HomeRootView.swift
  17. 2 0
      Trio/Sources/Modules/LiveActivitySettings/LiveActivitySettingsStateModel.swift
  18. 34 0
      Trio/Sources/Modules/LiveActivitySettings/View/LiveActivitySettingsRootView.swift
  19. 50 17
      Trio/Sources/Modules/Settings/View/SettingsRootView.swift
  20. 2 0
      Trio/Sources/Modules/Settings/View/Subviews/SubmodulesView.swift
  21. 6 0
      Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift
  22. 6 1
      Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift
  23. 14 9
      Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift
  24. 40 5
      Trio/Sources/Services/LiveActivity/Data/DataManager.swift
  25. 3 0
      Trio/Sources/Services/LiveActivity/Data/DeterminationData.swift
  26. 9 0
      Trio/Sources/Services/LiveActivity/LiveActitiyAttributes.swift
  27. 10 1
      Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift
  28. 5 1
      Trio/Sources/Services/LiveActivity/LiveActivityManager.swift

+ 3 - 0
.gitmodules

@@ -31,3 +31,6 @@
 [submodule "OmnipodKit"]
 [submodule "OmnipodKit"]
 	path = OmnipodKit
 	path = OmnipodKit
 	url = https://github.com/loopandlearn/OmnipodKit
 	url = https://github.com/loopandlearn/OmnipodKit
+[submodule "EversenseKit"]
+	path = EversenseKit
+	url = https://github.com/loopandlearn/EversenseKit

+ 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
 // The developers set the version numbers, please leave them alone
 APP_VERSION = 0.8.4
 APP_VERSION = 0.8.4
-APP_DEV_VERSION = 0.8.4.8
+APP_DEV_VERSION = 0.8.4.12
 APP_BUILD_NUMBER = 1
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 COPYRIGHT_NOTICE =
 
 

+ 1 - 0
EversenseKit

@@ -0,0 +1 @@
+Subproject commit 66979665bc73137b0a70e9b4f6f88f21558fa4c9

+ 5 - 1
LiveActivity/LiveActivity.swift

@@ -126,7 +126,11 @@ private extension LiveActivityAttributes.ContentState {
             tempTargetDate: Date().addingTimeInterval(-1800),
             tempTargetDate: Date().addingTimeInterval(-1800),
             tempTargetDuration: 60,
             tempTargetDuration: 60,
             tempTargetTarget: 120,
             tempTargetTarget: 120,
-            widgetItems: LiveActivityAttributes.LiveActivityItem.defaultItems
+            widgetItems: LiveActivityAttributes.LiveActivityItem.defaultItems,
+            minForecast: [],
+            maxForecast: [],
+            forecastLines: [],
+            forecastDisplayType: "cone"
         )
         )
 
 
     // 0 is the widest digit. Use this to get an upper bound on text width.
     // 0 is the widest digit. Use this to get an upper bound on text width.

+ 97 - 4
LiveActivity/Views/LiveActivityChartView.swift

@@ -9,6 +9,11 @@ import Foundation
 import SwiftUI
 import SwiftUI
 import WidgetKit
 import WidgetKit
 
 
+private enum ForecastDisplayTypeKey {
+    static let lines = "lines"
+    static let cone = "cone"
+}
+
 struct LiveActivityChartView: View {
 struct LiveActivityChartView: View {
     @Environment(\.colorScheme) var colorScheme
     @Environment(\.colorScheme) var colorScheme
     @Environment(\.isWatchOS) var isWatchOS
     @Environment(\.isWatchOS) var isWatchOS
@@ -22,9 +27,13 @@ struct LiveActivityChartView: View {
 
 
         let maxThreshhold: Decimal = isWatchOS ? 220 : 300
         let maxThreshhold: Decimal = isWatchOS ? 220 : 300
 
 
-        // Determine scale
-        let minValue = min(additionalState.chart.min(by: { $0.value < $1.value })?.value ?? 39, 39)
-        let maxValue = max(additionalState.chart.max(by: { $0.value < $1.value })?.value ?? maxThreshhold, maxThreshhold)
+        // Determine scale, accounting for both glucose history and prediction values
+        let chartMin = additionalState.chart.min(by: { $0.value < $1.value })?.value ?? 39
+        let chartMax = additionalState.chart.max(by: { $0.value < $1.value })?.value ?? maxThreshhold
+        let forecastMin = additionalState.minForecast.min().map { Decimal($0) } ?? chartMin
+        let forecastMax = min(additionalState.maxForecast.max().map { Decimal($0) } ?? chartMax, maxThreshhold)
+        let minValue = min(min(chartMin, forecastMin), 39)
+        let maxValue = max(max(chartMax, forecastMax), maxThreshhold)
 
 
         let yAxisRuleMarkMin = isMgdL ? state.lowGlucose : state.lowGlucose
         let yAxisRuleMarkMin = isMgdL ? state.lowGlucose : state.lowGlucose
             .asMmolL
             .asMmolL
@@ -34,12 +43,22 @@ struct LiveActivityChartView: View {
 
 
         let isOverrideActive = additionalState.isOverrideActive == true
         let isOverrideActive = additionalState.isOverrideActive == true
         let isTempTargetActive = additionalState.isTempTargetActive == true
         let isTempTargetActive = additionalState.isTempTargetActive == true
+        let hasForecast = !additionalState.minForecast.isEmpty || !additionalState.forecastLines.isEmpty
 
 
         let calendar = Calendar.current
         let calendar = Calendar.current
         let now = Date()
         let now = Date()
 
 
         let startDate = calendar.date(byAdding: .hour, value: isWatchOS ? -3 : -6, to: now) ?? now
         let startDate = calendar.date(byAdding: .hour, value: isWatchOS ? -3 : -6, to: now) ?? now
-        let endDate = calendar.date(byAdding: .minute, value: isWatchOS ? 5 : 0, to: now) ?? now
+        let endDate: Date = {
+            let baseEnd = calendar.date(byAdding: .minute, value: isWatchOS ? 5 : 0, to: now) ?? now
+            guard hasForecast, let anchorDate = state.date else { return baseEnd }
+            let forecastCount = max(
+                additionalState.minForecast.count,
+                additionalState.forecastLines.max(by: { $0.values.count < $1.values.count })?.values.count ?? 0
+            )
+            let predictionEnd = anchorDate.addingTimeInterval(TimeInterval(forecastCount * 300))
+            return max(baseEnd, predictionEnd)
+        }()
 
 
         // 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
         // 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 = isMgdL ? Decimal(55) : 55.asMmolL
         let hardCodedLow = isMgdL ? Decimal(55) : 55.asMmolL
@@ -83,6 +102,14 @@ struct LiveActivityChartView: View {
                 drawActiveTempTarget()
                 drawActiveTempTarget()
             }
             }
 
 
+            if hasForecast, let anchorDate = state.date {
+                if additionalState.forecastDisplayType == ForecastDisplayTypeKey.lines {
+                    drawForecastLines(anchorDate: anchorDate, isMgdL: isMgdL)
+                } else {
+                    drawForecastCone(anchorDate: anchorDate, isMgdL: isMgdL, maxValue: maxValue)
+                }
+            }
+
             drawChart(yAxisRuleMarkMin: yAxisRuleMarkMin, yAxisRuleMarkMax: yAxisRuleMarkMax)
             drawChart(yAxisRuleMarkMin: yAxisRuleMarkMin, yAxisRuleMarkMax: yAxisRuleMarkMax)
         }
         }
         .chartYAxis {
         .chartYAxis {
@@ -149,6 +176,72 @@ struct LiveActivityChartView: View {
         .lineStyle(.init(lineWidth: 8))
         .lineStyle(.init(lineWidth: 8))
     }
     }
 
 
+    private func timeForIndex(_ index: Int, anchorDate: Date) -> Date {
+        anchorDate.addingTimeInterval(TimeInterval(index * 300))
+    }
+
+    private func drawForecastCone(anchorDate: Date, isMgdL: Bool, maxValue: Decimal) -> some ChartContent {
+        let minForecast = additionalState.minForecast
+        let maxForecast = additionalState.maxForecast
+        let cappedMax = isMgdL ? maxValue : maxValue.asMmolL
+        let count = min(minForecast.count, maxForecast.count)
+
+        // Pre-compute cone data to avoid conditionals inside the ForEach closure
+        let coneData: [(date: Date, yMin: Decimal, yMax: Decimal)] = (0 ..< count).map { index in
+            let xValue = timeForIndex(index, anchorDate: anchorDate)
+            let delta = minForecast[index] - maxForecast[index]
+            let yMin: Decimal
+            let yMax: Decimal
+            if delta == 0 {
+                let base = isMgdL ? Decimal(minForecast[index]) : Decimal(minForecast[index]).asMmolL
+                yMin = base - 1
+                yMax = base + 1
+            } else {
+                yMin = isMgdL ? Decimal(minForecast[index]) : Decimal(minForecast[index]).asMmolL
+                yMax = isMgdL ? Decimal(maxForecast[index]) : Decimal(maxForecast[index]).asMmolL
+            }
+            return (date: xValue, yMin: min(yMin, cappedMax), yMax: min(yMax, cappedMax))
+        }
+
+        return ForEach(coneData.indices, id: \.self) { i in
+            AreaMark(
+                x: .value("Time", coneData[i].date),
+                yStart: .value("Min", coneData[i].yMin),
+                yEnd: .value("Max", coneData[i].yMax)
+            )
+            .foregroundStyle(Color.blue.opacity(0.5))
+            .interpolationMethod(.linear)
+        }
+    }
+
+    private func drawForecastLines(anchorDate: Date, isMgdL: Bool) -> some ChartContent {
+        let colorMap: [String: Color] = [
+            "iob": Color(red: 0.118, green: 0.588, blue: 0.988),
+            "cob": Color.orange,
+            "uam": Color(red: 0.820, green: 0.169, blue: 0.969),
+            "zt": Color(red: 0.443, green: 0.380, blue: 0.937)
+        ]
+
+        let points: [(series: String, date: Date, value: Decimal)] = additionalState.forecastLines.flatMap { line in
+            line.values.enumerated().map { index, value in
+                let displayValue = isMgdL ? Decimal(value) : Decimal(value).asMmolL
+                return (series: line.type, date: timeForIndex(index, anchorDate: anchorDate), value: displayValue)
+            }
+        }
+
+        return ForEach(0 ..< points.count, id: \.self) { i in
+            let point = points[i]
+            LineMark(
+                x: .value("Time", point.date),
+                y: .value("Value", point.value),
+                series: .value("Type", point.series)
+            )
+            .foregroundStyle(colorMap[point.series] ?? Color.gray)
+            .lineStyle(.init(lineWidth: 1.5))
+            .interpolationMethod(.linear)
+        }
+    }
+
     private func drawChart(yAxisRuleMarkMin _: Decimal, yAxisRuleMarkMax _: Decimal) -> some ChartContent {
     private func drawChart(yAxisRuleMarkMin _: Decimal, yAxisRuleMarkMax _: Decimal) -> some ChartContent {
         // 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
         // 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 hardCodedLow = Decimal(55)

+ 10 - 0
Trio.xcodeproj/project.pbxproj

@@ -313,6 +313,9 @@
 		3E54EF2C2E476DA40006F54D /* MedtrumKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */; };
 		3E54EF2C2E476DA40006F54D /* MedtrumKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */; };
 		3E54EF2D2E476DA40006F54D /* MedtrumKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
 		3E54EF2D2E476DA40006F54D /* MedtrumKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
 		3E62C7822F54CC1B00433237 /* BolusDisplayThreshold.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */; };
 		3E62C7822F54CC1B00433237 /* BolusDisplayThreshold.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */; };
+		3E705AFB2FF94FAB0007F96B /* BolusStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E705AFA2FF94FAB0007F96B /* BolusStatus.swift */; };
+		3E84DA402F48D96000033608 /* EversenseKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E84DA3F2F48D96000033608 /* EversenseKit.framework */; };
+		3E84DA412F48D96000033608 /* EversenseKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3E84DA3F2F48D96000033608 /* EversenseKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
 		3EF667132FE48509009FB31A /* BasalDeliveryState+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF667122FE48502009FB31A /* BasalDeliveryState+Extension.swift */; };
 		3EF667132FE48509009FB31A /* BasalDeliveryState+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EF667122FE48502009FB31A /* BasalDeliveryState+Extension.swift */; };
 		3F23E18680094E6DA98628E4 /* QuickPickBolusesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A54068ABDAE4898B243DF14 /* QuickPickBolusesView.swift */; };
 		3F23E18680094E6DA98628E4 /* QuickPickBolusesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A54068ABDAE4898B243DF14 /* QuickPickBolusesView.swift */; };
 		41740E936552456AAC0EDAC3 /* SettingsSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */; };
 		41740E936552456AAC0EDAC3 /* SettingsSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */; };
@@ -976,6 +979,7 @@
 				CE95BF642BA771BE00DC3DE3 /* LoopTestingKit.framework in Embed Frameworks */,
 				CE95BF642BA771BE00DC3DE3 /* LoopTestingKit.framework in Embed Frameworks */,
 				CE95BF622BA7715900DC3DE3 /* MockKitUI.framework in Embed Frameworks */,
 				CE95BF622BA7715900DC3DE3 /* MockKitUI.framework in Embed Frameworks */,
 				3B4BA78D2D8DC0EC0069D5B8 /* ShareClientUI.framework in Embed Frameworks */,
 				3B4BA78D2D8DC0EC0069D5B8 /* ShareClientUI.framework in Embed Frameworks */,
+				3E84DA412F48D96000033608 /* EversenseKit.framework in Embed Frameworks */,
 				3B4BA7872D8DBD690069D5B8 /* RileyLinkKitUI.framework in Embed Frameworks */,
 				3B4BA7872D8DBD690069D5B8 /* RileyLinkKitUI.framework in Embed Frameworks */,
 				3B4BA78B2D8DC0EC0069D5B8 /* ShareClient.framework in Embed Frameworks */,
 				3B4BA78B2D8DC0EC0069D5B8 /* ShareClient.framework in Embed Frameworks */,
 				3B4BA7912D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework in Embed Frameworks */,
 				3B4BA7912D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework in Embed Frameworks */,
@@ -1321,6 +1325,8 @@
 		3BF92F392D86F1AA006B545A /* iob-error-log.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "iob-error-log.json"; sourceTree = "<group>"; };
 		3BF92F392D86F1AA006B545A /* iob-error-log.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "iob-error-log.json"; sourceTree = "<group>"; };
 		3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MedtrumKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MedtrumKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusDisplayThreshold.swift; sourceTree = "<group>"; };
 		3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusDisplayThreshold.swift; sourceTree = "<group>"; };
+		3E705AFA2FF94FAB0007F96B /* BolusStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusStatus.swift; sourceTree = "<group>"; };
+		3E84DA3F2F48D96000033608 /* EversenseKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = EversenseKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		3EF667122FE48502009FB31A /* BasalDeliveryState+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BasalDeliveryState+Extension.swift"; sourceTree = "<group>"; };
 		3EF667122FE48502009FB31A /* BasalDeliveryState+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BasalDeliveryState+Extension.swift"; sourceTree = "<group>"; };
 		3F60E97100041040446F44E7 /* PumpConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigStateModel.swift; sourceTree = "<group>"; };
 		3F60E97100041040446F44E7 /* PumpConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigStateModel.swift; sourceTree = "<group>"; };
 		3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigEditorDataFlow.swift; sourceTree = "<group>"; };
 		3F8A87AA037BD079BA3528BA /* ConfigEditorDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigEditorDataFlow.swift; sourceTree = "<group>"; };
@@ -1948,6 +1954,7 @@
 				B958F1B72BA0711600484851 /* MKRingProgressView in Frameworks */,
 				B958F1B72BA0711600484851 /* MKRingProgressView in Frameworks */,
 				3B4BA7702D8DBD690069D5B8 /* G7SensorKit.framework in Frameworks */,
 				3B4BA7702D8DBD690069D5B8 /* G7SensorKit.framework in Frameworks */,
 				3B4BA76C2D8DBD690069D5B8 /* CGMBLEKitUI.framework in Frameworks */,
 				3B4BA76C2D8DBD690069D5B8 /* CGMBLEKitUI.framework in Frameworks */,
+				3E84DA402F48D96000033608 /* EversenseKit.framework in Frameworks */,
 				CE95BF5B2BA770C300DC3DE3 /* LoopKit.framework in Frameworks */,
 				CE95BF5B2BA770C300DC3DE3 /* LoopKit.framework in Frameworks */,
 				38B17B6625DD90E0005CAE3D /* SwiftDate in Frameworks */,
 				38B17B6625DD90E0005CAE3D /* SwiftDate in Frameworks */,
 				3833B46D26012030003021B3 /* Algorithms in Frameworks */,
 				3833B46D26012030003021B3 /* Algorithms in Frameworks */,
@@ -2556,6 +2563,7 @@
 			children = (
 			children = (
 				B6E925122EB3932A0076D719 /* OmnipodKit.framework */,
 				B6E925122EB3932A0076D719 /* OmnipodKit.framework */,
 				3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */,
 				3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */,
+				3E84DA3F2F48D96000033608 /* EversenseKit.framework */,
 				3B4BA7882D8DC0EC0069D5B8 /* TidepoolServiceKit.framework */,
 				3B4BA7882D8DC0EC0069D5B8 /* TidepoolServiceKit.framework */,
 				3B4BA7892D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework */,
 				3B4BA7892D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework */,
 				3B4BA75B2D8DBD690069D5B8 /* CGMBLEKit.framework */,
 				3B4BA75B2D8DBD690069D5B8 /* CGMBLEKit.framework */,
@@ -2738,6 +2746,7 @@
 				388358C725EEF6D200E024B2 /* BasalProfileEntry.swift */,
 				388358C725EEF6D200E024B2 /* BasalProfileEntry.swift */,
 				38D0B3B525EBE24900CB6E88 /* Battery.swift */,
 				38D0B3B525EBE24900CB6E88 /* Battery.swift */,
 				382C134A25F14E3700715CE1 /* BGTargets.swift */,
 				382C134A25F14E3700715CE1 /* BGTargets.swift */,
+				3E705AFA2FF94FAB0007F96B /* BolusStatus.swift */,
 				3870FF4225EC13F40088248F /* BloodGlucose.swift */,
 				3870FF4225EC13F40088248F /* BloodGlucose.swift */,
 				38A9260425F012D8009E3739 /* CarbRatios.swift */,
 				38A9260425F012D8009E3739 /* CarbRatios.swift */,
 				38D0B3D825EC07C400CB6E88 /* CarbsEntry.swift */,
 				38D0B3D825EC07C400CB6E88 /* CarbsEntry.swift */,
@@ -5574,6 +5583,7 @@
 				DDE1795E2C910127003CDDB7 /* PumpEventStored+CoreDataClass.swift in Sources */,
 				DDE1795E2C910127003CDDB7 /* PumpEventStored+CoreDataClass.swift in Sources */,
 				3BBB76AA2E01C70B0040977D /* MealCob.swift in Sources */,
 				3BBB76AA2E01C70B0040977D /* MealCob.swift in Sources */,
 				BDDAF9EF2D00554500B34E7A /* SelectionPopoverView.swift in Sources */,
 				BDDAF9EF2D00554500B34E7A /* SelectionPopoverView.swift in Sources */,
+				3E705AFB2FF94FAB0007F96B /* BolusStatus.swift in Sources */,
 				DDE1795F2C910127003CDDB7 /* PumpEventStored+CoreDataProperties.swift in Sources */,
 				DDE1795F2C910127003CDDB7 /* PumpEventStored+CoreDataProperties.swift in Sources */,
 				DDE179602C910127003CDDB7 /* StatsData+CoreDataClass.swift in Sources */,
 				DDE179602C910127003CDDB7 /* StatsData+CoreDataClass.swift in Sources */,
 				DD30BA0E2E07700000DA677C /* Profile+Autosens.swift in Sources */,
 				DD30BA0E2E07700000DA677C /* Profile+Autosens.swift in Sources */,

+ 3 - 0
Trio.xcworkspace/contents.xcworkspacedata

@@ -35,6 +35,9 @@
       location = "group:G7SensorKit/G7SensorKit.xcodeproj">
       location = "group:G7SensorKit/G7SensorKit.xcodeproj">
    </FileRef>
    </FileRef>
    <FileRef
    <FileRef
+      location = "group:EversenseKit/EversenseKit.xcodeproj">
+   </FileRef>
+   <FileRef
       location = "group:TidepoolService/TidepoolService.xcodeproj">
       location = "group:TidepoolService/TidepoolService.xcodeproj">
    </FileRef>
    </FileRef>
 </Workspace>
 </Workspace>

+ 9 - 3
Trio/Sources/APS/APSManager.swift

@@ -213,10 +213,12 @@ final class BaseAPSManager: APSManager, Injectable {
 
 
         deviceDataManager.bolusTrigger
         deviceDataManager.bolusTrigger
             .receive(on: processQueue)
             .receive(on: processQueue)
-            .sink { [weak self] bolusing in
-                if bolusing {
+            .sink { [weak self] bolusState in
+                switch bolusState {
+                case .initiating,
+                     .inProgress:
                     self?.createBolusReporter()
                     self?.createBolusReporter()
-                } else {
+                case .noBolus:
                     self?.clearBolusReporter()
                     self?.clearBolusReporter()
                 }
                 }
             }
             }
@@ -1347,6 +1349,10 @@ final class BaseAPSManager: APSManager, Injectable {
     /// Mutations are dispatched onto the queue regardless, so a future
     /// Mutations are dispatched onto the queue regardless, so a future
     /// caller from another context (e.g. `cancelBolus`) stays safe.
     /// caller from another context (e.g. `cancelBolus`) stays safe.
     private func createBolusReporter() {
     private func createBolusReporter() {
+        if bolusReporter != nil {
+            return
+        }
+
         processQueue.async {
         processQueue.async {
             self.bolusReporter = self.pumpManager?.createBolusProgressReporter(reportingOn: self.processQueue)
             self.bolusReporter = self.pumpManager?.createBolusProgressReporter(reportingOn: self.processQueue)
             self.bolusReporter?.addObserver(self)
             self.bolusReporter?.addObserver(self)

+ 9 - 6
Trio/Sources/APS/DeviceDataManager.swift

@@ -21,7 +21,7 @@ protocol DeviceDataManager: GlucoseSource {
     var loopInProgress: Bool { get set }
     var loopInProgress: Bool { get set }
     var pumpDisplayState: CurrentValueSubject<PumpDisplayState?, Never> { get }
     var pumpDisplayState: CurrentValueSubject<PumpDisplayState?, Never> { get }
     var recommendsLoop: PassthroughSubject<Void, Never> { get }
     var recommendsLoop: PassthroughSubject<Void, Never> { get }
-    var bolusTrigger: PassthroughSubject<Bool, Never> { get }
+    var bolusTrigger: PassthroughSubject<BolusStatus, Never> { get }
     var manualTempBasal: PassthroughSubject<Bool, Never> { get }
     var manualTempBasal: PassthroughSubject<Bool, Never> { get }
     var scheduledBasal: PassthroughSubject<Bool?, Never> { get }
     var scheduledBasal: PassthroughSubject<Bool?, Never> { get }
     var suspended: PassthroughSubject<Bool, Never> { get }
     var suspended: PassthroughSubject<Bool, Never> { get }
@@ -69,7 +69,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable {
         .distantPast
         .distantPast
 
 
     let recommendsLoop = PassthroughSubject<Void, Never>()
     let recommendsLoop = PassthroughSubject<Void, Never>()
-    let bolusTrigger = PassthroughSubject<Bool, Never>()
+    let bolusTrigger = PassthroughSubject<BolusStatus, Never>()
     let errorSubject = PassthroughSubject<Error, Never>()
     let errorSubject = PassthroughSubject<Error, Never>()
     let pumpNewStatus = PassthroughSubject<Void, Never>()
     let pumpNewStatus = PassthroughSubject<Void, Never>()
     let manualTempBasal = PassthroughSubject<Bool, Never>()
     let manualTempBasal = PassthroughSubject<Bool, Never>()
@@ -470,10 +470,13 @@ extension BaseDeviceDataManager: PumpManagerDelegate {
         debug(.deviceManager, "New pump status Bolus: \(status.bolusState)")
         debug(.deviceManager, "New pump status Bolus: \(status.bolusState)")
         debug(.deviceManager, "New pump status Basal: \(String(describing: status.basalDeliveryState))")
         debug(.deviceManager, "New pump status Basal: \(String(describing: status.basalDeliveryState))")
 
 
-        if case .inProgress = status.bolusState {
-            bolusTrigger.send(true)
-        } else {
-            bolusTrigger.send(false)
+        switch status.bolusState {
+        case .initiating:
+            bolusTrigger.send(.initiating)
+        case let .inProgress(dose):
+            bolusTrigger.send(.inProgress)
+        default:
+            bolusTrigger.send(.noBolus)
         }
         }
 
 
         switch status.basalDeliveryState {
         switch status.basalDeliveryState {

+ 6 - 0
Trio/Sources/APS/PluginManager.swift

@@ -1,4 +1,5 @@
 import CGMBLEKit
 import CGMBLEKit
+import EversenseKit
 import Foundation
 import Foundation
 import G7SensorKit
 import G7SensorKit
 import G7SensorKitUI
 import G7SensorKitUI
@@ -40,6 +41,11 @@ class BasePluginManager: Injectable, PluginManager {
             pluginIdentifier: LibreTransmitterManagerV3.pluginIdentifier,
             pluginIdentifier: LibreTransmitterManagerV3.pluginIdentifier,
             localizedTitle: String(localized: "FreeStyle Libre"),
             localizedTitle: String(localized: "FreeStyle Libre"),
             manager: LibreTransmitterManagerV3.self
             manager: LibreTransmitterManagerV3.self
+        ),
+        CgmPluginDescription(
+            pluginIdentifier: EversenseCGMManager.pluginIdentifier,
+            localizedTitle: String(localized: "Eversense"),
+            manager: EversenseCGMManager.self
         )
         )
     ]
     ]
 
 

+ 1 - 0
Trio/Sources/Helpers/CGMOptions.swift

@@ -4,6 +4,7 @@ let cgmOptions: [CGMOption] = [
     CGMOption(name: "Dexcom G7 / ONE+", predicate: { $0.type == .plugin && $0.displayName.contains("G7") }),
     CGMOption(name: "Dexcom G7 / ONE+", predicate: { $0.type == .plugin && $0.displayName.contains("G7") }),
     CGMOption(name: "Dexcom Share", predicate: { $0.type == .plugin && $0.displayName.contains("Dexcom Share") }),
     CGMOption(name: "Dexcom Share", predicate: { $0.type == .plugin && $0.displayName.contains("Dexcom Share") }),
     CGMOption(name: "FreeStyle Libre", predicate: { $0.type == .plugin && $0.displayName == "FreeStyle Libre" }),
     CGMOption(name: "FreeStyle Libre", predicate: { $0.type == .plugin && $0.displayName == "FreeStyle Libre" }),
+    CGMOption(name: "Eversense", predicate: { $0.type == .plugin && $0.displayName == "Eversense" }),
     CGMOption(
     CGMOption(
         name: "FreeStyle Libre Demo",
         name: "FreeStyle Libre Demo",
         predicate: { $0.type == .plugin && $0.displayName == "FreeStyle Libre Demo" }
         predicate: { $0.type == .plugin && $0.displayName == "FreeStyle Libre Demo" }

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

@@ -98278,6 +98278,12 @@
         }
         }
       }
       }
     },
     },
+    "Display Glucose Forecasts" : {
+
+    },
+    "Display Glucose Forecasts made by the Oref algorithm." : {
+
+    },
     "Display HR on Watch" : {
     "Display HR on Watch" : {
       "comment" : "Option to show HR in Watch app",
       "comment" : "Option to show HR in Watch app",
       "extractionState" : "manual",
       "extractionState" : "manual",
@@ -115568,6 +115574,10 @@
         }
         }
       }
       }
     },
     },
+    "Eversense" : {
+      "comment" : "Localized title for the Eversense CGM manager.",
+      "isCommentAutoGenerated" : true
+    },
     "Everything you enter here can be adjusted later in the app." : {
     "Everything you enter here can be adjusted later in the app." : {
       "localizations" : {
       "localizations" : {
         "bg" : {
         "bg" : {
@@ -145806,6 +145816,10 @@
         }
         }
       }
       }
     },
     },
+    "Initiating…" : {
+      "comment" : "A label displayed when the pump is waiting to start bolusing.",
+      "isCommentAutoGenerated" : true
+    },
     "Insert Cannula" : {
     "Insert Cannula" : {
       "extractionState" : "manual",
       "extractionState" : "manual",
       "localizations" : {
       "localizations" : {
@@ -164473,6 +164487,10 @@
         }
         }
       }
       }
     },
     },
+    "Max" : {
+      "comment" : "Y-axis value for the upper bound of a cone.",
+      "isCommentAutoGenerated" : true
+    },
     "Max Allowed Glucose Rise for SMB" : {
     "Max Allowed Glucose Rise for SMB" : {
       "comment" : "Max Allowed Glucose Rise for SMB, formerly Max Delta-BG Threshold",
       "comment" : "Max Allowed Glucose Rise for SMB, formerly Max Delta-BG Threshold",
       "localizations" : {
       "localizations" : {
@@ -172771,6 +172789,10 @@
         }
         }
       }
       }
     },
     },
+    "Min" : {
+      "comment" : "Y-axis minimum value.",
+      "isCommentAutoGenerated" : true
+    },
     "Min 4 hours, max 10 hours." : {
     "Min 4 hours, max 10 hours." : {
       "localizations" : {
       "localizations" : {
         "bg" : {
         "bg" : {
@@ -285150,6 +285172,9 @@
         }
         }
       }
       }
     },
     },
+    "When enabled, the live activity widget on the lock screen will show glucose forecasts. The visual representation will be the same as in the Main view. To change between Cone and Lines, change the setting under Features - User Interface - Forecast Display Type." : {
+
+    },
     "When enabled, Trio will create a customizable calendar event to keep you notified of your current glucose reading with every successful loop cycle." : {
     "When enabled, Trio will create a customizable calendar event to keep you notified of your current glucose reading with every successful loop cycle." : {
       "localizations" : {
       "localizations" : {
         "bg" : {
         "bg" : {

+ 7 - 0
Trio/Sources/Models/BolusStatus.swift

@@ -0,0 +1,7 @@
+import LoopKit
+
+enum BolusStatus {
+    case noBolus
+    case initiating
+    case inProgress
+}

+ 5 - 0
Trio/Sources/Models/TrioSettings.swift

@@ -66,6 +66,7 @@ struct TrioSettings: JSON, Equatable, Encodable {
     var useLiveActivity: Bool = false
     var useLiveActivity: Bool = false
     var lockScreenView: LockScreenView = .simple
     var lockScreenView: LockScreenView = .simple
     var smartStackView: LockScreenView = .simple
     var smartStackView: LockScreenView = .simple
+    var displayGlucoseForecasts: Bool = false
     var bolusShortcut: BolusShortcutLimit = .notAllowed
     var bolusShortcut: BolusShortcutLimit = .notAllowed
     var timeInRangeType: TimeInRangeType = .timeInTightRange
     var timeInRangeType: TimeInRangeType = .timeInTightRange
     var requireAdjustmentsConfirmation: Bool = false
     var requireAdjustmentsConfirmation: Bool = false
@@ -311,6 +312,10 @@ extension TrioSettings: Decodable {
             settings.smartStackView = smartStackView
             settings.smartStackView = smartStackView
         }
         }
 
 
+        if let displayGlucoseForecasts = try? container.decode(Bool.self, forKey: .displayGlucoseForecasts) {
+            settings.displayGlucoseForecasts = displayGlucoseForecasts
+        }
+
         if let bolusShortcut = try? container.decode(BolusShortcutLimit.self, forKey: .bolusShortcut) {
         if let bolusShortcut = try? container.decode(BolusShortcutLimit.self, forKey: .bolusShortcut) {
             settings.bolusShortcut = bolusShortcut
             settings.bolusShortcut = bolusShortcut
         }
         }

+ 9 - 0
Trio/Sources/Modules/Home/HomeStateModel.swift

@@ -100,6 +100,7 @@ extension Home {
         var tempBasals: [PumpEventStored] = []
         var tempBasals: [PumpEventStored] = []
         var suspendAndResumeEvents: [PumpEventStored] = []
         var suspendAndResumeEvents: [PumpEventStored] = []
         var batteryFromPersistence: [OpenAPS_Battery] = []
         var batteryFromPersistence: [OpenAPS_Battery] = []
+        var bolusStatus: BolusStatus = .noBolus
         var lastPumpBolus: PumpEventStored?
         var lastPumpBolus: PumpEventStored?
         var overrides: [OverrideStored] = []
         var overrides: [OverrideStored] = []
         var overrideRunStored: [OverrideRunStored] = []
         var overrideRunStored: [OverrideRunStored] = []
@@ -262,6 +263,14 @@ extension Home {
                     self.setupFPUsArray()
                     self.setupFPUsArray()
                 }
                 }
                 .store(in: &subscriptions)
                 .store(in: &subscriptions)
+
+            provider.deviceManager.bolusTrigger
+                .receive(on: queue)
+                .sink { [weak self] state in
+                    guard let self = self else { return }
+                    self.bolusStatus = state
+                }
+                .store(in: &subscriptions)
         }
         }
 
 
         private func registerHandlers() {
         private func registerHandlers() {

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

@@ -792,6 +792,8 @@ extension Home {
                         + String(localized: " of ", comment: "Bolus string partial message: 'x U of y U' in home view") +
                         + String(localized: " of ", comment: "Bolus string partial message: 'x U of y U' in home view") +
                         (Formatter.decimalFormatterWithThreeFractionDigits.string(from: bolusTotal as NSNumber) ?? "0")
                         (Formatter.decimalFormatterWithThreeFractionDigits.string(from: bolusTotal as NSNumber) ?? "0")
                         + String(localized: " U", comment: "Insulin unit")
                         + String(localized: " U", comment: "Insulin unit")
+                let bolusLabel = state
+                    .bolusStatus == .inProgress ? String(localized: "Bolusing") : String(localized: "Initiating…")
 
 
                 ZStack {
                 ZStack {
                     /// rectangle as background
                     /// rectangle as background
@@ -817,7 +819,7 @@ extension Home {
                         Spacer()
                         Spacer()
 
 
                         VStack {
                         VStack {
-                            Text("Bolusing")
+                            Text(bolusLabel)
                                 .font(.subheadline)
                                 .font(.subheadline)
                                 .frame(maxWidth: .infinity, alignment: .leading)
                                 .frame(maxWidth: .infinity, alignment: .leading)
                             Text(bolusString)
                             Text(bolusString)
@@ -827,12 +829,16 @@ extension Home {
 
 
                         Spacer()
                         Spacer()
 
 
-                        Button {
-                            state.showProgressView()
-                            state.cancelBolus()
-                        } label: {
-                            Image(systemName: "xmark.app")
-                                .font(.system(size: 25))
+                        if state.bolusStatus == .inProgress {
+                            Button {
+                                state.showProgressView()
+                                state.cancelBolus()
+                            } label: {
+                                Image(systemName: "xmark.app")
+                                    .font(.system(size: 25))
+                            }
+                        } else if state.bolusStatus == .initiating {
+                            ProgressView()
                         }
                         }
                     }.padding(.horizontal, 10)
                     }.padding(.horizontal, 10)
                         .padding(.trailing, 8)
                         .padding(.trailing, 8)

+ 2 - 0
Trio/Sources/Modules/LiveActivitySettings/LiveActivitySettingsStateModel.swift

@@ -9,11 +9,13 @@ extension LiveActivitySettings {
         @Published var useLiveActivity = false
         @Published var useLiveActivity = false
         @Published var lockScreenView: LockScreenView = .simple
         @Published var lockScreenView: LockScreenView = .simple
         @Published var smartStackView: LockScreenView = .simple
         @Published var smartStackView: LockScreenView = .simple
+        @Published var displayGlucoseForecasts = false
         override func subscribe() {
         override func subscribe() {
             units = settingsManager.settings.units
             units = settingsManager.settings.units
             subscribeSetting(\.useLiveActivity, on: $useLiveActivity) { useLiveActivity = $0 }
             subscribeSetting(\.useLiveActivity, on: $useLiveActivity) { useLiveActivity = $0 }
             subscribeSetting(\.lockScreenView, on: $lockScreenView) { lockScreenView = $0 }
             subscribeSetting(\.lockScreenView, on: $lockScreenView) { lockScreenView = $0 }
             subscribeSetting(\.smartStackView, on: $smartStackView) { smartStackView = $0 }
             subscribeSetting(\.smartStackView, on: $smartStackView) { smartStackView = $0 }
+            subscribeSetting(\.displayGlucoseForecasts, on: $displayGlucoseForecasts) { displayGlucoseForecasts = $0 }
         }
         }
     }
     }
 }
 }

+ 34 - 0
Trio/Sources/Modules/LiveActivitySettings/View/LiveActivitySettingsRootView.swift

@@ -9,6 +9,7 @@ extension LiveActivitySettings {
 
 
         @State private var shouldDisplayHintLockScreen: Bool = false
         @State private var shouldDisplayHintLockScreen: Bool = false
         @State private var shouldDisplayHintSmartStack: Bool = false
         @State private var shouldDisplayHintSmartStack: Bool = false
+        @State private var shouldDisplayHintGlucoseForecasts: Bool = false
         @State var hintDetent = PresentationDetent.large
         @State var hintDetent = PresentationDetent.large
         @State var selectedVerboseHint: AnyView?
         @State var selectedVerboseHint: AnyView?
         @State var hintLabel: String?
         @State var hintLabel: String?
@@ -83,6 +84,30 @@ extension LiveActivitySettings {
                     )
                     )
 
 
                     if state.useLiveActivity {
                     if state.useLiveActivity {
+                        SettingInputSection(
+                            decimalValue: $decimalPlaceholder,
+                            booleanValue: $state.displayGlucoseForecasts,
+                            shouldDisplayHint: $shouldDisplayHintGlucoseForecasts,
+                            selectedVerboseHint: Binding(
+                                get: { selectedVerboseHint },
+                                set: {
+                                    selectedVerboseHint = $0.map { AnyView($0) }
+                                    hintLabel = String(localized: "Display Glucose Forecasts")
+                                }
+                            ),
+                            units: state.units,
+                            type: .boolean,
+                            label: String(localized: "Display Glucose Forecasts"),
+                            miniHint: String(localized: "Display Glucose Forecasts made by the Oref algorithm."),
+                            verboseHint: VStack(alignment: .leading, spacing: 10) {
+                                Text("Default: OFF").bold()
+                                Text(
+                                    "When enabled, the live activity widget on the lock screen will show glucose forecasts. The visual representation will be the same as in the Main view. To change between Cone and Lines, change the setting under Features - User Interface - Forecast Display Type."
+                                )
+                            },
+                            headerText: String(localized: "Display Glucose Forecasts")
+                        )
+
                         Section {
                         Section {
                             VStack {
                             VStack {
                                 Picker(
                                 Picker(
@@ -229,6 +254,15 @@ extension LiveActivitySettings {
                     sheetTitle: String(localized: "Help", comment: "Help sheet title")
                     sheetTitle: String(localized: "Help", comment: "Help sheet title")
                 )
                 )
             }
             }
+            .sheet(isPresented: $shouldDisplayHintGlucoseForecasts) {
+                SettingInputHintView(
+                    hintDetent: $hintDetent,
+                    shouldDisplayHint: $shouldDisplayHintGlucoseForecasts,
+                    hintLabel: hintLabel ?? "",
+                    hintText: selectedVerboseHint ?? AnyView(EmptyView()),
+                    sheetTitle: String(localized: "Help", comment: "Help sheet title")
+                )
+            }
             .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
             .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
             .onAppear(perform: configureView)
             .onAppear(perform: configureView)
             .navigationTitle("Live Activity")
             .navigationTitle("Live Activity")

+ 50 - 17
Trio/Sources/Modules/Settings/View/SettingsRootView.swift

@@ -3,6 +3,7 @@ import LoopKit
 import LoopKitUI
 import LoopKitUI
 import SwiftUI
 import SwiftUI
 import Swinject
 import Swinject
+import UIKit
 
 
 extension Settings {
 extension Settings {
     struct VersionInfo: Equatable {
     struct VersionInfo: Equatable {
@@ -34,6 +35,7 @@ extension Settings {
             isDevUpdateAvailable: false
             isDevUpdateAvailable: false
         )
         )
         @State private var closedLoopDisabled = true
         @State private var closedLoopDisabled = true
+        @State private var showCopiedToast = false
 
 
         @Environment(\.colorScheme) var colorScheme
         @Environment(\.colorScheme) var colorScheme
         @EnvironmentObject var appIcons: Icons
         @EnvironmentObject var appIcons: Icons
@@ -98,30 +100,49 @@ extension Settings {
             }
             }
         }
         }
 
 
+        private func copyVersionInfo(_ text: String) {
+            UIPasteboard.general.string = text
+            UINotificationFeedbackGenerator().notificationOccurred(.success)
+            withAnimation { showCopiedToast = true }
+            DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
+                withAnimation { showCopiedToast = false }
+            }
+        }
+
         var body: some View {
         var body: some View {
             List {
             List {
                 if searchText.isEmpty {
                 if searchText.isEmpty {
                     let buildDetails = BuildDetails.shared
                     let buildDetails = BuildDetails.shared
 
 
-                    Section(
-                        header: Text("BRANCH: \(buildDetails.branchAndSha)").textCase(nil),
-                        content: {
-                            /// The current development version of the app.
-                            ///
-                            /// Follows a semantic pattern where release versions are like `0.5.0`, and
-                            /// development versions increment with a fourth component (e.g., `0.5.0.1`, `0.5.0.2`)
-                            /// after the base release. For example:
-                            /// - After release `0.5.0` → `0.5.0`
-                            /// - First dev push → `0.5.0.1`
-                            /// - Next dev push → `0.5.0.2`
-                            /// - Next release `0.6.0` → `0.6.0`
-                            /// - Next dev push → `0.6.0.1`
-                            ///
-                            /// If the dev version is unavailable, `"unknown"` is returned.
-                            let devVersion = Bundle.main.appDevVersion ?? "unknown"
+                    /// The current development version of the app.
+                    ///
+                    /// Follows a semantic pattern where release versions are like `0.5.0`, and
+                    /// development versions increment with a fourth component (e.g., `0.5.0.1`, `0.5.0.2`)
+                    /// after the base release. For example:
+                    /// - After release `0.5.0` → `0.5.0`
+                    /// - First dev push → `0.5.0.1`
+                    /// - Next dev push → `0.5.0.2`
+                    /// - Next release `0.6.0` → `0.6.0`
+                    /// - Next dev push → `0.6.0.1`
+                    ///
+                    /// If the dev version is unavailable, `"unknown"` is returned.
+                    let devVersion = Bundle.main.appDevVersion ?? "unknown"
 
 
-                            let buildNumber = Bundle.main.buildVersionNumber ?? String(localized: "Unknown")
+                    let buildNumber = Bundle.main.buildVersionNumber ?? String(localized: "Unknown")
 
 
+                    Section(
+                        header: HStack(spacing: 4) {
+                            Button {
+                                copyVersionInfo(
+                                    "Trio v\(devVersion) (\(buildNumber)) \(buildDetails.branchAndSha)"
+                                )
+                            } label: {
+                                Image(systemName: "doc.on.doc.fill")
+                            }
+                            .buttonStyle(.plain)
+                            Text("BRANCH: \(buildDetails.branchAndSha)")
+                        }.textCase(nil),
+                        content: {
                             NavigationLink(destination: SubmodulesView(buildDetails: buildDetails)) {
                             NavigationLink(destination: SubmodulesView(buildDetails: buildDetails)) {
                                 HStack {
                                 HStack {
                                     Image(appIcons.appIcon.rawValue)
                                     Image(appIcons.appIcon.rawValue)
@@ -310,6 +331,18 @@ extension Settings {
                     ).listRowBackground(Color.chart)
                     ).listRowBackground(Color.chart)
                 }
                 }
             }
             }
+            .overlay(alignment: .bottom) {
+                if showCopiedToast {
+                    Label("Copied", systemImage: "checkmark.circle.fill")
+                        .font(.footnote.weight(.semibold))
+                        .foregroundColor(.white)
+                        .padding(.horizontal, 16)
+                        .padding(.vertical, 10)
+                        .background(.ultraThinMaterial, in: Capsule())
+                        .padding(.bottom, 32)
+                        .transition(.move(edge: .bottom).combined(with: .opacity))
+                }
+            }
             .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
             .scrollContentBackground(.hidden).background(appState.trioBackgroundColor(for: colorScheme))
             .sheet(isPresented: $shouldDisplayHint) {
             .sheet(isPresented: $shouldDisplayHint) {
                 SettingInputHintView(
                 SettingInputHintView(

+ 2 - 0
Trio/Sources/Modules/Settings/View/Subviews/SubmodulesView.swift

@@ -28,10 +28,12 @@ struct KeyValueRow: View {
         HStack {
         HStack {
             Text(key)
             Text(key)
                 .foregroundColor(.primary)
                 .foregroundColor(.primary)
+                .textSelection(.enabled)
             Spacer()
             Spacer()
             Text(value)
             Text(value)
                 .foregroundColor(.secondary)
                 .foregroundColor(.secondary)
                 .multilineTextAlignment(.trailing)
                 .multilineTextAlignment(.trailing)
+                .textSelection(.enabled)
         }
         }
     }
     }
 }
 }

+ 6 - 0
Trio/Sources/Modules/SettingsExport/SettingsExportStateModel.swift

@@ -871,6 +871,12 @@ extension SettingsExport {
                     name: String(localized: "Lock Screen Widget Style"),
                     name: String(localized: "Lock Screen Widget Style"),
                     value: trioSettings.lockScreenView.rawValue
                     value: trioSettings.lockScreenView.rawValue
                 )
                 )
+                addSetting(
+                    category: notificationsCategory,
+                    subcategory: liveActivitySubcategory,
+                    name: String(localized: "Display Glucose Forecasts"),
+                    value: trioSettings.displayGlucoseForecasts ? String(localized: "Enabled") : String(localized: "Disabled")
+                )
             }
             }
 
 
             // Services
             // Services

+ 6 - 1
Trio/Sources/Modules/Treatments/TreatmentsStateModel.swift

@@ -139,7 +139,7 @@ extension Treatments {
         typealias PumpEvent = PumpEventStored.EventType
         typealias PumpEvent = PumpEventStored.EventType
 
 
         var bolusProgress: Decimal?
         var bolusProgress: Decimal?
-        var isBolusInProgress: Bool { bolusProgress != nil }
+        var bolusStatus: BolusStatus = .noBolus
         var lastPumpBolus: PumpEventStored?
         var lastPumpBolus: PumpEventStored?
 
 
         func unsubscribe() {
         func unsubscribe() {
@@ -221,6 +221,11 @@ extension Treatments {
                 .receive(on: DispatchQueue.main)
                 .receive(on: DispatchQueue.main)
                 .weakAssign(to: \.bolusProgress, on: self)
                 .weakAssign(to: \.bolusProgress, on: self)
                 .store(in: &lifetime)
                 .store(in: &lifetime)
+
+            provider.deviceManager.bolusTrigger
+                .receive(on: DispatchQueue.main)
+                .weakAssign(to: \.bolusStatus, on: self)
+                .store(in: &lifetime)
         }
         }
 
 
         func cancelBolus() {
         func cancelBolus() {

+ 14 - 9
Trio/Sources/Modules/Treatments/View/TreatmentsRootView.swift

@@ -492,7 +492,7 @@ extension Treatments {
         }
         }
 
 
         var treatmentButton: some View {
         var treatmentButton: some View {
-            let shouldDisplayBolusProgress = state.isBolusInProgress && state.amount > 0 &&
+            let shouldDisplayBolusProgress = state.bolusStatus != .noBolus && state.amount > 0 &&
                 !state.externalInsulin && (state.carbs == 0 || state.fat == 0 || state.protein == 0)
                 !state.externalInsulin && (state.carbs == 0 || state.fat == 0 || state.protein == 0)
 
 
             var treatmentButtonBackground = Color(.systemBlue)
             var treatmentButtonBackground = Color(.systemBlue)
@@ -568,6 +568,7 @@ extension Treatments {
                     + (Formatter.decimalFormatterWithThreeFractionDigits.string(from: bolusTotal as NSNumber) ?? "0")
                     + (Formatter.decimalFormatterWithThreeFractionDigits.string(from: bolusTotal as NSNumber) ?? "0")
                     + String(localized: " U", comment: "Insulin unit")
                     + String(localized: " U", comment: "Insulin unit")
             }()
             }()
+            let bolusLabel = state.bolusStatus == .inProgress ? String(localized: "Bolusing") : String(localized: "Initiating…")
 
 
             ZStack {
             ZStack {
                 // background card
                 // background card
@@ -593,7 +594,7 @@ extension Treatments {
                     Spacer()
                     Spacer()
 
 
                     VStack {
                     VStack {
-                        Text("Bolusing")
+                        Text(bolusLabel)
                             .font(.subheadline)
                             .font(.subheadline)
                             .frame(maxWidth: .infinity, alignment: .leading)
                             .frame(maxWidth: .infinity, alignment: .leading)
                         Text(bolusString)
                         Text(bolusString)
@@ -604,12 +605,16 @@ extension Treatments {
 
 
                     Spacer()
                     Spacer()
 
 
-                    Button { state.cancelBolus() } label: {
-                        Image(systemName: "xmark.app")
-                            .font(.system(size: 25))
-                    }.tint(Color.tabBar)
-                        .buttonStyle(.borderless)
-                        .accessibilityLabel("Cancel bolus")
+                    if state.bolusStatus == .inProgress {
+                        Button { state.cancelBolus() } label: {
+                            Image(systemName: "xmark.app")
+                                .font(.system(size: 25))
+                        }.tint(Color.tabBar)
+                            .buttonStyle(.borderless)
+                            .accessibilityLabel("Cancel bolus")
+                    } else if state.bolusStatus == .initiating {
+                        ProgressView()
+                    }
                 }
                 }
                 .padding(.horizontal, 10)
                 .padding(.horizontal, 10)
                 .padding(.trailing, 8)
                 .padding(.trailing, 8)
@@ -690,7 +695,7 @@ extension Treatments {
 
 
         private var disableTaskButton: Bool {
         private var disableTaskButton: Bool {
             (
             (
-                state.isBolusInProgress && state
+                state.bolusStatus != .noBolus && state
                     .amount > 0 && !state.externalInsulin && (state.carbs == 0 || state.fat == 0 || state.protein == 0)
                     .amount > 0 && !state.externalInsulin && (state.carbs == 0 || state.fat == 0 || state.protein == 0)
             ) || state
             ) || state
                 .addButtonPressed || limitExceeded
                 .addButtonPressed || limitExceeded

+ 40 - 5
Trio/Sources/Services/LiveActivity/Data/DataManager.swift

@@ -34,7 +34,7 @@ extension LiveActivityManager {
             key: "deliverAt",
             key: "deliverAt",
             ascending: false,
             ascending: false,
             fetchLimit: 1,
             fetchLimit: 1,
-            propertiesToFetch: ["cob", "currentTarget", "deliverAt"]
+            relationshipKeyPathsForPrefetching: ["forecasts", "forecasts.forecastValues"]
         )
         )
 
 
         let tddResults = try await CoreDataStack.shared.fetchEntitiesAsync(
         let tddResults = try await CoreDataStack.shared.fetchEntitiesAsync(
@@ -48,7 +48,9 @@ extension LiveActivityManager {
         )
         )
 
 
         return try await context.perform {
         return try await context.perform {
-            guard let determinationResults = results as? [[String: Any]], let tddResults = tddResults as? [[String: Any]] else {
+            guard let determinationResults = results as? [OrefDetermination],
+                  let tddResults = tddResults as? [[String: Any]]
+            else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
             }
 
 
@@ -58,11 +60,44 @@ extension LiveActivityManager {
 
 
             let tddValue = (tddResults.first?["total"] as? NSDecimalNumber)?.decimalValue ?? 0
             let tddValue = (tddResults.first?["total"] as? NSDecimalNumber)?.decimalValue ?? 0
 
 
+            // Compute cone bounds and per-type lines from forecast relationships (cap at 24 values = 2h)
+            var allForecastValues = [[Int]]()
+            var forecastLines = [(type: String, values: [Int])]()
+
+            if let forecasts = determination.forecasts {
+                let hasCarbs = forecasts.contains(where: {
+                    ($0.type == "cob" || $0.type == "uam") && !$0.forecastValuesArray.isEmpty
+                })
+                for forecast in forecasts.sorted(by: { ($0.type ?? "") < ($1.type ?? "") }) {
+                    let values = forecast.forecastValuesArray.prefix(24).map { Int($0.value) }
+                    guard !values.isEmpty else { continue }
+                    // iob is hidden when cob or uam are active (matches phone app behavior)
+                    if forecast.type == "iob", hasCarbs { continue }
+                    allForecastValues.append(Array(values))
+                    if let type = forecast.type {
+                        forecastLines.append((type: type, values: Array(values)))
+                    }
+                }
+            }
+
+            let minCount = allForecastValues.map(\.count).min() ?? 0
+            var minForecast = [Int]()
+            var maxForecast = [Int]()
+
+            for index in 0 ..< minCount {
+                let col = allForecastValues.compactMap { $0.indices.contains(index) ? $0[index] : nil }
+                minForecast.append(col.min() ?? 0)
+                maxForecast.append(col.max() ?? 0)
+            }
+
             return DeterminationData(
             return DeterminationData(
-                cob: (determination["cob"] as? Int) ?? 0,
+                cob: Int(determination.cob),
                 tdd: tddValue,
                 tdd: tddValue,
-                target: (determination["currentTarget"] as? NSDecimalNumber)?.decimalValue ?? 0,
-                date: determination["deliverAt"] as? Date ?? nil
+                target: determination.currentTarget?.decimalValue ?? 0,
+                date: determination.deliverAt,
+                minForecast: minForecast,
+                maxForecast: maxForecast,
+                forecastLines: forecastLines
             )
             )
         }
         }
     }
     }

+ 3 - 0
Trio/Sources/Services/LiveActivity/Data/DeterminationData.swift

@@ -5,4 +5,7 @@ struct DeterminationData {
     let tdd: Decimal
     let tdd: Decimal
     let target: Decimal
     let target: Decimal
     let date: Date?
     let date: Date?
+    let minForecast: [Int]
+    let maxForecast: [Int]
+    let forecastLines: [(type: String, values: [Int])]
 }
 }

+ 9 - 0
Trio/Sources/Services/LiveActivity/LiveActitiyAttributes.swift

@@ -49,6 +49,10 @@ struct LiveActivityAttributes: ActivityAttributes {
         let tempTargetDuration: Decimal
         let tempTargetDuration: Decimal
         let tempTargetTarget: Decimal
         let tempTargetTarget: Decimal
         let widgetItems: [LiveActivityItem]
         let widgetItems: [LiveActivityItem]
+        let minForecast: [Int]
+        let maxForecast: [Int]
+        let forecastLines: [ForecastLine]
+        let forecastDisplayType: String
     }
     }
 
 
     struct ChartItem: Codable, Hashable {
     struct ChartItem: Codable, Hashable {
@@ -56,5 +60,10 @@ struct LiveActivityAttributes: ActivityAttributes {
         let date: Date
         let date: Date
     }
     }
 
 
+    struct ForecastLine: Codable, Hashable {
+        let type: String
+        let values: [Int]
+    }
+
     let startDate: Date
     let startDate: Date
 }
 }

+ 10 - 1
Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift

@@ -118,7 +118,16 @@ extension LiveActivityAttributes.ContentState {
             tempTargetDate: tempTarget?.date ?? Date(),
             tempTargetDate: tempTarget?.date ?? Date(),
             tempTargetDuration: tempTarget?.duration ?? 0,
             tempTargetDuration: tempTarget?.duration ?? 0,
             tempTargetTarget: tempTarget?.target ?? 0,
             tempTargetTarget: tempTarget?.target ?? 0,
-            widgetItems: widgetItems ?? [] // set empty array here to silence compiler; this can never be nil
+            widgetItems: widgetItems ?? [], // set empty array here to silence compiler; this can never be nil
+            minForecast: settings.displayGlucoseForecasts && settings.forecastDisplayType == .cone
+                ? (determination?.minForecast ?? []) : [],
+            maxForecast: settings.displayGlucoseForecasts && settings.forecastDisplayType == .cone
+                ? (determination?.maxForecast ?? []) : [],
+            forecastLines: settings.displayGlucoseForecasts && settings.forecastDisplayType == .lines
+                ? (determination?.forecastLines ?? [])
+                .map { LiveActivityAttributes.ForecastLine(type: $0.type, values: $0.values) }
+                : [],
+            forecastDisplayType: settings.forecastDisplayType.rawValue
         )
         )
 
 
         self.init(
         self.init(

+ 5 - 1
Trio/Sources/Services/LiveActivity/LiveActivityManager.swift

@@ -322,7 +322,11 @@ final class LiveActivityData: ObservableObject {
                                 tempTargetDate: Date.now,
                                 tempTargetDate: Date.now,
                                 tempTargetDuration: 0,
                                 tempTargetDuration: 0,
                                 tempTargetTarget: 0,
                                 tempTargetTarget: 0,
-                                widgetItems: []
+                                widgetItems: [],
+                                minForecast: [],
+                                maxForecast: [],
+                                forecastLines: [],
+                                forecastDisplayType: ForecastDisplayType.cone.rawValue
                             ),
                             ),
                             isInitialState: true
                             isInitialState: true
                         ),
                         ),