瀏覽代碼

feat: Re-add forecast lines mode with payload-safe mutually exclusive data

The lines display type is back, with two changes to keep the payload lean:

- Mutually exclusive arrays: cone mode sends minForecast/maxForecast with
  empty forecastLines; lines mode sends forecastLines with empty min/max.
  Only one set is ever populated per update.
- Cap at 24 values (2h) instead of 36 (3h), halving the raw data sent.

The selection is made in LiveActivityAttributes+Helper where the user
setting is available, so DataManager continues to compute both and
the helper zeroes out whichever is not needed.

Lines use .linear interpolation (same as cone) to keep widget extension
rendering lightweight.
Magnus Reintz 1 月之前
父節點
當前提交
c9113df4d6

+ 3 - 1
LiveActivity/LiveActivity.swift

@@ -128,7 +128,9 @@ private extension LiveActivityAttributes.ContentState {
             tempTargetTarget: 120,
             widgetItems: LiveActivityAttributes.LiveActivityItem.defaultItems,
             minForecast: [],
-            maxForecast: []
+            maxForecast: [],
+            forecastLines: [],
+            forecastDisplayType: "cone"
         )
 
     // 0 is the widest digit. Use this to get an upper bound on text width.

+ 39 - 3
LiveActivity/Views/LiveActivityChartView.swift

@@ -38,7 +38,7 @@ struct LiveActivityChartView: View {
 
         let isOverrideActive = additionalState.isOverrideActive == true
         let isTempTargetActive = additionalState.isTempTargetActive == true
-        let hasForecast = !additionalState.minForecast.isEmpty
+        let hasForecast = !additionalState.minForecast.isEmpty || !additionalState.forecastLines.isEmpty
 
         let calendar = Calendar.current
         let now = Date()
@@ -47,7 +47,11 @@ struct LiveActivityChartView: View {
         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 predictionEnd = anchorDate.addingTimeInterval(TimeInterval(additionalState.minForecast.count * 300))
+            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)
         }()
 
@@ -94,7 +98,11 @@ struct LiveActivityChartView: View {
             }
 
             if hasForecast, let anchorDate = state.date {
-                drawForecastCone(anchorDate: anchorDate, isMgdL: isMgdL, maxValue: maxValue)
+                if additionalState.forecastDisplayType == "lines" {
+                    drawForecastLines(anchorDate: anchorDate, isMgdL: isMgdL)
+                } else {
+                    drawForecastCone(anchorDate: anchorDate, isMgdL: isMgdL, maxValue: maxValue)
+                }
             }
 
             drawChart(yAxisRuleMarkMin: yAxisRuleMarkMin, yAxisRuleMarkMax: yAxisRuleMarkMax)
@@ -201,6 +209,34 @@ struct LiveActivityChartView: View {
         }
     }
 
+    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(Array(points.indices), 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)

+ 8 - 3
Trio/Sources/Services/LiveActivity/Data/DataManager.swift

@@ -60,14 +60,18 @@ extension LiveActivityManager {
 
             let tddValue = (tddResults.first?["total"] as? NSDecimalNumber)?.decimalValue ?? 0
 
-            // Compute cone bounds from forecast relationships (max 36 values = 3h)
+            // 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 {
                 for forecast in forecasts.sorted(by: { ($0.type ?? "") < ($1.type ?? "") }) {
-                    let values = forecast.forecastValuesArray.prefix(36).map { Int($0.value) }
+                    let values = forecast.forecastValuesArray.prefix(24).map { Int($0.value) }
                     guard !values.isEmpty else { continue }
                     allForecastValues.append(Array(values))
+                    if let type = forecast.type {
+                        forecastLines.append((type: type, values: Array(values)))
+                    }
                 }
             }
 
@@ -87,7 +91,8 @@ extension LiveActivityManager {
                 target: determination.currentTarget?.decimalValue ?? 0,
                 date: determination.deliverAt,
                 minForecast: minForecast,
-                maxForecast: maxForecast
+                maxForecast: maxForecast,
+                forecastLines: forecastLines
             )
         }
     }

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

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

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

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

+ 7 - 2
Trio/Sources/Services/LiveActivity/LiveActivityAttributes+Helper.swift

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

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

@@ -324,7 +324,9 @@ final class LiveActivityData: ObservableObject {
                                 tempTargetTarget: 0,
                                 widgetItems: [],
                                 minForecast: [],
-                                maxForecast: []
+                                maxForecast: [],
+                                forecastLines: [],
+                                forecastDisplayType: ForecastDisplayType.cone.rawValue
                             ),
                             isInitialState: true
                         ),