Sfoglia il codice sorgente

Merge branch 'dev' into feat/bolus-initiating

Bastiaan Verhaar 7 ore fa
parent
commit
44d119561c

+ 3 - 0
.gitmodules

@@ -31,3 +31,6 @@
 [submodule "OmnipodKit"]
 	path = 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
 APP_VERSION = 0.8.4
-APP_DEV_VERSION = 0.8.4.8
+APP_DEV_VERSION = 0.8.4.11
 APP_BUILD_NUMBER = 1
 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),
             tempTargetDuration: 60,
             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.

+ 97 - 4
LiveActivity/Views/LiveActivityChartView.swift

@@ -9,6 +9,11 @@ import Foundation
 import SwiftUI
 import WidgetKit
 
+private enum ForecastDisplayTypeKey {
+    static let lines = "lines"
+    static let cone = "cone"
+}
+
 struct LiveActivityChartView: View {
     @Environment(\.colorScheme) var colorScheme
     @Environment(\.isWatchOS) var isWatchOS
@@ -22,9 +27,13 @@ struct LiveActivityChartView: View {
 
         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
             .asMmolL
@@ -34,12 +43,22 @@ struct LiveActivityChartView: View {
 
         let isOverrideActive = additionalState.isOverrideActive == true
         let isTempTargetActive = additionalState.isTempTargetActive == true
+        let hasForecast = !additionalState.minForecast.isEmpty || !additionalState.forecastLines.isEmpty
 
         let calendar = Calendar.current
         let now = Date()
 
         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
         let hardCodedLow = isMgdL ? Decimal(55) : 55.asMmolL
@@ -83,6 +102,14 @@ struct LiveActivityChartView: View {
                 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)
         }
         .chartYAxis {
@@ -149,6 +176,72 @@ struct LiveActivityChartView: View {
         .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 {
         // 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)

+ 6 - 0
Trio.xcodeproj/project.pbxproj

@@ -314,6 +314,8 @@
 		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 */; };
 		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 */; };
 		3F23E18680094E6DA98628E4 /* QuickPickBolusesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A54068ABDAE4898B243DF14 /* QuickPickBolusesView.swift */; };
 		41740E936552456AAC0EDAC3 /* SettingsSearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */; };
@@ -977,6 +979,7 @@
 				CE95BF642BA771BE00DC3DE3 /* LoopTestingKit.framework in Embed Frameworks */,
 				CE95BF622BA7715900DC3DE3 /* MockKitUI.framework in Embed Frameworks */,
 				3B4BA78D2D8DC0EC0069D5B8 /* ShareClientUI.framework in Embed Frameworks */,
+				3E84DA412F48D96000033608 /* EversenseKit.framework in Embed Frameworks */,
 				3B4BA7872D8DBD690069D5B8 /* RileyLinkKitUI.framework in Embed Frameworks */,
 				3B4BA78B2D8DC0EC0069D5B8 /* ShareClient.framework in Embed Frameworks */,
 				3B4BA7912D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework in Embed Frameworks */,
@@ -1323,6 +1326,7 @@
 		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>"; };
 		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>"; };
 		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>"; };
@@ -1950,6 +1954,7 @@
 				B958F1B72BA0711600484851 /* MKRingProgressView in Frameworks */,
 				3B4BA7702D8DBD690069D5B8 /* G7SensorKit.framework in Frameworks */,
 				3B4BA76C2D8DBD690069D5B8 /* CGMBLEKitUI.framework in Frameworks */,
+				3E84DA402F48D96000033608 /* EversenseKit.framework in Frameworks */,
 				CE95BF5B2BA770C300DC3DE3 /* LoopKit.framework in Frameworks */,
 				38B17B6625DD90E0005CAE3D /* SwiftDate in Frameworks */,
 				3833B46D26012030003021B3 /* Algorithms in Frameworks */,
@@ -2558,6 +2563,7 @@
 			children = (
 				B6E925122EB3932A0076D719 /* OmnipodKit.framework */,
 				3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */,
+				3E84DA3F2F48D96000033608 /* EversenseKit.framework */,
 				3B4BA7882D8DC0EC0069D5B8 /* TidepoolServiceKit.framework */,
 				3B4BA7892D8DC0EC0069D5B8 /* TidepoolServiceKitUI.framework */,
 				3B4BA75B2D8DBD690069D5B8 /* CGMBLEKit.framework */,

+ 3 - 0
Trio.xcworkspace/contents.xcworkspacedata

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

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

@@ -1,4 +1,5 @@
 import CGMBLEKit
+import EversenseKit
 import Foundation
 import G7SensorKit
 import G7SensorKitUI
@@ -40,6 +41,11 @@ class BasePluginManager: Injectable, PluginManager {
             pluginIdentifier: LibreTransmitterManagerV3.pluginIdentifier,
             localizedTitle: String(localized: "FreeStyle Libre"),
             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 Share", predicate: { $0.type == .plugin && $0.displayName.contains("Dexcom Share") }),
     CGMOption(name: "FreeStyle Libre", predicate: { $0.type == .plugin && $0.displayName == "FreeStyle Libre" }),
+    CGMOption(name: "Eversense", predicate: { $0.type == .plugin && $0.displayName == "Eversense" }),
     CGMOption(
         name: "FreeStyle Libre Demo",
         predicate: { $0.type == .plugin && $0.displayName == "FreeStyle Libre Demo" }

+ 21 - 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" : {
       "comment" : "Option to show HR in Watch app",
       "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." : {
       "localizations" : {
         "bg" : {
@@ -164477,6 +164487,10 @@
         }
       }
     },
+    "Max" : {
+      "comment" : "Y-axis value for the upper bound of a cone.",
+      "isCommentAutoGenerated" : true
+    },
     "Max Allowed Glucose Rise for SMB" : {
       "comment" : "Max Allowed Glucose Rise for SMB, formerly Max Delta-BG Threshold",
       "localizations" : {
@@ -172775,6 +172789,10 @@
         }
       }
     },
+    "Min" : {
+      "comment" : "Y-axis minimum value.",
+      "isCommentAutoGenerated" : true
+    },
     "Min 4 hours, max 10 hours." : {
       "localizations" : {
         "bg" : {
@@ -285154,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." : {
       "localizations" : {
         "bg" : {

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

@@ -66,6 +66,7 @@ struct TrioSettings: JSON, Equatable, Encodable {
     var useLiveActivity: Bool = false
     var lockScreenView: LockScreenView = .simple
     var smartStackView: LockScreenView = .simple
+    var displayGlucoseForecasts: Bool = false
     var bolusShortcut: BolusShortcutLimit = .notAllowed
     var timeInRangeType: TimeInRangeType = .timeInTightRange
     var requireAdjustmentsConfirmation: Bool = false
@@ -311,6 +312,10 @@ extension TrioSettings: Decodable {
             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) {
             settings.bolusShortcut = bolusShortcut
         }

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

@@ -9,11 +9,13 @@ extension LiveActivitySettings {
         @Published var useLiveActivity = false
         @Published var lockScreenView: LockScreenView = .simple
         @Published var smartStackView: LockScreenView = .simple
+        @Published var displayGlucoseForecasts = false
         override func subscribe() {
             units = settingsManager.settings.units
             subscribeSetting(\.useLiveActivity, on: $useLiveActivity) { useLiveActivity = $0 }
             subscribeSetting(\.lockScreenView, on: $lockScreenView) { lockScreenView = $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 shouldDisplayHintSmartStack: Bool = false
+        @State private var shouldDisplayHintGlucoseForecasts: Bool = false
         @State var hintDetent = PresentationDetent.large
         @State var selectedVerboseHint: AnyView?
         @State var hintLabel: String?
@@ -83,6 +84,30 @@ extension LiveActivitySettings {
                     )
 
                     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 {
                             VStack {
                                 Picker(
@@ -229,6 +254,15 @@ extension LiveActivitySettings {
                     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))
             .onAppear(perform: configureView)
             .navigationTitle("Live Activity")

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

@@ -3,6 +3,7 @@ import LoopKit
 import LoopKitUI
 import SwiftUI
 import Swinject
+import UIKit
 
 extension Settings {
     struct VersionInfo: Equatable {
@@ -34,6 +35,7 @@ extension Settings {
             isDevUpdateAvailable: false
         )
         @State private var closedLoopDisabled = true
+        @State private var showCopiedToast = false
 
         @Environment(\.colorScheme) var colorScheme
         @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 {
             List {
                 if searchText.isEmpty {
                     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)) {
                                 HStack {
                                     Image(appIcons.appIcon.rawValue)
@@ -310,6 +331,18 @@ extension Settings {
                     ).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))
             .sheet(isPresented: $shouldDisplayHint) {
                 SettingInputHintView(

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

@@ -28,10 +28,12 @@ struct KeyValueRow: View {
         HStack {
             Text(key)
                 .foregroundColor(.primary)
+                .textSelection(.enabled)
             Spacer()
             Text(value)
                 .foregroundColor(.secondary)
                 .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"),
                     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

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

@@ -34,7 +34,7 @@ extension LiveActivityManager {
             key: "deliverAt",
             ascending: false,
             fetchLimit: 1,
-            propertiesToFetch: ["cob", "currentTarget", "deliverAt"]
+            relationshipKeyPathsForPrefetching: ["forecasts", "forecasts.forecastValues"]
         )
 
         let tddResults = try await CoreDataStack.shared.fetchEntitiesAsync(
@@ -48,7 +48,9 @@ extension LiveActivityManager {
         )
 
         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)
             }
 
@@ -58,11 +60,44 @@ extension LiveActivityManager {
 
             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(
-                cob: (determination["cob"] as? Int) ?? 0,
+                cob: Int(determination.cob),
                 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 target: Decimal
     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 tempTargetTarget: Decimal
         let widgetItems: [LiveActivityItem]
+        let minForecast: [Int]
+        let maxForecast: [Int]
+        let forecastLines: [ForecastLine]
+        let forecastDisplayType: String
     }
 
     struct ChartItem: Codable, Hashable {
@@ -56,5 +60,10 @@ struct LiveActivityAttributes: ActivityAttributes {
         let date: Date
     }
 
+    struct ForecastLine: Codable, Hashable {
+        let type: String
+        let values: [Int]
+    }
+
     let startDate: Date
 }

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

@@ -118,7 +118,16 @@ extension LiveActivityAttributes.ContentState {
             tempTargetDate: tempTarget?.date ?? Date(),
             tempTargetDuration: tempTarget?.duration ?? 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(

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

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