Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions LoopFollow/Controllers/NightScout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ extension MainViewController {
overrideGraphData.removeAll()
Observable.shared.override.value = nil
Observable.shared.overrideEndAt.value = nil
Observable.shared.overridePercent.value = nil
Observable.shared.overrideRangeLowMgdl.value = nil
Observable.shared.overrideRangeHighMgdl.value = nil
updateOverrideGraph()
}

Expand Down
15 changes: 15 additions & 0 deletions LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ extension MainViewController {
overrideGraphData.removeAll()
var activeOverrideNote: String?
var activeOverrideEndAt: TimeInterval?
var activeOverridePercent: Int?
var activeOverrideRange: (low: Double, high: Double)?

let sorted = entries.sorted { lhs, rhs in
guard
Expand Down Expand Up @@ -94,11 +96,16 @@ extension MainViewController {
if now >= start, now < end {
activeOverrideNote = e["notes"] as? String ?? e["reason"] as? String
activeOverrideEndAt = trueEnd
activeOverridePercent = WatchStateBuilder.overridePercent(dot)
activeOverrideRange = Self.overrideRangeMgdl(dot.correctionRange)
}
}

Observable.shared.override.value = activeOverrideNote
Observable.shared.overrideEndAt.value = activeOverrideEndAt
Observable.shared.overridePercent.value = activeOverridePercent
Observable.shared.overrideRangeLowMgdl.value = activeOverrideRange?.low
Observable.shared.overrideRangeHighMgdl.value = activeOverrideRange?.high
if Storage.shared.device.value != "Loop" {
if let note = activeOverrideNote {
infoManager.updateInfoData(type: .override, value: note)
Expand All @@ -114,4 +121,12 @@ extension MainViewController {
LiveActivityManager.shared.refreshFromCurrentState(reason: "overrideChanged")
#endif
}

/// An override's target range in mg/dL, or nil when it sets none (stored as
/// [0, 0]). Values this small can only be mmol/L, so they are converted.
nonisolated static func overrideRangeMgdl(_ range: [Int]) -> (low: Double, high: Double)? {
guard range.count == 2, range[0] > 0, range[1] > 0 else { return nil }
let mgdl = range.map { $0 < 30 ? Double($0) * GlucoseConversion.mmolToMgDl : Double($0) }
return (min(mgdl[0], mgdl[1]), max(mgdl[0], mgdl[1]))
}
}
4 changes: 4 additions & 0 deletions LoopFollow/LiveActivity/APNSClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ class APNSClient {
"aps": [
"timestamp": Int(Date().timeIntervalSince1970),
"event": "update",
"stale-date": Int(state.snapshot.staleDate().timeIntervalSince1970),
"content-state": contentState,
],
]
Expand All @@ -248,6 +249,9 @@ class APNSClient {
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 overridePercent = snapshot.overridePercent { snapshotDict["overridePercent"] = overridePercent }
if let overrideRangeLowMgdl = snapshot.overrideRangeLowMgdl { snapshotDict["overrideRangeLowMgdl"] = overrideRangeLowMgdl }
if let overrideRangeHighMgdl = snapshot.overrideRangeHighMgdl { snapshotDict["overrideRangeHighMgdl"] = overrideRangeHighMgdl }
if let tempTargetMgdl = snapshot.tempTargetMgdl { snapshotDict["tempTargetMgdl"] = tempTargetMgdl }
if let tempTargetEndAt = snapshot.tempTargetEndAt { snapshotDict["tempTargetEndAt"] = tempTargetEndAt }
if let recBolus = snapshot.recBolus { snapshotDict["recBolus"] = recBolus }
Expand Down
10 changes: 10 additions & 0 deletions LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ protocol CurrentGlucoseStateProviding {
/// End of the active override as Unix epoch seconds (nil = no override or indefinite).
var overrideEndAt: TimeInterval? { get }

/// Insulin needs of the active override as a percentage (nil = none or 100 %).
var overridePercent: Int? { get }

/// Target range of the active override in mg/dL (nil = none or not set).
var overrideRangeLowMgdl: Double? { get }
var overrideRangeHighMgdl: Double? { get }

/// Active temporary target in mg/dL (nil if no active temp target).
var tempTargetMgdl: Double? { get }

Expand Down Expand Up @@ -154,6 +161,9 @@ enum GlucoseSnapshotBuilder {
projected: provider.projectedMgdl,
override: provider.override,
overrideEndAt: provider.overrideEndAt,
overridePercent: provider.overridePercent,
overrideRangeLowMgdl: provider.overrideRangeLowMgdl,
overrideRangeHighMgdl: provider.overrideRangeHighMgdl,
tempTargetMgdl: provider.tempTargetMgdl,
tempTargetEndAt: provider.tempTargetEndAt,
recBolus: provider.recBolus,
Expand Down
48 changes: 32 additions & 16 deletions LoopFollow/LiveActivity/LiveActivityManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ final class LiveActivityManager {
)
let content = ActivityContent(
state: state,
staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value),
staleDate: snapshot.staleDate(),
relevanceScore: 100.0,
)

Expand Down Expand Up @@ -376,11 +376,11 @@ final class LiveActivityManager {
}

let now = Date().timeIntervalSince1970
let staleDatePassed = activity.content.staleDate.map { $0 <= Date() } ?? false
if staleDatePassed {
let renewBy = Storage.shared.laRenewBy.value
if renewBy > 0, now >= renewBy {
LogManager.shared.log(
category: .general,
message: "[LA] liveness check: staleDate already passed"
message: "[LA] liveness check: renewal deadline already passed"
)
return true
}
Expand Down Expand Up @@ -414,6 +414,15 @@ final class LiveActivityManager {
static let opportunisticRenewalThreshold: TimeInterval = 4 * 3600
static let extensionLivenessGrace: TimeInterval = 15 * 60

/// Whether a running activity must be replaced instead of reused. Only the
/// renewal clock decides. The activity's stale date does not: it says the last
/// reading has aged, which a new activity would not fix. An activity with no
/// stored deadline is replaced because `renewIfNeeded` could never renew it,
/// leaving iOS to end it at 8 hours.
static func existingActivityNeedsReplacing(renewBy: TimeInterval, renewalFailed: Bool, now: TimeInterval) -> Bool {
renewalFailed || renewBy <= 0 || now >= renewBy - renewalWarning
}

/// Base backoff after a 429 for push-to-start; doubled on each subsequent 429,
/// capped at `pushToStartMaxBackoff`. Reset to base after a successful send.
private static let pushToStartBaseBackoff: TimeInterval = 300 // 5 min
Expand Down Expand Up @@ -502,15 +511,14 @@ final class LiveActivityManager {
let startReason = nextStartReasonOverride ?? "user-start"
nextStartReasonOverride = nil

// If an activity is already running and not stale we adopt/reuse it
// If an activity is already running and not due for renewal we adopt/reuse it
// (covers warm starts where the LA survived a relaunch); only truly new
// starts go through createActivity (local when foreground, APNs otherwise).
if let existing = Activity<GlucoseLiveActivityAttributes>.activities.first {
let renewBy = Storage.shared.laRenewBy.value
let now = Date().timeIntervalSince1970
let staleDatePassed = existing.content.staleDate.map { $0 <= Date() } ?? false
let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning
let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed
let renewalFailed = Storage.shared.laRenewalFailed.value
let needsRestart = LiveActivityManager.existingActivityNeedsReplacing(renewBy: renewBy, renewalFailed: renewalFailed, now: now)
if !needsRestart {
// Opportunistic renewal: the LA is healthy but in the back half
// of its 8-hour lifetime and the app is open — replace it locally
Expand All @@ -530,7 +538,7 @@ final class LiveActivityManager {
}
LogManager.shared.log(
category: .general,
message: "[LA] existing activity is stale on startIfNeeded — replacing (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))"
message: "[LA] existing activity needs renewal on startIfNeeded — replacing (renewalFailed=\(renewalFailed), renewBy=\(renewBy), now=\(now))"
)
createActivity(reason: startReason, oldActivity: existing)
return
Expand Down Expand Up @@ -753,10 +761,10 @@ final class LiveActivityManager {
reason: reason,
producedAt: Date(),
)
let staleDate = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
let renewBy = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
let content = ActivityContent(
state: state,
staleDate: staleDate,
staleDate: workingSnapshot.staleDate(),
relevanceScore: 100.0,
)

Expand All @@ -770,7 +778,7 @@ final class LiveActivityManager {
category: .general,
message: "[LA] local create (\(reason)) succeeded id=\(activity.id) seq=\(nextSeq)"
)
Storage.shared.laRenewBy.value = staleDate.timeIntervalSince1970
Storage.shared.laRenewBy.value = renewBy.timeIntervalSince1970
Storage.shared.laRenewalFailed.value = false
cancelRenewalFailedNotification()
pendingForegroundRestart = false
Expand Down Expand Up @@ -930,7 +938,7 @@ final class LiveActivityManager {
reason: reason,
producedAt: Date(),
)
let staleDate = Date().addingTimeInterval(LiveActivityManager.renewalThreshold)
let staleDate = freshSnapshot.staleDate()

let tail = String(token.suffix(8))
LogManager.shared.log(
Expand Down Expand Up @@ -1084,7 +1092,7 @@ final class LiveActivityManager {
return
}
if current == nil,
let existing = Activity<GlucoseLiveActivityAttributes>.activities.first(where: { $0.activityState == .active })
let existing = firstUpdatableActivity
{
// Skip activities already in .ended/.dismissed — those are corpses
// (typically post-410 ends pending iOS dismissal). Binding to them
Expand All @@ -1108,14 +1116,22 @@ final class LiveActivityManager {
}
}

/// The first activity that can still take updates. `.stale` counts: it only
/// means the last reading has aged, and the next update clears it.
private var firstUpdatableActivity: Activity<GlucoseLiveActivityAttributes>? {
Activity<GlucoseLiveActivityAttributes>.activities.first {
$0.activityState == .active || $0.activityState == .stale
}
}

private func isAppVisibleForLiveActivityStart() -> Bool {
let scenes = UIApplication.shared.connectedScenes
return scenes.contains { $0.activationState == .foregroundActive }
}

func update(snapshot: GlucoseSnapshot, reason: String) {
if current == nil,
let existing = Activity<GlucoseLiveActivityAttributes>.activities.first(where: { $0.activityState == .active })
let existing = firstUpdatableActivity
{
bind(to: existing, logReason: "bind-existing")
}
Expand Down Expand Up @@ -1145,7 +1161,7 @@ final class LiveActivityManager {

let content = ActivityContent(
state: state,
staleDate: Date(timeIntervalSince1970: Storage.shared.laRenewBy.value),
staleDate: snapshot.staleDate(),
relevanceScore: 100.0,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding {
Observable.shared.overrideEndAt.value
}

var overridePercent: Int? {
Observable.shared.overridePercent.value
}

var overrideRangeLowMgdl: Double? {
Observable.shared.overrideRangeLowMgdl.value
}

var overrideRangeHighMgdl: Double? {
Observable.shared.overrideRangeHighMgdl.value
}

var tempTargetMgdl: Double? {
Observable.shared.tempTarget.value?.doubleValue(for: .milligramsPerDeciliter)
}
Expand Down
63 changes: 61 additions & 2 deletions LoopFollow/LiveActivitySettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
@State private var restartConfirmed = false
@State private var slots: [LiveActivitySlotOption] = LAAppGroupSettings.slots()
@State private var smallWidgetSlot: LiveActivitySlotOption = LAAppGroupSettings.smallWidgetSlot()
@State private var showSmallWidgetStatusLine: Bool = LAAppGroupSettings.showSmallWidgetStatusLine()
@State private var smallWidgetTightLayout: Bool = LAAppGroupSettings.smallWidgetTightLayout()
@State private var keyId: String = Storage.shared.lfKeyId.value
@State private var apnsKey: String = Storage.shared.lfApnsKey.value

Expand Down Expand Up @@ -67,18 +69,41 @@
}

Section(header: Text("Grid Slot - CarPlay / Watch")) {
let rightSlotOptions = LiveActivitySlotOption.smallWidgetCases(showStatusLine: showSmallWidgetStatusLine)
Picker("Right slot", selection: Binding(
get: { smallWidgetSlot },
get: { rightSlotOptions.contains(smallWidgetSlot) ? smallWidgetSlot : .none },
set: { newValue in
smallWidgetSlot = newValue
LAAppGroupSettings.setSmallWidgetSlot(newValue)
LiveActivityManager.shared.refreshFromCurrentState(reason: "small widget slot changed")
}
)) {
ForEach(LiveActivitySlotOption.allCases, id: \.self) { option in
ForEach(rightSlotOptions, id: \.self) { option in
Text(option.displayName).tag(option)
}
}

Toggle(isOn: Binding(
get: { showSmallWidgetStatusLine },
set: { newValue in
showSmallWidgetStatusLine = newValue
LAAppGroupSettings.setShowSmallWidgetStatusLine(newValue)
LiveActivityManager.shared.refreshFromCurrentState(reason: "small widget status line changed")
}
)) {
InfoLabel(title: "Status Line", info: Self.statusLineInfo)
}

Toggle(isOn: Binding(
get: { smallWidgetTightLayout },
set: { newValue in
smallWidgetTightLayout = newValue
LAAppGroupSettings.setSmallWidgetTightLayout(newValue)
LiveActivityManager.shared.refreshFromCurrentState(reason: "small widget tight layout changed")
}
)) {
InfoLabel(title: "Tight CarPlay Layout", info: Self.tightLayoutInfo)
}
}
}
.onReceive(Storage.shared.laEnabled.$value) { newValue in
Expand All @@ -103,6 +128,10 @@
.navigationBarTitleDisplayMode(.inline)
}

private static let statusLineInfo = "Shows a line under the reading with the update time, IOB and COB, or Not Looping when Loop stops reporting, then any active override with its insulin needs, range and time left. While it is on, IOB, COB and Override are not offered for the right slot. Changes appear with the next Live Activity update."

private static let tightLayoutInfo = "Replaces the right slot with projected BG, IOB, COB and basal stacked on the right, and reduces the status line to the time. It only applies to wide CarPlay screens; the Watch and narrow CarPlay screens keep the regular layout. Changes appear with the next Live Activity update."

/// Selects an option for the given slot index, enforcing uniqueness:
/// if the chosen option is already in another slot, that slot is cleared to `.none`.
private func selectSlot(_ option: LiveActivitySlotOption, at index: Int) {
Expand All @@ -116,4 +145,34 @@
LiveActivityManager.shared.refreshFromCurrentState(reason: "slot config changed")
}
}

/// A setting's title followed by an info button that explains it in a popover.
private struct InfoLabel: View {
let title: String
let info: String
@State private var showInfo = false

var body: some View {
HStack(spacing: 6) {
Text(title)
Button {
showInfo = true
} label: {
Image(systemName: "info.circle")
.foregroundStyle(Color.accentColor)
}
// Borderless keeps the tap on the icon instead of the whole row.
.buttonStyle(.borderless)
.accessibilityLabel("About \(title)")
.popover(isPresented: $showInfo) {
Text(info)
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
.frame(width: 280, alignment: .leading)
.padding()
.presentationCompactAdaptation(.popover)
}
}
}
}
#endif
2 changes: 1 addition & 1 deletion LoopFollow/Settings/SettingsMenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ enum SettingsRoute: Hashable, Identifiable {
case .liveActivity: return [
SettingsLeaf("Enable Live Activity"),
SettingsLeaf("Restart Live Activity"),
SettingsLeaf("Grid Slots", ["carplay", "watch"]),
SettingsLeaf("Grid Slots", ["carplay", "watch", "status line", "tight layout"]),
]
#endif
case .remote: return [
Expand Down
5 changes: 5 additions & 0 deletions LoopFollow/Storage/Observable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ class Observable {

/// End of the active override as Unix epoch seconds; nil = no override or indefinite.
var overrideEndAt = ObservableValue<TimeInterval?>(default: nil)
/// Insulin needs of the active override as a percentage; nil = none or 100 %.
var overridePercent = ObservableValue<Int?>(default: nil)
/// Target range of the active override in mg/dL; nil = none or not set.
var overrideRangeLowMgdl = ObservableValue<Double?>(default: nil)
var overrideRangeHighMgdl = ObservableValue<Double?>(default: nil)
/// End of the active temp target as Unix epoch seconds; nil = no active temp target.
var tempTargetEndAt = ObservableValue<TimeInterval?>(default: nil)

Expand Down
4 changes: 2 additions & 2 deletions LoopFollow/Watch/WatchStateBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,8 @@ enum WatchStateBuilder {
}

/// Insulin needs of an override, from its Nightscout entry or else from the preset of the same name,
/// since Trio does not upload the factor with the override.
private static func overridePercent(_ override: DataStructs.overrideStruct) -> Int? {
/// since Trio does not upload the factor with the override. Also used for the Live Activity.
static func overridePercent(_ override: DataStructs.overrideStruct) -> Int? {
var factor = override.insulNeedsScaleFactor
if factor == 1 {
if let loop = ProfileManager.shared.loopOverrides.first(where: { $0.name == override.reason }) {
Expand Down
Loading