Browse Source

Merge pull request #1194 from t1dude/feat/live-activity-prediction

Live Activity: Adding glucose forecast
Deniz Cengiz 9 hours ago
parent
commit
05f45510a7

+ 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)

+ 17 - 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",
@@ -164477,6 +164483,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" : {
@@ -172775,6 +172785,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" : {
@@ -285154,6 +285168,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" : {

+ 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
         }
         }

+ 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")

+ 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

+ 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
                         ),
                         ),