Explorar o código

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 hai 1 semana
pai
achega
d8151a95d0

+ 4 - 0
LoopFollow/Controllers/NightScout.swift

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

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

@@ -7,6 +7,7 @@ extension MainViewController {
     func processNSOverrides(entries: [[String: AnyObject]]) {
     func processNSOverrides(entries: [[String: AnyObject]]) {
         overrideGraphData.removeAll()
         overrideGraphData.removeAll()
         var activeOverrideNote: String?
         var activeOverrideNote: String?
+        var activeOverrideEndAt: TimeInterval?
 
 
         let sorted = entries.sorted { lhs, rhs in
         let sorted = entries.sorted { lhs, rhs in
             guard
             guard
@@ -35,19 +36,28 @@ extension MainViewController {
 
 
             let start = max(startDate.timeIntervalSince1970, graphHorizon)
             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
                 end = min(end, nextStart - 60) // avoid overlapping overrides
+                trueEnd = trueEnd.map { min($0, nextStart - 60) }
             }
             }
 
 
             end = min(end, maxEndDate)
             end = min(end, maxEndDate)
@@ -80,10 +90,12 @@ extension MainViewController {
 
 
             if now >= start, now < end {
             if now >= start, now < end {
                 activeOverrideNote = e["notes"] as? String ?? e["reason"] as? String
                 activeOverrideNote = e["notes"] as? String ?? e["reason"] as? String
+                activeOverrideEndAt = trueEnd
             }
             }
         }
         }
 
 
         Observable.shared.override.value = activeOverrideNote
         Observable.shared.override.value = activeOverrideNote
+        Observable.shared.overrideEndAt.value = activeOverrideEndAt
         if Storage.shared.device.value != "Loop" {
         if Storage.shared.device.value != "Loop" {
             if let note = activeOverrideNote {
             if let note = activeOverrideNote {
                 infoManager.updateInfoData(type: .override, value: note)
                 infoManager.updateInfoData(type: .override, value: note)
@@ -94,5 +106,9 @@ extension MainViewController {
         if Storage.shared.graphOtherTreatments.value {
         if Storage.shared.graphOtherTreatments.value {
             updateOverrideGraph()
             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]]) {
     func processNSTemporaryTarget(entries: [[String: AnyObject]]) {
         tempTargetGraphData.removeAll()
         tempTargetGraphData.removeAll()
         var activeTempTarget: Int?
         var activeTempTarget: Int?
+        var activeTempTargetEndAt: TimeInterval?
 
 
         for (index, currentEntry) in entries.reversed().enumerated() {
         for (index, currentEntry) in entries.reversed().enumerated() {
             guard let dateStr = currentEntry["timestamp"] as? String ?? currentEntry["created_at"] as? String else { continue }
             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 }) {
                 if let activeIndex = tempTargetGraphData.lastIndex(where: { $0.endDate > dateTimeStamp }) {
                     tempTargetGraphData[activeIndex].endDate = dateTimeStamp
                     tempTargetGraphData[activeIndex].endDate = dateTimeStamp
                     activeTempTarget = nil
                     activeTempTarget = nil
+                    activeTempTargetEndAt = nil
                 }
                 }
                 continue
                 continue
             }
             }
@@ -58,6 +60,9 @@ extension MainViewController {
             let currentTime = Date().timeIntervalSince1970
             let currentTime = Date().timeIntervalSince1970
             if currentTime < endDate {
             if currentTime < endDate {
                 activeTempTarget = Int(targetValue!)
                 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 {
         } else {
             Observable.shared.tempTarget.value = nil
             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 cob = snapshot.cob { snapshotDict["cob"] = cob }
         if let projected = snapshot.projected { snapshotDict["projected"] = projected }
         if let projected = snapshot.projected { snapshotDict["projected"] = projected }
         if let override = snapshot.override { snapshotDict["override"] = override }
         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 recBolus = snapshot.recBolus { snapshotDict["recBolus"] = recBolus }
         if let battery = snapshot.battery { snapshotDict["battery"] = battery }
         if let battery = snapshot.battery { snapshotDict["battery"] = battery }
         if let pumpBattery = snapshot.pumpBattery { snapshotDict["pumpBattery"] = pumpBattery }
         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).
     /// Active override name (nil if no active override).
     var override: String? { get }
     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.
     /// Recommended bolus in units.
     var recBolus: Double? { get }
     var recBolus: Double? { get }
 
 
@@ -141,6 +150,9 @@ enum GlucoseSnapshotBuilder {
             cob: provider.cob,
             cob: provider.cob,
             projected: provider.projectedMgdl,
             projected: provider.projectedMgdl,
             override: provider.override,
             override: provider.override,
+            overrideEndAt: provider.overrideEndAt,
+            tempTargetMgdl: provider.tempTargetMgdl,
+            tempTargetEndAt: provider.tempTargetEndAt,
             recBolus: provider.recBolus,
             recBolus: provider.recBolus,
             battery: provider.battery,
             battery: provider.battery,
             pumpBattery: provider.pumpBattery,
             pumpBattery: provider.pumpBattery,

+ 13 - 0
LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift

@@ -2,6 +2,7 @@
 // StorageCurrentGlucoseStateProvider.swift
 // StorageCurrentGlucoseStateProvider.swift
 
 
 import Foundation
 import Foundation
+import HealthKit
 
 
 /// Reads the latest glucose state from LoopFollow's Storage and Observable layers.
 /// 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
 /// 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
         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? {
     var recBolus: Double? {
         Observable.shared.deviceRecBolus.value
         Observable.shared.deviceRecBolus.value
     }
     }

+ 1 - 1
LoopFollow/LiveActivitySettingsView.swift

@@ -59,7 +59,7 @@
                             get: { slots[index] },
                             get: { slots[index] },
                             set: { selectSlot($0, at: 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)
                                 Text(option.displayName).tag(option)
                             }
                             }
                         }
                         }

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

@@ -6,6 +6,7 @@ import SwiftUI
 struct LoopAPNSRemoteView: View {
 struct LoopAPNSRemoteView: View {
     @Environment(\.presentationMode) var presentationMode
     @Environment(\.presentationMode) var presentationMode
     @StateObject private var viewModel = RemoteSettingsViewModel()
     @StateObject private var viewModel = RemoteSettingsViewModel()
+    @ObservedObject private var activeOverrideNote = Observable.shared.override
 
 
     var body: some View {
     var body: some View {
         NavigationView {
         NavigationView {
@@ -20,7 +21,7 @@ struct LoopAPNSRemoteView: View {
                     LazyVGrid(columns: columns, spacing: 16) {
                     LazyVGrid(columns: columns, spacing: 16) {
                         CommandButtonView(command: "Meal", iconName: "fork.knife", destination: LoopAPNSCarbsView())
                         CommandButtonView(command: "Meal", iconName: "fork.knife", destination: LoopAPNSCarbsView())
                         CommandButtonView(command: "Bolus", iconName: "syringe", destination: LoopAPNSBolusView())
                         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)
                     .padding(.horizontal)
                 } else {
                 } else {

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

@@ -7,6 +7,7 @@ struct OverridePresetsView: View {
     @StateObject private var viewModel = OverridePresetsViewModel()
     @StateObject private var viewModel = OverridePresetsViewModel()
     @Environment(\.presentationMode) var presentationMode
     @Environment(\.presentationMode) var presentationMode
     @ObservedObject var overrideNote = Observable.shared.override
     @ObservedObject var overrideNote = Observable.shared.override
+    @ObservedObject var overrideEndAt = Observable.shared.overrideEndAt
 
 
     var body: some View {
     var body: some View {
         NavigationView {
         NavigationView {
@@ -28,6 +29,12 @@ struct OverridePresetsView: View {
                                     Text(activeNote)
                                     Text(activeNote)
                                         .font(.subheadline)
                                         .font(.subheadline)
                                         .foregroundColor(.secondary)
                                         .foregroundColor(.secondary)
+
+                                    if let endAt = overrideEndAt.value {
+                                        RemainingTimeText(endAt: endAt)
+                                            .font(.subheadline)
+                                            .foregroundColor(.secondary)
+                                    }
                                 }
                                 }
 
 
                                 Spacer()
                                 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 device = Storage.shared.device
     @ObservedObject var overrideNote = Observable.shared.override
     @ObservedObject var overrideNote = Observable.shared.override
+    @ObservedObject var overrideEndAt = Observable.shared.overrideEndAt
 
 
     @State private var showAlert: Bool = false
     @State private var showAlert: Bool = false
     @State private var alertType: AlertType? = nil
     @State private var alertType: AlertType? = nil
@@ -49,6 +50,14 @@ struct OverrideView: View {
                                     Text(activeNote)
                                     Text(activeNote)
                                         .foregroundColor(.secondary)
                                         .foregroundColor(.secondary)
                                 }
                                 }
+                                if let endAt = overrideEndAt.value {
+                                    HStack {
+                                        Text("Time Remaining")
+                                        Spacer()
+                                        RemainingTimeText(endAt: endAt)
+                                            .foregroundColor(.secondary)
+                                    }
+                                }
                                 Button {
                                 Button {
                                     alertType = .confirmCancellation
                                     alertType = .confirmCancellation
                                     showAlert = true
                                     showAlert = true

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

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

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

@@ -5,6 +5,8 @@ import SwiftUI
 
 
 struct TrioRemoteControlView: View {
 struct TrioRemoteControlView: View {
     @ObservedObject var viewModel: TrioRemoteControlViewModel
     @ObservedObject var viewModel: TrioRemoteControlViewModel
+    @ObservedObject private var activeOverrideNote = Observable.shared.override
+    @ObservedObject private var activeTempTarget = Observable.shared.tempTarget
     @Environment(\.presentationMode) var presentationMode
     @Environment(\.presentationMode) var presentationMode
 
 
     var body: some View {
     var body: some View {
@@ -18,8 +20,8 @@ struct TrioRemoteControlView: View {
                 LazyVGrid(columns: columns, spacing: 16) {
                 LazyVGrid(columns: columns, spacing: 16) {
                     CommandButtonView(command: "Meal", iconName: "fork.knife", destination: MealView())
                     CommandButtonView(command: "Meal", iconName: "fork.knife", destination: MealView())
                     CommandButtonView(command: "Bolus", iconName: "syringe", destination: BolusView())
                     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)
                 .padding(.horizontal)
 
 
@@ -34,6 +36,9 @@ struct CommandButtonView<Destination: View>: View {
     let command: String
     let command: String
     let iconName: String
     let iconName: String
     let destination: Destination
     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 {
     var body: some View {
         NavigationLink(destination: destination) {
         NavigationLink(destination: destination) {
@@ -49,6 +54,13 @@ struct CommandButtonView<Destination: View>: View {
             .background(Color.blue)
             .background(Color.blue)
             .foregroundColor(.white)
             .foregroundColor(.white)
             .cornerRadius(8)
             .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())
         .buttonStyle(PlainButtonStyle())
     }
     }

+ 5 - 0
LoopFollow/Storage/Observable.swift

@@ -15,6 +15,11 @@ class Observable {
     var tempTarget = ObservableValue<HKQuantity?>(default: nil)
     var tempTarget = ObservableValue<HKQuantity?>(default: nil)
     var override = ObservableValue<String?>(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 minAgoText = ObservableValue<String>(default: "?? min ago")
     var bgText = ObservableValue<String>(default: "BG")
     var bgText = ObservableValue<String>(default: "BG")
     var bg = ObservableValue<Int?>(default: nil)
     var bg = ObservableValue<Int?>(default: nil)

+ 93 - 5
LoopFollowLAExtension/LoopFollowLiveActivity.swift

@@ -235,6 +235,8 @@ private struct LockScreenLiveActivityView: View {
                 .frame(maxWidth: .infinity, alignment: .trailing)
                 .frame(maxWidth: .infinity, alignment: .trailing)
             }
             }
 
 
+            ActiveAdjustmentsView(snapshot: s)
+
             Text(LAAppGroupSettings.showDisplayName()
             Text(LAAppGroupSettings.showDisplayName()
                 ? "\(LAAppGroupSettings.displayName()) — \(LAFormat.updated(s))"
                 ? "\(LAAppGroupSettings.displayName()) — \(LAFormat.updated(s))"
                 : "Last Update: \(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
 // MARK: - Dynamic Island
 
 
 private struct DynamicIslandLeadingView: View {
 private struct DynamicIslandLeadingView: View {
@@ -435,11 +515,14 @@ private struct DynamicIslandBottomView: View {
                 .lineLimit(1)
                 .lineLimit(1)
                 .minimumScaleFactor(0.75)
                 .minimumScaleFactor(0.75)
         } else {
         } 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 ?? "—"
         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 {
     static func profileName(_ s: GlucoseSnapshot) -> String {
         s.profileName ?? "—"
         s.profileName ?? "—"
     }
     }

+ 28 - 1
Shared/GlucoseSnapshot.swift

@@ -52,6 +52,17 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
     /// Active override name (nil if no active override)
     /// Active override name (nil if no active override)
     let override: String?
     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)
     /// Recommended bolus in units (nil if not available)
     let recBolus: Double?
     let recBolus: Double?
 
 
@@ -134,6 +145,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
         cob: Double?,
         cob: Double?,
         projected: Double?,
         projected: Double?,
         override: String? = nil,
         override: String? = nil,
+        overrideEndAt: TimeInterval? = nil,
+        tempTargetMgdl: Double? = nil,
+        tempTargetEndAt: TimeInterval? = nil,
         recBolus: Double? = nil,
         recBolus: Double? = nil,
         battery: Double? = nil,
         battery: Double? = nil,
         pumpBattery: Double? = nil,
         pumpBattery: Double? = nil,
@@ -164,6 +178,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
         self.cob = cob
         self.cob = cob
         self.projected = projected
         self.projected = projected
         self.override = override
         self.override = override
+        self.overrideEndAt = overrideEndAt
+        self.tempTargetMgdl = tempTargetMgdl
+        self.tempTargetEndAt = tempTargetEndAt
         self.recBolus = recBolus
         self.recBolus = recBolus
         self.battery = battery
         self.battery = battery
         self.pumpBattery = pumpBattery
         self.pumpBattery = pumpBattery
@@ -207,6 +224,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
             cob: cob,
             cob: cob,
             projected: projected,
             projected: projected,
             override: override,
             override: override,
+            overrideEndAt: overrideEndAt,
+            tempTargetMgdl: tempTargetMgdl,
+            tempTargetEndAt: tempTargetEndAt,
             recBolus: recBolus,
             recBolus: recBolus,
             battery: battery,
             battery: battery,
             pumpBattery: pumpBattery,
             pumpBattery: pumpBattery,
@@ -243,6 +263,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
         try container.encodeIfPresent(cob, forKey: .cob)
         try container.encodeIfPresent(cob, forKey: .cob)
         try container.encodeIfPresent(projected, forKey: .projected)
         try container.encodeIfPresent(projected, forKey: .projected)
         try container.encodeIfPresent(override, forKey: .override)
         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(recBolus, forKey: .recBolus)
         try container.encodeIfPresent(battery, forKey: .battery)
         try container.encodeIfPresent(battery, forKey: .battery)
         try container.encodeIfPresent(pumpBattery, forKey: .pumpBattery)
         try container.encodeIfPresent(pumpBattery, forKey: .pumpBattery)
@@ -276,6 +299,9 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
         cob = try container.decodeIfPresent(Double.self, forKey: .cob)
         cob = try container.decodeIfPresent(Double.self, forKey: .cob)
         projected = try container.decodeIfPresent(Double.self, forKey: .projected)
         projected = try container.decodeIfPresent(Double.self, forKey: .projected)
         override = try container.decodeIfPresent(String.self, forKey: .override)
         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)
         recBolus = try container.decodeIfPresent(Double.self, forKey: .recBolus)
         battery = try container.decodeIfPresent(Double.self, forKey: .battery)
         battery = try container.decodeIfPresent(Double.self, forKey: .battery)
         pumpBattery = try container.decodeIfPresent(Double.self, forKey: .pumpBattery)
         pumpBattery = try container.decodeIfPresent(Double.self, forKey: .pumpBattery)
@@ -302,7 +328,8 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable {
     private enum CodingKeys: String, CodingKey {
     private enum CodingKeys: String, CodingKey {
         case glucose, delta, trend, updatedAt
         case glucose, delta, trend, updatedAt
         case iob, cob, projected
         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 autosens, tdd, targetLowMgdl, targetHighMgdl, isfMgdlPerU, carbRatio, carbsToday
         case profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl, maxBgMgdl
         case profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl, maxBgMgdl
         case unit, isNotLooping, showRenewalOverlay
         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
     /// True when the underlying value may be nil (e.g. Dexcom-only users who have
     /// no Loop data). The widget renders "—" in those cases.
     /// no Loop data). The widget renders "—" in those cases.
     var isOptional: Bool {
     var isOptional: Bool {
@@ -188,7 +196,11 @@ enum LAAppGroupSettings {
         guard let raw = defaults?.stringArray(forKey: Keys.slots), raw.count == 4 else {
         guard let raw = defaults?.stringArray(forKey: Keys.slots), raw.count == 4 else {
             return LiveActivitySlotDefaults.all
             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 }
         return raw.map { LiveActivitySlotOption(rawValue: $0) ?? .none }
+            .map { $0 == .override ? .none : $0 }
     }
     }
 
 
     // MARK: - Small widget slot (Write)
     // MARK: - Small widget slot (Write)