Просмотр исходного кода

Show active override and temp target with remaining time on the Live Activity and in the remote command views (#719)

* Show active override and temp target with remaining time on the Live Activity and in the remote command views

* Treat Trio's 30 day durations as indefinite and light up the remote command buttons while an override or temp target is active
Jonas Björkert 1 неделя назад
Родитель
Сommit
d8151a95d0

+ 4 - 0
LoopFollow/Controllers/NightScout.swift

@@ -72,11 +72,15 @@ extension MainViewController {
 
     func clearOldOverride() {
         overrideGraphData.removeAll()
+        Observable.shared.override.value = nil
+        Observable.shared.overrideEndAt.value = nil
         updateOverrideGraph()
     }
 
     func clearOldTempTarget() {
         tempTargetGraphData.removeAll()
+        Observable.shared.tempTarget.value = nil
+        Observable.shared.tempTargetEndAt.value = nil
         updateTempTargetGraph()
     }
 

+ 27 - 11
LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift

@@ -7,6 +7,7 @@ extension MainViewController {
     func processNSOverrides(entries: [[String: AnyObject]]) {
         overrideGraphData.removeAll()
         var activeOverrideNote: String?
+        var activeOverrideEndAt: TimeInterval?
 
         let sorted = entries.sorted { lhs, rhs in
             guard
@@ -35,19 +36,28 @@ extension MainViewController {
 
             let start = max(startDate.timeIntervalSince1970, graphHorizon)
 
-            var end: TimeInterval
-            if (e["durationType"] as? String) == "indefinite" { // Only for Loop overrides
-                end = maxEndDate
-            } else {
-                end = start + (e["duration"] as? Double ?? 5) * 60
-            }
+            let nextStart: TimeInterval? = {
+                guard i + 1 < sorted.count,
+                      let nextDateStr = (sorted[i + 1]["timestamp"] as? String) ?? (sorted[i + 1]["created_at"] as? String)
+                else { return nil }
+                return NightscoutUtils.parseDate(nextDateStr)?.timeIntervalSince1970
+            }()
+
+            let durationSeconds = (e["duration"] as? Double ?? 5) * 60
+            // Loop marks indefinite overrides explicitly; Trio represents them
+            // as a ~30-day duration. Treat a week or longer as indefinite.
+            let isIndefinite = (e["durationType"] as? String) == "indefinite"
+                || durationSeconds >= 7 * 24 * 3600
 
-            if i + 1 < sorted.count,
-               let nextDateStr = (sorted[i + 1]["timestamp"] as? String) ?? (sorted[i + 1]["created_at"] as? String),
-               let nextStart = NightscoutUtils.parseDate(nextDateStr)?
-               .timeIntervalSince1970
-            {
+            var end: TimeInterval = isIndefinite ? maxEndDate : start + durationSeconds
+
+            // True end for countdown display: based on the raw start and never
+            // clamped to the graph edge; nil while indefinite.
+            var trueEnd: TimeInterval? = isIndefinite ? nil : startDate.timeIntervalSince1970 + durationSeconds
+
+            if let nextStart = nextStart {
                 end = min(end, nextStart - 60) // avoid overlapping overrides
+                trueEnd = trueEnd.map { min($0, nextStart - 60) }
             }
 
             end = min(end, maxEndDate)
@@ -80,10 +90,12 @@ extension MainViewController {
 
             if now >= start, now < end {
                 activeOverrideNote = e["notes"] as? String ?? e["reason"] as? String
+                activeOverrideEndAt = trueEnd
             }
         }
 
         Observable.shared.override.value = activeOverrideNote
+        Observable.shared.overrideEndAt.value = activeOverrideEndAt
         if Storage.shared.device.value != "Loop" {
             if let note = activeOverrideNote {
                 infoManager.updateInfoData(type: .override, value: note)
@@ -94,5 +106,9 @@ extension MainViewController {
         if Storage.shared.graphOtherTreatments.value {
             updateOverrideGraph()
         }
+
+        #if !targetEnvironment(macCatalyst)
+            LiveActivityManager.shared.refreshFromCurrentState(reason: "overrideChanged")
+        #endif
     }
 }

+ 10 - 0
LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift

@@ -9,6 +9,7 @@ extension MainViewController {
     func processNSTemporaryTarget(entries: [[String: AnyObject]]) {
         tempTargetGraphData.removeAll()
         var activeTempTarget: Int?
+        var activeTempTargetEndAt: TimeInterval?
 
         for (index, currentEntry) in entries.reversed().enumerated() {
             guard let dateStr = currentEntry["timestamp"] as? String ?? currentEntry["created_at"] as? String else { continue }
@@ -27,6 +28,7 @@ extension MainViewController {
                 if let activeIndex = tempTargetGraphData.lastIndex(where: { $0.endDate > dateTimeStamp }) {
                     tempTargetGraphData[activeIndex].endDate = dateTimeStamp
                     activeTempTarget = nil
+                    activeTempTargetEndAt = nil
                 }
                 continue
             }
@@ -58,6 +60,9 @@ extension MainViewController {
             let currentTime = Date().timeIntervalSince1970
             if currentTime < endDate {
                 activeTempTarget = Int(targetValue!)
+                // Trio represents indefinite temp targets as a ~30-day duration;
+                // treat a week or longer as indefinite (no end to display).
+                activeTempTargetEndAt = duration >= 7 * 24 * 3600 ? nil : endDate
             }
         }
 
@@ -72,5 +77,10 @@ extension MainViewController {
         } else {
             Observable.shared.tempTarget.value = nil
         }
+        Observable.shared.tempTargetEndAt.value = activeTempTargetEndAt
+
+        #if !targetEnvironment(macCatalyst)
+            LiveActivityManager.shared.refreshFromCurrentState(reason: "tempTargetChanged")
+        #endif
     }
 }

+ 3 - 0
LoopFollow/LiveActivity/APNSClient.swift

@@ -246,6 +246,9 @@ class APNSClient {
         if let cob = snapshot.cob { snapshotDict["cob"] = cob }
         if let projected = snapshot.projected { snapshotDict["projected"] = projected }
         if let override = snapshot.override { snapshotDict["override"] = override }
+        if let overrideEndAt = snapshot.overrideEndAt { snapshotDict["overrideEndAt"] = overrideEndAt }
+        if let tempTargetMgdl = snapshot.tempTargetMgdl { snapshotDict["tempTargetMgdl"] = tempTargetMgdl }
+        if let tempTargetEndAt = snapshot.tempTargetEndAt { snapshotDict["tempTargetEndAt"] = tempTargetEndAt }
         if let recBolus = snapshot.recBolus { snapshotDict["recBolus"] = recBolus }
         if let battery = snapshot.battery { snapshotDict["battery"] = battery }
         if let pumpBattery = snapshot.pumpBattery { snapshotDict["pumpBattery"] = pumpBattery }

+ 12 - 0
LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift

@@ -33,6 +33,15 @@ protocol CurrentGlucoseStateProviding {
     /// Active override name (nil if no active override).
     var override: String? { get }
 
+    /// End of the active override as Unix epoch seconds (nil = no override or indefinite).
+    var overrideEndAt: TimeInterval? { get }
+
+    /// Active temporary target in mg/dL (nil if no active temp target).
+    var tempTargetMgdl: Double? { get }
+
+    /// End of the active temporary target as Unix epoch seconds.
+    var tempTargetEndAt: TimeInterval? { get }
+
     /// Recommended bolus in units.
     var recBolus: Double? { get }
 
@@ -141,6 +150,9 @@ enum GlucoseSnapshotBuilder {
             cob: provider.cob,
             projected: provider.projectedMgdl,
             override: provider.override,
+            overrideEndAt: provider.overrideEndAt,
+            tempTargetMgdl: provider.tempTargetMgdl,
+            tempTargetEndAt: provider.tempTargetEndAt,
             recBolus: provider.recBolus,
             battery: provider.battery,
             pumpBattery: provider.pumpBattery,

+ 13 - 0
LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift

@@ -2,6 +2,7 @@
 // StorageCurrentGlucoseStateProvider.swift
 
 import Foundation
+import HealthKit
 
 /// Reads the latest glucose state from LoopFollow's Storage and Observable layers.
 /// This is the only file in the pipeline that is allowed to touch Storage.shared
@@ -47,6 +48,18 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding {
         Observable.shared.override.value
     }
 
+    var overrideEndAt: TimeInterval? {
+        Observable.shared.overrideEndAt.value
+    }
+
+    var tempTargetMgdl: Double? {
+        Observable.shared.tempTarget.value?.doubleValue(for: .milligramsPerDeciliter)
+    }
+
+    var tempTargetEndAt: TimeInterval? {
+        Observable.shared.tempTargetEndAt.value
+    }
+
     var recBolus: Double? {
         Observable.shared.deviceRecBolus.value
     }

+ 1 - 1
LoopFollow/LiveActivitySettingsView.swift

@@ -59,7 +59,7 @@
                             get: { slots[index] },
                             set: { selectSlot($0, at: index) }
                         )) {
-                            ForEach(LiveActivitySlotOption.allCases, id: \.self) { option in
+                            ForEach(LiveActivitySlotOption.gridCases, id: \.self) { option in
                                 Text(option.displayName).tag(option)
                             }
                         }

+ 2 - 1
LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift

@@ -6,6 +6,7 @@ import SwiftUI
 struct LoopAPNSRemoteView: View {
     @Environment(\.presentationMode) var presentationMode
     @StateObject private var viewModel = RemoteSettingsViewModel()
+    @ObservedObject private var activeOverrideNote = Observable.shared.override
 
     var body: some View {
         NavigationView {
@@ -20,7 +21,7 @@ struct LoopAPNSRemoteView: View {
                     LazyVGrid(columns: columns, spacing: 16) {
                         CommandButtonView(command: "Meal", iconName: "fork.knife", destination: LoopAPNSCarbsView())
                         CommandButtonView(command: "Bolus", iconName: "syringe", destination: LoopAPNSBolusView())
-                        CommandButtonView(command: "Overrides", iconName: "slider.horizontal.3", destination: OverridePresetsView())
+                        CommandButtonView(command: "Overrides", iconName: "slider.horizontal.3", destination: OverridePresetsView(), isActive: activeOverrideNote.value != nil)
                     }
                     .padding(.horizontal)
                 } else {

+ 7 - 0
LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift

@@ -7,6 +7,7 @@ struct OverridePresetsView: View {
     @StateObject private var viewModel = OverridePresetsViewModel()
     @Environment(\.presentationMode) var presentationMode
     @ObservedObject var overrideNote = Observable.shared.override
+    @ObservedObject var overrideEndAt = Observable.shared.overrideEndAt
 
     var body: some View {
         NavigationView {
@@ -28,6 +29,12 @@ struct OverridePresetsView: View {
                                     Text(activeNote)
                                         .font(.subheadline)
                                         .foregroundColor(.secondary)
+
+                                    if let endAt = overrideEndAt.value {
+                                        RemainingTimeText(endAt: endAt)
+                                            .font(.subheadline)
+                                            .foregroundColor(.secondary)
+                                    }
                                 }
 
                                 Spacer()

+ 34 - 0
LoopFollow/Remote/RemainingTimeText.swift

@@ -0,0 +1,34 @@
+// LoopFollow
+// RemainingTimeText.swift
+
+import SwiftUI
+
+/// Remaining time for an active override / temp target: a live countdown when
+/// 2 hours or less remain, otherwise a static "29d 23h"-style duration —
+/// a multi-day ticker reads poorly.
+struct RemainingTimeText: View {
+    let endAt: TimeInterval
+
+    static let tickerMaxRemaining: TimeInterval = 2 * 3600
+
+    private static let longFormatter: DateComponentsFormatter = {
+        let f = DateComponentsFormatter()
+        f.unitsStyle = .abbreviated
+        f.allowedUnits = [.day, .hour, .minute]
+        f.maximumUnitCount = 2
+        return f
+    }()
+
+    var body: some View {
+        let end = Date(timeIntervalSince1970: endAt)
+        let remaining = end.timeIntervalSinceNow
+        if remaining <= 0 {
+            EmptyView()
+        } else if remaining <= Self.tickerMaxRemaining {
+            Text(timerInterval: Date() ... end, countsDown: true)
+                .monospacedDigit()
+        } else {
+            Text(Self.longFormatter.string(from: remaining) ?? "")
+        }
+    }
+}

+ 9 - 0
LoopFollow/Remote/TRC/OverrideView.swift

@@ -10,6 +10,7 @@ struct OverrideView: View {
 
     @ObservedObject var device = Storage.shared.device
     @ObservedObject var overrideNote = Observable.shared.override
+    @ObservedObject var overrideEndAt = Observable.shared.overrideEndAt
 
     @State private var showAlert: Bool = false
     @State private var alertType: AlertType? = nil
@@ -49,6 +50,14 @@ struct OverrideView: View {
                                     Text(activeNote)
                                         .foregroundColor(.secondary)
                                 }
+                                if let endAt = overrideEndAt.value {
+                                    HStack {
+                                        Text("Time Remaining")
+                                        Spacer()
+                                        RemainingTimeText(endAt: endAt)
+                                            .foregroundColor(.secondary)
+                                    }
+                                }
                                 Button {
                                     alertType = .confirmCancellation
                                     showAlert = true

+ 9 - 0
LoopFollow/Remote/TRC/TempTargetView.swift

@@ -10,6 +10,7 @@ struct TempTargetView: View {
 
     @ObservedObject var device = Storage.shared.device
     @ObservedObject var tempTarget = Observable.shared.tempTarget
+    @ObservedObject var tempTargetEndAt = Observable.shared.tempTargetEndAt
 
     @State private var newHKTarget = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 0.0)
     @State private var duration = HKQuantity(unit: .minute(), doubleValue: 0.0)
@@ -51,6 +52,14 @@ struct TempTargetView: View {
                                     Text(Localizer.formatQuantity(tempTargetValue))
                                     Text(Localizer.getPreferredUnit().localizedShortUnitString).foregroundColor(.secondary)
                                 }
+                                if let endAt = tempTargetEndAt.value {
+                                    HStack {
+                                        Text("Time Remaining")
+                                        Spacer()
+                                        RemainingTimeText(endAt: endAt)
+                                            .foregroundColor(.secondary)
+                                    }
+                                }
                                 Button {
                                     alertType = .confirmCancellation
                                     showAlert = true

+ 14 - 2
LoopFollow/Remote/TRC/TrioRemoteControlView.swift

@@ -5,6 +5,8 @@ import SwiftUI
 
 struct TrioRemoteControlView: View {
     @ObservedObject var viewModel: TrioRemoteControlViewModel
+    @ObservedObject private var activeOverrideNote = Observable.shared.override
+    @ObservedObject private var activeTempTarget = Observable.shared.tempTarget
     @Environment(\.presentationMode) var presentationMode
 
     var body: some View {
@@ -18,8 +20,8 @@ struct TrioRemoteControlView: View {
                 LazyVGrid(columns: columns, spacing: 16) {
                     CommandButtonView(command: "Meal", iconName: "fork.knife", destination: MealView())
                     CommandButtonView(command: "Bolus", iconName: "syringe", destination: BolusView())
-                    CommandButtonView(command: "Temp Target", iconName: "scope", destination: TempTargetView())
-                    CommandButtonView(command: "Overrides", iconName: "slider.horizontal.3", destination: OverrideView())
+                    CommandButtonView(command: "Temp Target", iconName: "scope", destination: TempTargetView(), isActive: activeTempTarget.value != nil)
+                    CommandButtonView(command: "Overrides", iconName: "slider.horizontal.3", destination: OverrideView(), isActive: activeOverrideNote.value != nil)
                 }
                 .padding(.horizontal)
 
@@ -34,6 +36,9 @@ struct CommandButtonView<Destination: View>: View {
     let command: String
     let iconName: String
     let destination: Destination
+    /// Lights the button up with a glow while the corresponding override /
+    /// temp target is active.
+    var isActive: Bool = false
 
     var body: some View {
         NavigationLink(destination: destination) {
@@ -49,6 +54,13 @@ struct CommandButtonView<Destination: View>: View {
             .background(Color.blue)
             .foregroundColor(.white)
             .cornerRadius(8)
+            .overlay {
+                if isActive {
+                    RoundedRectangle(cornerRadius: 8)
+                        .stroke(Color.green, lineWidth: 2.5)
+                }
+            }
+            .shadow(color: isActive ? Color.green.opacity(0.7) : .clear, radius: isActive ? 9 : 0)
         }
         .buttonStyle(PlainButtonStyle())
     }

+ 5 - 0
LoopFollow/Storage/Observable.swift

@@ -15,6 +15,11 @@ class Observable {
     var tempTarget = ObservableValue<HKQuantity?>(default: nil)
     var override = ObservableValue<String?>(default: nil)
 
+    /// End of the active override as Unix epoch seconds; nil = no override or indefinite.
+    var overrideEndAt = ObservableValue<TimeInterval?>(default: nil)
+    /// End of the active temp target as Unix epoch seconds; nil = no active temp target.
+    var tempTargetEndAt = ObservableValue<TimeInterval?>(default: nil)
+
     var minAgoText = ObservableValue<String>(default: "?? min ago")
     var bgText = ObservableValue<String>(default: "BG")
     var bg = ObservableValue<Int?>(default: nil)

+ 93 - 5
LoopFollowLAExtension/LoopFollowLiveActivity.swift

@@ -235,6 +235,8 @@ private struct LockScreenLiveActivityView: View {
                 .frame(maxWidth: .infinity, alignment: .trailing)
             }
 
+            ActiveAdjustmentsView(snapshot: s)
+
             Text(LAAppGroupSettings.showDisplayName()
                 ? "\(LAAppGroupSettings.displayName()) — \(LAFormat.updated(s))"
                 : "Last Update: \(LAFormat.updated(s))")
@@ -365,6 +367,84 @@ private struct SlotView: View {
     }
 }
 
+/// Conditional row showing the active override and/or temp target with a
+/// self-ticking countdown. Shared by the lock screen card and the expanded
+/// Dynamic Island bottom region; renders nothing when neither is active.
+private struct ActiveAdjustmentsView: View {
+    let snapshot: GlucoseSnapshot
+
+    /// Above this remaining time the ticker is skipped (name/value only) —
+    /// a multi-hour or multi-day countdown reads poorly in the compact row.
+    private static let tickerMaxRemaining: TimeInterval = 2 * 3600
+
+    // Ends already in the past are stale data waiting for the next refresh —
+    // drop them rather than render a dead 0:00 timer.
+    private var overrideEnd: Date? {
+        guard let t = snapshot.overrideEndAt, t > Date().timeIntervalSince1970 else { return nil }
+        return Date(timeIntervalSince1970: t)
+    }
+
+    private var tempTargetEnd: Date? {
+        guard let t = snapshot.tempTargetEndAt, t > Date().timeIntervalSince1970 else { return nil }
+        return Date(timeIntervalSince1970: t)
+    }
+
+    /// nil end with a non-nil name means indefinite — keep showing the name;
+    /// a timed override whose end has passed is hidden entirely.
+    private var overrideName: String? {
+        guard let name = snapshot.override else { return nil }
+        if snapshot.overrideEndAt != nil, overrideEnd == nil { return nil }
+        return name
+    }
+
+    private var tempTargetText: String? {
+        guard tempTargetEnd != nil else { return nil }
+        return LAFormat.tempTargetValue(snapshot)
+    }
+
+    var body: some View {
+        if overrideName != nil || tempTargetText != nil {
+            HStack(spacing: 5) {
+                Text("⏱")
+                    .font(.system(size: 11))
+                if let name = overrideName {
+                    Text(name)
+                        .lineLimit(1)
+                        .minimumScaleFactor(0.7)
+                        .layoutPriority(1)
+                    if let end = overrideEnd, end.timeIntervalSinceNow <= Self.tickerMaxRemaining {
+                        countdown(to: end)
+                    }
+                }
+                if overrideName != nil, tempTargetText != nil {
+                    Text("·")
+                        .foregroundStyle(.white.opacity(0.5))
+                }
+                if let tt = tempTargetText, let end = tempTargetEnd {
+                    Text("TT \(tt)")
+                        .lineLimit(1)
+                        .minimumScaleFactor(0.8)
+                    if end.timeIntervalSinceNow <= Self.tickerMaxRemaining {
+                        countdown(to: end)
+                    }
+                }
+            }
+            .font(.system(size: 12, weight: .semibold, design: .rounded))
+            .monospacedDigit()
+            .foregroundStyle(.white.opacity(0.85))
+            .frame(maxWidth: .infinity, alignment: .center)
+        }
+    }
+
+    @ViewBuilder
+    private func countdown(to end: Date) -> some View {
+        // Text(timerInterval:) claims flexible width; cap it so the row stays centered.
+        Text(timerInterval: Date() ... end, countsDown: true)
+            .multilineTextAlignment(.leading)
+            .frame(maxWidth: end.timeIntervalSinceNow >= 3600 ? 62 : 46, alignment: .leading)
+    }
+}
+
 // MARK: - Dynamic Island
 
 private struct DynamicIslandLeadingView: View {
@@ -435,11 +515,14 @@ private struct DynamicIslandBottomView: View {
                 .lineLimit(1)
                 .minimumScaleFactor(0.75)
         } else {
-            Text("Updated at: \(LAFormat.updated(snapshot))")
-                .font(.system(size: 13, weight: .semibold, design: .rounded))
-                .foregroundStyle(.white.opacity(0.92))
-                .lineLimit(1)
-                .minimumScaleFactor(0.85)
+            VStack(spacing: 2) {
+                ActiveAdjustmentsView(snapshot: snapshot)
+                Text("Updated at: \(LAFormat.updated(snapshot))")
+                    .font(.system(size: 13, weight: .semibold, design: .rounded))
+                    .foregroundStyle(.white.opacity(0.92))
+                    .lineLimit(1)
+                    .minimumScaleFactor(0.85)
+            }
         }
     }
 }
@@ -653,6 +736,11 @@ private enum LAFormat {
         s.override ?? "—"
     }
 
+    static func tempTargetValue(_ s: GlucoseSnapshot) -> String? {
+        guard let tt = s.tempTargetMgdl, tt > 0 else { return nil }
+        return formatGlucoseValue(tt, unit: s.unit)
+    }
+
     static func profileName(_ s: GlucoseSnapshot) -> String {
         s.profileName ?? "—"
     }

+ 28 - 1
Shared/GlucoseSnapshot.swift

@@ -52,6 +52,17 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
     /// Active override name (nil if no active override)
     let override: String?
 
+    /// End of the active override as Unix epoch seconds. Only meaningful while
+    /// `override` is non-nil; nil with a non-nil name means the override is
+    /// indefinite (no countdown).
+    let overrideEndAt: TimeInterval?
+
+    /// Active temporary target in mg/dL (nil if no active temp target)
+    let tempTargetMgdl: Double?
+
+    /// End of the active temporary target as Unix epoch seconds (nil if no active temp target)
+    let tempTargetEndAt: TimeInterval?
+
     /// Recommended bolus in units (nil if not available)
     let recBolus: Double?
 
@@ -134,6 +145,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
         cob: Double?,
         projected: Double?,
         override: String? = nil,
+        overrideEndAt: TimeInterval? = nil,
+        tempTargetMgdl: Double? = nil,
+        tempTargetEndAt: TimeInterval? = nil,
         recBolus: Double? = nil,
         battery: Double? = nil,
         pumpBattery: Double? = nil,
@@ -164,6 +178,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
         self.cob = cob
         self.projected = projected
         self.override = override
+        self.overrideEndAt = overrideEndAt
+        self.tempTargetMgdl = tempTargetMgdl
+        self.tempTargetEndAt = tempTargetEndAt
         self.recBolus = recBolus
         self.battery = battery
         self.pumpBattery = pumpBattery
@@ -207,6 +224,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
             cob: cob,
             projected: projected,
             override: override,
+            overrideEndAt: overrideEndAt,
+            tempTargetMgdl: tempTargetMgdl,
+            tempTargetEndAt: tempTargetEndAt,
             recBolus: recBolus,
             battery: battery,
             pumpBattery: pumpBattery,
@@ -243,6 +263,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
         try container.encodeIfPresent(cob, forKey: .cob)
         try container.encodeIfPresent(projected, forKey: .projected)
         try container.encodeIfPresent(override, forKey: .override)
+        try container.encodeIfPresent(overrideEndAt, forKey: .overrideEndAt)
+        try container.encodeIfPresent(tempTargetMgdl, forKey: .tempTargetMgdl)
+        try container.encodeIfPresent(tempTargetEndAt, forKey: .tempTargetEndAt)
         try container.encodeIfPresent(recBolus, forKey: .recBolus)
         try container.encodeIfPresent(battery, forKey: .battery)
         try container.encodeIfPresent(pumpBattery, forKey: .pumpBattery)
@@ -276,6 +299,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
         cob = try container.decodeIfPresent(Double.self, forKey: .cob)
         projected = try container.decodeIfPresent(Double.self, forKey: .projected)
         override = try container.decodeIfPresent(String.self, forKey: .override)
+        overrideEndAt = try container.decodeIfPresent(Double.self, forKey: .overrideEndAt)
+        tempTargetMgdl = try container.decodeIfPresent(Double.self, forKey: .tempTargetMgdl)
+        tempTargetEndAt = try container.decodeIfPresent(Double.self, forKey: .tempTargetEndAt)
         recBolus = try container.decodeIfPresent(Double.self, forKey: .recBolus)
         battery = try container.decodeIfPresent(Double.self, forKey: .battery)
         pumpBattery = try container.decodeIfPresent(Double.self, forKey: .pumpBattery)
@@ -302,7 +328,8 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
     private enum CodingKeys: String, CodingKey {
         case glucose, delta, trend, updatedAt
         case iob, cob, projected
-        case override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU
+        case override, overrideEndAt, tempTargetMgdl, tempTargetEndAt
+        case recBolus, battery, pumpBattery, basalRate, pumpReservoirU
         case autosens, tdd, targetLowMgdl, targetHighMgdl, isfMgdlPerU, carbRatio, carbsToday
         case profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl, maxBgMgdl
         case unit, isNotLooping, showRenewalOverlay

+ 12 - 0
Shared/LAAppGroupSettings.swift

@@ -106,6 +106,14 @@ enum LiveActivitySlotOption: String, CaseIterable, Codable {
         }
     }
 
+    /// Options selectable for the lock-screen 2×2 grid. The active override is
+    /// excluded — the card has a dedicated override/temp-target countdown row.
+    /// It remains selectable for the single CarPlay/Watch slot, which has no
+    /// such row.
+    static var gridCases: [LiveActivitySlotOption] {
+        allCases.filter { $0 != .override }
+    }
+
     /// True when the underlying value may be nil (e.g. Dexcom-only users who have
     /// no Loop data). The widget renders "—" in those cases.
     var isOptional: Bool {
@@ -188,7 +196,11 @@ enum LAAppGroupSettings {
         guard let raw = defaults?.stringArray(forKey: Keys.slots), raw.count == 4 else {
             return LiveActivitySlotDefaults.all
         }
+        // .override is no longer a grid option (the card has a dedicated
+        // override/temp-target row); selections stored by older versions
+        // render as empty cells.
         return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none }
+            .map { $0 == .override ? .none : $0 }
     }
 
     // MARK: - Small widget slot (Write)