Kaynağa Gözat

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 ay önce
ebeveyn
işleme
c9113df4d6

+ 3 - 1
LiveActivity/LiveActivity.swift

@@ -128,7 +128,9 @@ private extension LiveActivityAttributes.ContentState {
             tempTargetTarget: 120,
             tempTargetTarget: 120,
             widgetItems: LiveActivityAttributes.LiveActivityItem.defaultItems,
             widgetItems: LiveActivityAttributes.LiveActivityItem.defaultItems,
             minForecast: [],
             minForecast: [],
-            maxForecast: []
+            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.

+ 39 - 3
LiveActivity/Views/LiveActivityChartView.swift

@@ -38,7 +38,7 @@ 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
+        let hasForecast = !additionalState.minForecast.isEmpty || !additionalState.forecastLines.isEmpty
 
 
         let calendar = Calendar.current
         let calendar = Calendar.current
         let now = Date()
         let now = Date()
@@ -47,7 +47,11 @@ struct LiveActivityChartView: View {
         let endDate: Date = {
         let endDate: Date = {
             let baseEnd = calendar.date(byAdding: .minute, value: isWatchOS ? 5 : 0, to: now) ?? now
             let baseEnd = calendar.date(byAdding: .minute, value: isWatchOS ? 5 : 0, to: now) ?? now
             guard hasForecast, let anchorDate = state.date else { return baseEnd }
             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)
             return max(baseEnd, predictionEnd)
         }()
         }()
 
 
@@ -94,7 +98,11 @@ struct LiveActivityChartView: View {
             }
             }
 
 
             if hasForecast, let anchorDate = state.date {
             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)
             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 {
     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)

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

@@ -60,14 +60,18 @@ extension LiveActivityManager {
 
 
             let tddValue = (tddResults.first?["total"] as? NSDecimalNumber)?.decimalValue ?? 0
             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 allForecastValues = [[Int]]()
+            var forecastLines = [(type: String, values: [Int])]()
 
 
             if let forecasts = determination.forecasts {
             if let forecasts = determination.forecasts {
                 for forecast in forecasts.sorted(by: { ($0.type ?? "") < ($1.type ?? "") }) {
                 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 }
                     guard !values.isEmpty else { continue }
                     allForecastValues.append(Array(values))
                     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,
                 target: determination.currentTarget?.decimalValue ?? 0,
                 date: determination.deliverAt,
                 date: determination.deliverAt,
                 minForecast: minForecast,
                 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 date: Date?
     let minForecast: [Int]
     let minForecast: [Int]
     let maxForecast: [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 widgetItems: [LiveActivityItem]
         let minForecast: [Int]
         let minForecast: [Int]
         let maxForecast: [Int]
         let maxForecast: [Int]
+        let forecastLines: [ForecastLine]
+        let forecastDisplayType: String
     }
     }
 
 
     struct ChartItem: Codable, Hashable {
     struct ChartItem: Codable, Hashable {
@@ -58,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
 }
 }

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

@@ -119,8 +119,13 @@ extension LiveActivityAttributes.ContentState {
             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: 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(
         self.init(

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

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