From 73aaf02a7d8ca91044953d40a6032d5275ef350a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 29 Jul 2026 02:28:03 +0200 Subject: [PATCH 01/25] Fix Advanced Settings graph toggles not hiding chart elements (#729) The old chart only drew what the gated update calls pushed into it, but the Swift Charts model reads the data arrays directly, so Graph Basal, Graph Bolus, Graph Carbs and Graph Other Treatments were never consulted. Skip the corresponding data during rebuild when a toggle is off, and mark the chart dirty when a toggle changes so it redraws on return to the home screen. Restore two settings-change behaviors dropped in the migration: changing the prediction style re-routes the stored predBGs again, and toggling Show Yesterday's BG reloads the BG window so the overlay updates right away instead of at the next scheduled fetch. --- LoopFollow/Charts/BGChartModel.swift | 32 ++++++++++++------- LoopFollow/Charts/BGChartStubs.swift | 9 ++++++ .../Settings/AdvancedSettingsViewModel.swift | 4 +++ 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index a08bab90f..f4f697a2f 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -408,6 +408,14 @@ final class BGChartModel: ObservableObject { showMidnight = Storage.shared.showMidnightLines.value smallGraphTreatments = Storage.shared.smallGraphTreatments.value + // Advanced-settings visibility toggles. The Nightscout controllers + // collect the data regardless (it also feeds the info rows), so hidden + // kinds are dropped here at render time. + let showBasal = Storage.shared.graphBasal.value + let showBolus = Storage.shared.graphBolus.value + let showCarbs = Storage.shared.graphCarbs.value + let showOtherTreatments = Storage.shared.graphOtherTreatments.value + let isLoop = Storage.shared.device.value == "Loop" overrideColor = isLoop ? .green : .purple tempTargetColor = isLoop ? .purple : .green @@ -436,7 +444,7 @@ final class BGChartModel: ObservableObject { cobPrediction = vc.cobPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) } uamPrediction = vc.uamPredictionData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), color: .purple) } - let bolusPoints = vc.bolusData.map { + let bolusPoints = (showBolus ? vc.bolusData : []).map { let dose = self.formatDose($0.value) return TreatmentPoint( date: Date(timeIntervalSince1970: $0.date), @@ -446,7 +454,7 @@ final class BGChartModel: ObservableObject { pillText: "Bolus\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" ) } - carbs = Self.spread(vc.carbData.map { + carbs = Self.spread((showCarbs ? vc.carbData : []).map { let grams = Int($0.value) var label = "\(grams)" if $0.absorptionTime > 0, Storage.shared.showAbsorption.value { @@ -460,7 +468,7 @@ final class BGChartModel: ObservableObject { pillText: "Carbs\n\(grams)g\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" ) }, minGap: Spread.carbGap, maxShift: Spread.carbShift) - let smbPoints = vc.smbData.map { + let smbPoints = (showBolus ? vc.smbData : []).map { let dose = self.formatDose($0.value) return TreatmentPoint( date: Date(timeIntervalSince1970: $0.date), @@ -471,7 +479,7 @@ final class BGChartModel: ObservableObject { ) } (boluses, smbs) = Self.spreadTogether(bolusPoints, smbPoints, minGap: Spread.bolusGap, maxShift: Spread.bolusShift) - bgChecks = vc.bgCheckData.map { + bgChecks = (showOtherTreatments ? vc.bgCheckData : []).map { TreatmentPoint( date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), @@ -480,16 +488,16 @@ final class BGChartModel: ObservableObject { pillText: "BG Check\n\(Localizer.toDisplayUnits(String($0.sgv)))\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" ) } - suspends = vc.suspendGraphData.map { + suspends = (showOtherTreatments ? vc.suspendGraphData : []).map { TreatmentPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), sgv: Double($0.sgv), label: "", pillText: "Suspend\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))") } - resumes = vc.resumeGraphData.map { + resumes = (showOtherTreatments ? vc.resumeGraphData : []).map { TreatmentPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), sgv: Double($0.sgv), label: "", pillText: "Resume\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))") } - sensorStarts = vc.sensorStartGraphData.map { + sensorStarts = (showOtherTreatments ? vc.sensorStartGraphData : []).map { TreatmentPoint(date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), sgv: Double($0.sgv), label: "", pillText: "Sensor Start\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))") } - notes = vc.noteGraphData.map { + notes = (showOtherTreatments ? vc.noteGraphData : []).map { TreatmentPoint( date: Date(timeIntervalSince1970: $0.date), value: Double($0.sgv), @@ -499,12 +507,12 @@ final class BGChartModel: ObservableObject { ) } - basalScheduled = vc.basalScheduleData.map { + basalScheduled = (showBasal ? vc.basalScheduleData : []).map { ScheduledBasalPoint(date: Date(timeIntervalSince1970: $0.date), rate: $0.basalRate) } var steps: [BasalStep] = [] - let sortedBasal = vc.basalData.sorted { $0.date < $1.date } + let sortedBasal = (showBasal ? vc.basalData : []).sorted { $0.date < $1.date } for i in 0 ..< sortedBasal.count { let start = sortedBasal[i].date let end = i + 1 < sortedBasal.count @@ -523,7 +531,7 @@ final class BGChartModel: ObservableObject { let yTop = maxBG - 5 let yBottom = maxBG - 25 - overrides = vc.overrideGraphData.map { + overrides = (showOtherTreatments ? vc.overrideGraphData : []).map { let overrideName = $0.reason.trimmingCharacters(in: .whitespacesAndNewlines) let displayName = overrideName.isEmpty ? "Override" : overrideName return BandRect( @@ -535,7 +543,7 @@ final class BGChartModel: ObservableObject { pillText: "Override\n\(displayName)\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" ) } - tempTargets = vc.tempTargetGraphData.map { + tempTargets = (showOtherTreatments ? vc.tempTargetGraphData : []).map { let target = $0.correctionRange.first.map { String($0) } ?? "" // Temp targets render at the BG level they target (±5 mg/dL); // only overrides live in the top strip. diff --git a/LoopFollow/Charts/BGChartStubs.swift b/LoopFollow/Charts/BGChartStubs.swift index ba8714a82..700821a66 100644 --- a/LoopFollow/Charts/BGChartStubs.swift +++ b/LoopFollow/Charts/BGChartStubs.swift @@ -22,6 +22,15 @@ extension MainViewController { func updateBGGraphSettings() { chartModel.rebuild() + + // Re-route the stored predBGs in case the prediction style + // (cone/lines) changed; rebuild() alone only redraws what was + // already routed. + updateOpenAPSPredictionDisplay() + + // The yesterday overlay is built during the BG fetch and needs an + // extra day of history, so reload the BG window when it's toggled. + TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: Date()) } private func recomputeTopBG() { diff --git a/LoopFollow/Settings/AdvancedSettingsViewModel.swift b/LoopFollow/Settings/AdvancedSettingsViewModel.swift index 307d91e69..078e62288 100644 --- a/LoopFollow/Settings/AdvancedSettingsViewModel.swift +++ b/LoopFollow/Settings/AdvancedSettingsViewModel.swift @@ -19,24 +19,28 @@ class AdvancedSettingsViewModel: ObservableObject { @Published var graphBasal: Bool { didSet { Storage.shared.graphBasal.value = graphBasal + Observable.shared.chartSettingsChanged.value = true } } @Published var graphBolus: Bool { didSet { Storage.shared.graphBolus.value = graphBolus + Observable.shared.chartSettingsChanged.value = true } } @Published var graphCarbs: Bool { didSet { Storage.shared.graphCarbs.value = graphCarbs + Observable.shared.chartSettingsChanged.value = true } } @Published var graphOtherTreatments: Bool { didSet { Storage.shared.graphOtherTreatments.value = graphOtherTreatments + Observable.shared.chartSettingsChanged.value = true } } From d9f7b2a58e88b4ee47a7f0a8bc16468a93d5835c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Jul 2026 00:28:19 +0000 Subject: [PATCH 02/25] CI: Bump dev version to 7.0.1 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 275e32170..99ea773c2 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.0.0 +LOOP_FOLLOW_MARKETING_VERSION = 7.0.1 From 639f8c6b691f01b0db731114d3caeb94e4faa66d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 30 Jul 2026 01:10:41 +0200 Subject: [PATCH 03/25] Fix main graph locking up after system-cancelled gestures (#733) When the system cancels a touch (permission alert, incoming call, app switch) the chart gestures never get their onEnded call, so pinch or inspect state stays latched and blocks all panning and zooming until the app is restarted. Clear leaked gesture state when a new touch begins, reset it when the chart disappears, and rebuild the gesture attachments on foregrounding while keeping the current viewport. --- LoopFollow/Charts/BGChartModel.swift | 4 ++ LoopFollow/Charts/BGChartView.swift | 77 +++++++++++++++++++++++----- 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index f4f697a2f..9b451ac6a 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -17,6 +17,10 @@ final class BGChartInteraction: ObservableObject { /// when the user pans back into history, re-armed when they return to the edge. @Published var followLatest: Bool = true + /// Set once the main chart has done its initial scroll-to-now; kept out of + /// view @State so a chart remount (see BGChartView) keeps the user's place. + var hasInitializedViewport = false + init() { let seconds = Self.visibleSeconds(forScale: Storage.shared.chartScaleX.value) visibleSeconds = seconds diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index e7aa43b3c..9098bd123 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -74,11 +74,22 @@ struct BGChartView: View { let model: BGChartModel let config: Config + /// Remount key. A system-cancelled touch can wedge SwiftUI's gesture graph + /// for this subtree; bumping this on foregrounding rebuilds the gesture + /// attachments while BGChartInteraction preserves the viewport. + @State private var gestureMountEpoch = 0 + var body: some View { - if config == .small { - SmallBGChart(model: model, interaction: model.interaction) - } else { - MainBGChart(model: model, interaction: model.interaction) + Group { + if config == .small { + SmallBGChart(model: model, interaction: model.interaction) + } else { + MainBGChart(model: model, interaction: model.interaction) + } + } + .id(gestureMountEpoch) + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + gestureMountEpoch &+= 1 } } } @@ -102,13 +113,21 @@ private struct MainBGChart: View { @ObservedObject var model: BGChartModel @ObservedObject var interaction: BGChartInteraction - @State private var didInitialize = false - /// Rendered slice of the domain. The canvas covers only this window /// (visible ± `renderWindowPadFactor` viewports), bounding canvas width /// and per-layout cost no matter how long the data domain grows. - @State private var renderWindowStart = Date() - @State private var renderWindowEnd = Date().addingTimeInterval(3600) + @State private var renderWindowStart: Date + @State private var renderWindowEnd: Date + + init(model: BGChartModel, interaction: BGChartInteraction) { + _model = ObservedObject(wrappedValue: model) + _interaction = ObservedObject(wrappedValue: interaction) + // Seed the render window around the current viewport so a remount's + // first frame draws in place. + let pad = BGChartConfig.renderWindowPadFactor * interaction.visibleSeconds + _renderWindowStart = State(initialValue: interaction.scrollPosition.addingTimeInterval(-pad)) + _renderWindowEnd = State(initialValue: interaction.scrollPosition.addingTimeInterval(interaction.visibleSeconds + pad)) + } /// Plot area of the static axis overlay, in shell coordinates. The /// selection overlay uses it for its value-to-pixel maps. @@ -265,15 +284,19 @@ private struct MainBGChart: View { updateRenderWindow() } .onAppear { - if !didInitialize { - didInitialize = true + if !interaction.hasInitializedViewport { + interaction.hasInitializedViewport = true scrollToNow(animated: false) - updateRenderWindow(force: true) + } else if !interaction.followLatest { + // Remounted while in history: re-arm the auto-return pause. + autoFollowPausedUntil = Date().addingTimeInterval(BGChartConfig.autoFollowPause) } + updateRenderWindow(force: true) } .onDisappear { momentumTask?.cancel() - inspectHoldTask?.cancel() + momentumTask = nil + resetGestureState() } } @@ -394,6 +417,13 @@ private struct MainBGChart: View { .onChanged { value in momentumTask?.cancel() momentumTask = nil + // A touch's first event has zero translation and precedes any + // pinch, so gesture state still set here was leaked by a + // system-cancelled touch (its onEnded never fired) and would + // swallow this and every later touch. + if value.translation == .zero, hasLeakedGestureState { + resetGestureState() + } guard !isPinching else { inspectHoldTask?.cancel() if selection != nil { selection = nil } @@ -454,6 +484,24 @@ private struct MainBGChart: View { } } + private var hasLeakedGestureState: Bool { + pinchAnchor != nil || pinchScale != 1 || isInspectLatched + || touchDownTime != nil || panBaseline != nil || selection != nil + } + + private func resetGestureState() { + inspectHoldTask?.cancel() + inspectHoldTask = nil + pinchAnchor = nil + pinchScale = 1 + isInspectLatched = false + touchDownTime = nil + panBaseline = nil + selection = nil + lastTouchLocation = nil + lastHapticAnchorDate = nil + } + /// Arms the inspect hold: after `inspectHoldDelay`, if the touch is still /// down and has neither become a pan nor a pinch, latch into inspect mode /// at the finger's last known position — with a haptic so the mode change is felt. @@ -936,6 +984,11 @@ private struct SmallBGChart: View { .gesture( DragGesture(minimumDistance: 0) .onChanged { value in + // Zero translation = fresh touch; clear a scrub flag + // leaked by a system-cancelled touch. + if value.translation == .zero { + isScrubbing = false + } let distance = hypot(value.translation.width, value.translation.height) if isScrubbing || distance >= BGChartConfig.inspectMovementTolerance { isScrubbing = true From d9f5974e628792a88310ec4b585091c1879dfc6a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 29 Jul 2026 23:10:52 +0000 Subject: [PATCH 04/25] CI: Bump dev version to 7.0.2 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 99ea773c2..8a85a5f15 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.0.1 +LOOP_FOLLOW_MARKETING_VERSION = 7.0.2 From 193029a13506b802ffdedc5abfc8719c2b9b243e Mon Sep 17 00:00:00 2001 From: Daniel Mini Johansson <42831533+codebymini@users.noreply.github.com> Date: Sun, 16 Aug 2026 05:30:53 +0200 Subject: [PATCH 05/25] Refactor OverrideActivationModal to simplify duration handling (#740) * Refactor OverrideActivationModal to simplify duration handling and make duration settings available for all overrides * Fix duration handling in OverrideActivationModal to use zero for indefinite activation --- .../Remote/LoopAPNS/LoopAPNSService.swift | 2 +- .../Remote/LoopAPNS/OverridePresetsView.swift | 89 ++++++++----------- 2 files changed, 40 insertions(+), 51 deletions(-) diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index 2a13dd081..38dc4fb23 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -682,7 +682,7 @@ class LoopAPNSService { "alert": alertText, ] - if let duration = duration, duration > 0 { + if let duration = duration { payload["override-duration-minutes"] = Int(duration / 60) } diff --git a/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift index 4d29aa6f6..6ed39e5bc 100644 --- a/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift +++ b/LoopFollow/Remote/LoopAPNS/OverridePresetsView.swift @@ -215,10 +215,11 @@ struct OverrideActivationModal: View { // Initialize state based on preset duration if preset.duration == 0 { - // Indefinite override - allow user to choose + // Indefinite override defaults to indefinite. _enableIndefinitely = State(initialValue: true) + _durationHours = State(initialValue: 1.0) } else { - // Override with predefined duration - use preset duration + // Predefined-duration override defaults to the preset duration, but remains editable. _enableIndefinitely = State(initialValue: false) _durationHours = State(initialValue: preset.duration / 3600) } @@ -251,69 +252,57 @@ struct OverrideActivationModal: View { .foregroundColor(.secondary) } - // Only show duration for overrides with predefined duration - if preset.duration != 0 { - Text("Duration: \(preset.durationDescription)") - .font(.subheadline) - .foregroundColor(.secondary) - } + Text("Preset: \(preset.durationDescription)") + .font(.subheadline) + .foregroundColor(.secondary) } .padding(.top) Spacer() - // Duration Settings (only show for overrides without predefined duration) - if preset.duration == 0 { - VStack(spacing: 16) { - // Duration Input (only show when not indefinite) - if !enableIndefinitely { - VStack(spacing: 8) { - HStack { - Text("Duration") - .font(.headline) - Spacer() - Text(formatDuration(durationHours)) - .font(.headline) - .foregroundColor(.blue) - } - - Slider(value: $durationHours, in: 0.25 ... 24.0, step: 0.25) - .accentColor(.blue) - HStack { - Text("15m") - .font(.caption) - .foregroundColor(.secondary) - .frame(width: 80, alignment: .leading) - Spacer() - Text("24h") - .font(.caption) - .foregroundColor(.secondary) - .frame(width: 80, alignment: .trailing) - } + // Duration Settings (available for all overrides) + VStack(spacing: 16) { + // Duration Input (only show when not indefinite) + if !enableIndefinitely { + VStack(spacing: 8) { + HStack { + Text("Duration") + .font(.headline) + Spacer() + Text(formatDuration(durationHours)) + .font(.headline) + .foregroundColor(.blue) } - .padding(.horizontal) - } - // Indefinitely Toggle - HStack { - Toggle("Enable indefinitely", isOn: $enableIndefinitely) - Spacer() + Slider(value: $durationHours, in: 0.25 ... 24.0, step: 0.25) + .accentColor(.blue) + HStack { + Text("15m") + .font(.caption) + .foregroundColor(.secondary) + .frame(width: 80, alignment: .leading) + Spacer() + Text("24h") + .font(.caption) + .foregroundColor(.secondary) + .frame(width: 80, alignment: .trailing) + } } .padding(.horizontal) } + + // Indefinitely Toggle + HStack { + Toggle("Enable indefinitely", isOn: $enableIndefinitely) + Spacer() + } + .padding(.horizontal) } // Action Buttons VStack(spacing: 12) { Button(action: { - let duration: TimeInterval? - if preset.duration == 0 { - // For indefinite overrides, use user selection - duration = enableIndefinitely ? nil : (durationHours * 3600) - } else { - // For overrides with predefined duration, use preset duration - duration = preset.duration - } + let duration: TimeInterval? = enableIndefinitely ? 0 : (durationHours * 3600) onActivate(duration) }) { Text("Activate Override") From 835689ad48d7a8d2f64811ccd45a2bdc63ca649d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 16 Aug 2026 03:31:04 +0000 Subject: [PATCH 06/25] CI: Bump dev version to 7.0.3 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 8a85a5f15..1b39d70b9 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.0.2 +LOOP_FOLLOW_MARKETING_VERSION = 7.0.3 From 89c22fff1381f1683f57467b3a3a901a65f6f1cb Mon Sep 17 00:00:00 2001 From: Auggie <659845+aug0211@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:11:03 -0400 Subject: [PATCH 07/25] Fix missing Trio FPU entries (#741) LoopFollow was deduplicating Nightscout treatments by Trio's shared FPU ID, which hid the first scheduled entry from the graph. Deduplicate by event type and occurrence time as well, preserving distinct FPU entries while still collapsing true duplicates. --- .../Controllers/Nightscout/Treatments.swift | 40 ++++++++++-- ...ightscoutTreatmentDeduplicationTests.swift | 64 +++++++++++++++++++ 2 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 Tests/NightscoutTreatmentDeduplicationTests.swift diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index b628737b4..14e9e391b 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -4,6 +4,39 @@ import Foundation extension MainViewController { + private struct TreatmentOccurrence: Hashable { + let id: String + let eventType: String? + let date: Date? + } + + /// Nightscout duplicates can have different MongoDB `_id` values. Trio FPU siblings share an `id` but have distinct + /// times, so deduplicate by logical occurrence rather than `id`. + static func deduplicatedTreatmentEntries(_ entries: [[String: AnyObject]]) -> [[String: AnyObject]] { + var seenOccurrences = Set() + return entries.filter { entry in + guard let id = entry["id"] as? String, !id.isEmpty else { return true } + let eventType = entry["eventType"] as? String + let occurrence = TreatmentOccurrence( + id: id, + eventType: eventType, + date: treatmentOccurrenceDate(entry, eventType: eventType) + ) + return seenOccurrences.insert(occurrence).inserted + } + } + + private static func treatmentOccurrenceDate(_ entry: [String: AnyObject], eventType: String?) -> Date? { + let rawDate: String? + switch eventType { + case "Pump Site Change", "Site Change", "Sensor Start", "Insulin Change": + rawDate = entry["created_at"] as? String + default: + rawDate = (entry["timestamp"] as? String) ?? (entry["created_at"] as? String) + } + return rawDate.flatMap(NightscoutUtils.parseDate) + } + // NS Treatments Web Call // Downloads Basal, Bolus, Carbs, BG Check, Notes, Overrides func WebLoadNSTreatments() { @@ -35,12 +68,7 @@ extension MainViewController { // Process and split out treatments to individual tasks func updateTreatments(entries: [[String: AnyObject]]) { - // Deduplicate entries by "id" field (Trio/Loop UUID) - var seenIDs = Set() - let uniqueEntries = entries.filter { entry in - guard let id = entry["id"] as? String else { return true } - return seenIDs.insert(id).inserted - } + let uniqueEntries = Self.deduplicatedTreatmentEntries(entries) var tempBasal: [[String: AnyObject]] = [] var bolus: [[String: AnyObject]] = [] diff --git a/Tests/NightscoutTreatmentDeduplicationTests.swift b/Tests/NightscoutTreatmentDeduplicationTests.swift new file mode 100644 index 000000000..4c7696bda --- /dev/null +++ b/Tests/NightscoutTreatmentDeduplicationTests.swift @@ -0,0 +1,64 @@ +// LoopFollow +// NightscoutTreatmentDeduplicationTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct NightscoutTreatmentDeduplicationTests { + private typealias Entry = [String: AnyObject] + + @Test("keeps Trio FPU siblings with one id and distinct times") + func keepsFPUOccurrences() { + let entries = [ + entry("newer", createdAt: "2026-08-16T02:06:00Z"), + entry("older", createdAt: "2026-08-16T01:06:00Z"), + ] + #expect(deduplicatedIDs(entries) == ["newer", "older"]) + } + + @Test("collapses duplicate Nightscout documents and keeps the first") + func collapsesDuplicates() { + let entries = [ + entry("first", createdAt: "2026-08-16T01:06:00Z"), + entry("duplicate", createdAt: "2026-08-16T01:06:00Z"), + ] + #expect(deduplicatedIDs(entries) == ["first"]) + } + + @Test("uses normalized effective time and event type") + func usesLogicalOccurrence() { + let first = entry("first", timestamp: "2026-08-16T01:06:00Z", createdAt: "2026-08-16T01:05:58Z") + let equivalent = entry("equivalent", timestamp: "2026-08-16T01:06:00.000Z", createdAt: "2026-08-16T01:06:02Z") + let createdAtOnly = entry("created-at", timestamp: nil, createdAt: "2026-08-16T01:06:00Z") + let bolus = entry("bolus", eventType: "Correction Bolus", timestamp: "2026-08-16T01:06:00Z") + #expect(deduplicatedIDs([first, equivalent, createdAtOnly, bolus]) == ["first", "bolus"]) + } + + @Test("preserves missing identifiers and fallback behavior") + func preservesFallbacks() { + let noID = entry("no-id", id: nil) + let blankID = entry("blank-id", id: "") + let noTime = [entry("no-time"), entry("no-time-duplicate")] + let sensor = entry("sensor", eventType: "Sensor Start", timestamp: "2026-08-16T02:00:00Z", createdAt: "2026-08-16T01:00:00Z") + let sensorDuplicate = entry("sensor-duplicate", eventType: "Sensor Start", timestamp: "2026-08-16T03:00:00Z", createdAt: "2026-08-16T01:00:00Z") + #expect(deduplicatedIDs([noID, noID, blankID, blankID]) == ["no-id", "no-id", "blank-id", "blank-id"]) + #expect(deduplicatedIDs(noTime) == ["no-time"]) + #expect(deduplicatedIDs([sensor, sensorDuplicate]) == ["sensor"]) + } + + private func deduplicatedIDs(_ entries: [Entry]) -> [String] { + MainViewController.deduplicatedTreatmentEntries(entries).compactMap { $0["_id"] as? String } + } + + private func entry(_ mongoID: String, id: String? = "shared-id", eventType: String = "Carb Correction", timestamp: String? = nil, createdAt: String? = nil) -> Entry { + var result: Entry = [ + "_id": mongoID as AnyObject, + "eventType": eventType as AnyObject, + ] + if let id { result["id"] = id as AnyObject } + if let timestamp { result["timestamp"] = timestamp as AnyObject } + if let createdAt { result["created_at"] = createdAt as AnyObject } + return result + } +} From a2ea3084d2feb8753761e967d2ab699681717b13 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 26 Aug 2026 13:11:15 +0000 Subject: [PATCH 08/25] CI: Bump dev version to 7.0.4 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 1b39d70b9..6b255a7d8 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.0.3 +LOOP_FOLLOW_MARKETING_VERSION = 7.0.4 From bc1a973699d88a37b957dfa93e8fed911ca46841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Wed, 26 Aug 2026 22:58:53 +0200 Subject: [PATCH 09/25] Recover the Silent Tune keep-alive after audio interruptions (#745) * Recover the Silent Tune keep-alive after audio interruptions The silent audio session is the only background execution claim Silent Tune has, so when another app interrupts it the process is suspended within seconds and no app code runs again until iOS resumes it. Reactivation now happens inside a UIApplication background task assertion, which grants runtime independently of the audio claim, so the retry attempts actually run instead of being frozen by suspension. Attempts are spread over about 18 seconds, which fits inside both the assertion and a BGAppRefreshTask window, and playAudio confirms the player is really playing rather than assuming play() worked. The BGAppRefreshTask stays open until the restart resolves, so a failed first attempt is retried within the same window. It also queues its successor before doing any work, asks for the earliest possible window when a restart fails, and cancels itself when the user is no longer on Silent Tune. scheduleRefresh leaves a pending request alone when it would run at least as soon, so repeated background transitions no longer push the check further out. Logging follows the same shape: quiet when the keep-alive is healthy, and detailed once it is struggling. A first attempt success stays behind debug logging, while recovery after a failure reports the attempts and elapsed time, repeated failures with an unchanged error code are suppressed so a long ladder cannot bury the log, and session errors are named instead of being printed as raw four character codes. The task scheduler records one line per lost runtime window with its length and which background alerts fired, and the background and foreground transitions record the refresh mode, Low Power Mode and Background App Refresh status. * Recover on interruption without trusting player.isPlaying Device logs show isPlaying still reporting true at the moment an interruption is delivered, so the recovery entered on interruption began was skipped before it took its background task assertion, leaving the app dependent on iOS delivering interruption ended after all. Recovery no longer consults isPlaying to decide whether to run. Reattempting against a player that is genuinely playing is harmless, because the session is activated before the player is replaced, so a failed attempt leaves a working player untouched. Recovery on interruption began now waits a second before its first attempt. A brief interrupter's interruption ended lands inside that window and supersedes the work, so momentary blips stay as quiet as before, and work that does run means the claim is really gone whatever isPlaying says. The immediate background refresh request moved to that point as well, so it is made once per real interruption instead of once per blip, and the assertion expiring now arms it unconditionally since reaching expiration means the claim was never re-established. * Recover from route changes and background relaunches Silent audio also stops when the audio route disappears and when media services reset, and neither posts an interruption notification, so nothing noticed. A day of device logs shows three silent deaths with zero interruptions while the phone moved in and out of CarPlay. Route changes and media services resets are now observed and recover through the same ladder. Recovery runs for a route appearing or disappearing; a category change never recovers, because playAudio sets the category itself and an alarm takes over the session that way. Other reasons are logged so a later log can earn them a recovery. A process launched into the background by BGAppRefreshTask never runs the backgrounding transition, so it had no interruption, route or media services observers and no background alerts armed. Observers are now attached whenever audio is restarted, and a background recovery arms the alerts, which also clears any delivered notification the recovery has just made obsolete. The task scheduler is kicked on recovery so the alerts are re-armed from the moment runtime returns. Alerts are only armed while backgrounded, since the task's work lands on the main queue and the app may have been opened in between. The runtime gap is measured against a monotonic clock, so a wall clock correction cannot hide a stall, and a material difference between the two is reported. A scheduler park that outlives the moment between a task firing and its action rescheduling it is now reported with its duration. * Tighten comments in the background keep-alive --- .../Controllers/BackgroundAlertManager.swift | 14 +- .../Helpers/BackgroundRefreshManager.swift | 159 ++++++- LoopFollow/Helpers/BackgroundTaskAudio.swift | 399 ++++++++++++++++-- LoopFollow/Task/TaskScheduler.swift | 80 ++++ .../ViewControllers/MainViewController.swift | 16 + 5 files changed, 605 insertions(+), 63 deletions(-) diff --git a/LoopFollow/Controllers/BackgroundAlertManager.swift b/LoopFollow/Controllers/BackgroundAlertManager.swift index 8b844c983..b4cd22680 100644 --- a/LoopFollow/Controllers/BackgroundAlertManager.swift +++ b/LoopFollow/Controllers/BackgroundAlertManager.swift @@ -64,14 +64,14 @@ class BackgroundAlertManager { func scheduleBackgroundAlert(force: Bool = false) { guard isAlertScheduled, Storage.shared.backgroundRefreshType.value != .none else { return } - // Throttle execution if not forced: only run once every 10 seconds. - if !force { - let now = Date() - if let lastDate = lastScheduleDate, now.timeIntervalSince(lastDate) < 10 { - return - } - lastScheduleDate = now + // Throttle execution if not forced: only run once every 10 seconds. A forced + // run stamps the date too, so the next tick doesn't immediately repeat the + // remove-and-re-add it just performed. + let now = Date() + if !force, let lastDate = lastScheduleDate, now.timeIntervalSince(lastDate) < 10 { + return } + lastScheduleDate = now removeDeliveredNotifications() diff --git a/LoopFollow/Helpers/BackgroundRefreshManager.swift b/LoopFollow/Helpers/BackgroundRefreshManager.swift index ab2b42e67..b1a6427b6 100644 --- a/LoopFollow/Helpers/BackgroundRefreshManager.swift +++ b/LoopFollow/Helpers/BackgroundRefreshManager.swift @@ -3,6 +3,7 @@ import BackgroundTasks import Foundation +import UIKit class BackgroundRefreshManager { static let shared = BackgroundRefreshManager() @@ -10,6 +11,18 @@ class BackgroundRefreshManager { private let taskIdentifier = "\(Bundle.main.bundleIdentifier ?? "com.loopfollow").audiorefresh" + /// Spacing for the routine health check. iOS treats this as a floor and + /// schedules on its own budget, so the effective interval is longer. + private let refreshInterval: TimeInterval = 15 * 60 + + /// Serialises the read-modify-write around the pending request, so a routine + /// request can't land on top of an immediate one. + private let queue = DispatchQueue(label: "com.LoopFollow.BackgroundRefreshQueue") + + /// True while the pending request asks for the earliest window iOS will give. + /// Guarded by `queue`. + private var immediateRequested = false + func register() { BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in guard let refreshTask = task as? BGAppRefreshTask else { return } @@ -20,47 +33,147 @@ class BackgroundRefreshManager { private func handleRefreshTask(_ task: BGAppRefreshTask) { LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask fired") - // Guard against double setTaskCompleted if expiration fires while the - // main-queue block is in-flight (Apple documents this as a programming error). + // Guard against double setTaskCompleted (Apple documents this as a programming + // error). The restart below keeps the task open for seconds, so expiration and + // the main-queue block genuinely race for the flag and it needs a lock. + let lock = NSLock() var completed = false + let claim: () -> Bool = { + lock.lock() + defer { lock.unlock() } + guard !completed else { return false } + completed = true + return true + } + let complete: (Bool) -> Void = { success in + guard claim() else { return } + task.setTaskCompleted(success: success) + } task.expirationHandler = { - guard !completed else { return } - completed = true LogManager.shared.log(category: .taskScheduler, message: "BGAppRefreshTask expired") - task.setTaskCompleted(success: false) - self.scheduleRefresh() + complete(false) + } + + // This task exists only to revive the Silent Tune keep-alive. Reading the mode + // is safe before storage is confirmed readable: the default is `.silentTune`, + // so an unhydrated read keeps the check armed rather than cancelling it. + guard !StorageReadiness.ready.value || Storage.shared.backgroundRefreshType.value == .silentTune else { + LogManager.shared.log(category: .taskScheduler, message: "Background refresh no longer needed for the current mode; cancelling") + BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: taskIdentifier) + queue.async { self.immediateRequested = false } + complete(true) + return + } + + // Queue the successor before doing any work, so an early expiration or a + // crash still leaves a pending request behind. + queue.async { + self.immediateRequested = false + self.submit(earliestBeginDate: Date(timeIntervalSinceNow: self.refreshInterval)) } DispatchQueue.main.async { - guard !completed else { return } - completed = true - if let mainVC = self.getMainViewController() { - if !mainVC.backgroundTask.player.isPlaying { - LogManager.shared.log(category: .taskScheduler, message: "audio dead, attempting restart") - mainVC.backgroundTask.stopBackgroundTask() - mainVC.backgroundTask.startBackgroundTask() - LogManager.shared.log(category: .taskScheduler, message: "audio restart initiated") - } else { - LogManager.shared.log(category: .taskScheduler, message: "audio alive, no action needed", isDebug: true) + guard let backgroundTask = MainViewController.shared?.backgroundTask else { + LogManager.shared.log(category: .taskScheduler, message: "No main view controller yet; nothing to check") + complete(true) + return + } + guard !backgroundTask.isPlaying else { + // Full level: `.taskScheduler` debug lines are dropped before the file + // write, and a healthy check should leave a trace of its own. + LogManager.shared.log(category: .taskScheduler, message: "audio alive, no action needed") + self.armBackgroundAlerts() + TaskScheduler.shared.checkTasksNow() + complete(true) + return + } + + LogManager.shared.log(category: .taskScheduler, message: "audio dead, attempting restart") + // The task must stay open until the restart resolves: completing it here + // lets iOS suspend the app, and a pending retry would then not run until + // something else resumes the process — minutes or hours later. + backgroundTask.restartAudio(reason: "BGAppRefreshTask") { success in + LogManager.shared.log( + category: .taskScheduler, + message: success ? "audio restart succeeded" : "audio restart failed" + ) + // Only on success: a failed restart means suspension is imminent, and + // dispatching fetches that cannot finish helps nothing. + if success { + self.armBackgroundAlerts() + TaskScheduler.shared.checkTasksNow() } + complete(success) } - self.scheduleRefresh() - task.setTaskCompleted(success: true) } } + /// Clears any delivered "App inactive" notification and re-arms the 6/12/18 minute + /// alerts from this moment. A process launched into the background never ran + /// `appMovedToBackground`, so this is the only place its alerts are armed. + private func armBackgroundAlerts() { + // The task fires while backgrounded, but its work lands on the main queue and + // the user may have opened the app in between. Alerts belong only to a + // backgrounded app. + guard UIApplication.shared.applicationState == .background else { return } + BackgroundAlertManager.shared.startBackgroundAlert() + } + + /// Requests the routine health check, leaving an existing pending request alone + /// when it would run at least as soon. Every background transition calls this, so + /// the earliest pending request is the one that survives. func scheduleRefresh() { + let desired = Date(timeIntervalSinceNow: refreshInterval) + BGTaskScheduler.shared.getPendingTaskRequests { [weak self] pending in + guard let self else { return } + self.queue.async { + // Category `.general`: LogManager drops `.taskScheduler` debug lines + // before the file write, and these belong in a shared log. + guard !self.immediateRequested else { + LogManager.shared.log(category: .general, message: "Keeping the pending immediate refresh request", isDebug: true) + return + } + if let existing = pending.first(where: { $0.identifier == self.taskIdentifier }) { + guard let existingDate = existing.earliestBeginDate else { return } + guard existingDate > desired else { + LogManager.shared.log(category: .general, message: "Refresh already pending at \(existingDate); leaving it", isDebug: true) + return + } + } + self.submit(earliestBeginDate: desired) + } + } + } + + /// Requests the earliest window iOS is willing to give, used when the audio + /// keep-alive has been lost and a background refresh is the only route back to + /// running code. + func scheduleImmediateRefresh() { + queue.async { + // The flag tracks what is actually pending. A submit that throws — as it + // does when Background App Refresh is switched off — must not leave the + // routine check suppressed behind a request that was never accepted. + self.immediateRequested = self.submit(earliestBeginDate: nil) + LogManager.shared.log( + category: .taskScheduler, + message: self.immediateRequested + ? "Requested the earliest possible background refresh" + : "Could not request a background refresh; no recovery window is pending" + ) + } + } + + @discardableResult + private func submit(earliestBeginDate: Date?) -> Bool { let request = BGAppRefreshTaskRequest(identifier: taskIdentifier) - request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60) + request.earliestBeginDate = earliestBeginDate do { try BGTaskScheduler.shared.submit(request) + return true } catch { LogManager.shared.log(category: .taskScheduler, message: "Failed to schedule BGAppRefreshTask: \(error)") + return false } } - - private func getMainViewController() -> MainViewController? { - MainViewController.shared - } } diff --git a/LoopFollow/Helpers/BackgroundTaskAudio.swift b/LoopFollow/Helpers/BackgroundTaskAudio.swift index 25aa6b3c8..cfbe5c0e1 100755 --- a/LoopFollow/Helpers/BackgroundTaskAudio.swift +++ b/LoopFollow/Helpers/BackgroundTaskAudio.swift @@ -2,30 +2,175 @@ // BackgroundTaskAudio.swift import AVFoundation +import UIKit +/// Keeps the app running in the background by looping a silent audio file. +/// +/// The audio session is the only background-execution claim Silent Tune has, so +/// losing it means the process is suspended within seconds and no app code — +/// including any retry timer — runs again until iOS resumes the app. Every +/// reactivation attempt therefore runs inside a `UIApplication` background-task +/// assertion, which grants runtime independently of the audio claim, and the +/// attempts are bounded to stay inside that assertion's budget. class BackgroundTask { // MARK: - Vars var player = AVAudioPlayer() - private var retryCount = 0 - private let maxRetries = 3 + /// True while the silent loop actually holds the background-audio claim. + var isPlaying: Bool { player.isPlaying } + + /// Attempts spread over `retryInterval`, sized to fit a background-task + /// assertion (~30s) and a `BGAppRefreshTask` window with room to spare. + private let maxAttempts = 10 + private let retryInterval: TimeInterval = 2.0 + + /// Delay before the first attempt after an interruption ends, letting the + /// interrupting app (e.g. Clock alarm) fully release the audio session. + /// Without it `setActive(true)` races with the alarm and fails with + /// `AVAudioSession.ErrorCode.cannotInterruptOthers` (560557684). + private let postInterruptionDelay: TimeInterval = 0.5 + + /// Window after an interruption begins in which a matching `.ended` supersedes + /// the recovery. Longer than `postInterruptionDelay` so a blip's own restart + /// lands first; short enough that a real claim loss is addressed promptly. + private let interruptionSettleDelay: TimeInterval = 1.0 + + private var recoveryWorkItem: DispatchWorkItem? + private var assertionID: UIBackgroundTaskIdentifier = .invalid + + /// Callers waiting on the outcome. A caller holding a `BGAppRefreshTask` open + /// must always hear back so it can complete the task, so a sequence that + /// supersedes another inherits its waiters. + private var pendingCompletions: [(Bool) -> Void] = [] + + /// Per-sequence diagnostics: how long recovery has been running, how many + /// attempts it took, and the last session error. A first-attempt success stays + /// quiet; anything slower reports what it cost. + private var sequenceStart: Date? + private var attemptsMade = 0 + private var lastFailureCode: Int? + + /// Set when a sequence runs out of attempts, so the eventual recovery is reported + /// at full level however it arrives. + private var lastSequenceGaveUp = false + + /// True while the active sequence was started by an interruption beginning. + /// Exhausting the attempts there is expected for any interrupter that outlasts + /// the assertion (a phone call), and iOS still commonly heals it by delivering + /// `.ended`, so that case must not be announced as a failed keep-alive. + private var startedByInterruption = false // MARK: - Methods func startBackgroundTask() { - NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) + attachObservers() + onMain { self.recover(after: 0, reason: "start") } + } + + /// Idempotent. A process launched into the background by `BGAppRefreshTask` never + /// sees a backgrounding transition, so the keep-alive attaches these wherever it + /// starts. + private func attachObservers() { + removeObservers() NotificationCenter.default.addObserver(self, selector: #selector(interruptedAudio), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance()) - retryCount = 0 - playAudio() + // A route disappearing pauses the player without any interruption notification, + // and a media services reset invalidates the session and player outright — + // neither is observable through `interruptionNotification`. + NotificationCenter.default.addObserver(self, selector: #selector(audioRouteChanged), name: AVAudioSession.routeChangeNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(mediaServicesWereReset), name: AVAudioSession.mediaServicesWereResetNotification, object: nil) } func stopBackgroundTask() { + removeObservers() + onMain { + self.cancelRecovery() + self.player.stop() + // Reached only from the foreground transition: with the app open, the + // next backgrounding is a clean start. + self.lastSequenceGaveUp = false + LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) + } + } + + /// Reactivates the silent loop, retrying until it plays or the attempt budget + /// is spent, and reports the outcome. Runtime is held by a background-task + /// assertion for the whole sequence, so the retries survive the loss of the + /// audio claim that made them necessary. + /// - Parameter completion: Called on the main queue with the final state. + func restartAudio(reason: String, completion: ((Bool) -> Void)? = nil) { + attachObservers() + onMain { + self.player.stop() + self.recover(after: 0, reason: reason, completion: completion) + } + } + + private func removeObservers() { NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) - player.stop() - LogManager.shared.log(category: .general, message: "Silent audio stopped", isDebug: true) + NotificationCenter.default.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: AVAudioSession.mediaServicesWereResetNotification, object: nil) + } + + // MARK: - Route and media services handling + + @objc private func audioRouteChanged(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, + let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) + else { return } + + let previous = userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription + let route = "reason=\(describe(reason)) from=\(portTypes(previous)) to=\(portTypes(AVAudioSession.sharedInstance().currentRoute))" + + switch reason { + case .oldDeviceUnavailable, .newDeviceAvailable: + LogManager.shared.log(category: .general, message: "[LA] Audio route changed, restarting silent audio: \(route)") + // CarPlay and Bluetooth transitions emit a burst of route changes; each + // supersedes the last, so the ladder runs once against the settled route. + onMain { self.recover(after: self.interruptionSettleDelay, reason: "route change") } + + case .categoryChange: + // `playAudio` sets the category itself, and an alarm takes over the + // session the same way. Both make this reason unsafe to act on. + LogManager.shared.log(category: .general, message: "[LA] Audio route changed, ignoring: \(route)", isDebug: true) + + default: + // Recorded for diagnosis without acting. + LogManager.shared.log(category: .general, message: "[LA] Audio route changed, no action: \(route)") + } } + @objc private func mediaServicesWereReset(_: Notification) { + LogManager.shared.log(category: .general, message: "[LA] Media services were reset — session and player are invalid, rebuilding") + // `playAudio` reconfigures the category, reactivates, and creates a fresh + // player, which is the recovery Apple prescribes for a reset. + onMain { self.recover(after: self.interruptionSettleDelay, reason: "media services reset") } + } + + /// Port types only — `portName` carries the user's accessory name, which must not + /// reach a shared log. + private func portTypes(_ route: AVAudioSessionRouteDescription?) -> String { + guard let route, !route.outputs.isEmpty else { return "none" } + return route.outputs.map { $0.portType.rawValue }.joined(separator: "+") + } + + private func describe(_ reason: AVAudioSession.RouteChangeReason) -> String { + switch reason { + case .newDeviceAvailable: "newDeviceAvailable" + case .oldDeviceUnavailable: "oldDeviceUnavailable" + case .categoryChange: "categoryChange" + case .override: "override" + case .wakeFromSleep: "wakeFromSleep" + case .noSuitableRouteForCategory: "noSuitableRouteForCategory" + case .routeConfigurationChange: "routeConfigurationChange" + case .unknown: "unknown" + @unknown default: "other" + } + } + + // MARK: - Interruption handling + @objc private func interruptedAudio(_ notification: Notification) { guard notification.name == AVAudioSession.interruptionNotification, let userInfo = notification.userInfo, @@ -35,7 +180,19 @@ class BackgroundTask { switch type { case .began: - LogManager.shared.log(category: .general, message: "[LA] Silent audio session interrupted (began)") + let reason = (userInfo[AVAudioSessionInterruptionReasonKey] as? UInt) + .flatMap { AVAudioSession.InterruptionReason(rawValue: $0) } + LogManager.shared.log( + category: .general, + message: "[LA] Silent audio session interrupted (began), reason=\(describe(reason)), otherAudioPlaying=\(AVAudioSession.sharedInstance().isOtherAudioPlaying)" + ) + // iOS delivers `.ended` only if the app is still running, and the lost + // audio claim means suspension is imminent, so recovery cannot wait for + // it. The delay is a supersede window: a brief interrupter's `.ended` + // arrives well inside it and cancels this work, so momentary blips stay + // quiet. Work that does run is therefore a reliable signal that the + // claim is really gone, whatever `player.isPlaying` reports. + onMain { self.recover(after: self.interruptionSettleDelay, reason: "interruption began", startedByInterruption: true) } case .ended: if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { @@ -44,47 +201,223 @@ class BackgroundTask { LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — shouldResume not set, attempting restart anyway") } } - LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — scheduling restart in 0.5s") - retryCount = 0 - // Brief delay to let the interrupting app (e.g. Clock alarm) fully release the audio - // session before we attempt to reactivate. Without this, setActive(true) races with - // the alarm and fails with AVAudioSession.ErrorCode.cannotInterruptOthers (560557684). - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.playAudio() - } + LogManager.shared.log(category: .general, message: "[LA] Silent audio interruption ended — scheduling restart in \(postInterruptionDelay)s") + onMain { self.recover(after: self.postInterruptionDelay, reason: "interruption ended") } @unknown default: break } } - private func playAudio() { - let attemptDesc = retryCount == 0 ? "initial attempt" : "retry \(retryCount)/\(maxRetries)" + private func describe(_ reason: AVAudioSession.InterruptionReason?) -> String { + switch reason { + case .default: "default" + case .builtInMicMuted: "builtInMicMuted" + case .none: "unknown" + @unknown default: "other" + } + } + + // MARK: - Recovery + + /// Runs one bounded recovery sequence, superseding any sequence already in flight. + private func recover(after delay: TimeInterval, reason: String, startedByInterruption: Bool = false, completion: ((Bool) -> Void)? = nil) { + // Waiters from the in-flight sequence inherit this sequence's outcome. + recoveryWorkItem?.cancel() + recoveryWorkItem = nil + if let completion { + pendingCompletions.append(completion) + } + self.startedByInterruption = startedByInterruption + if sequenceStart == nil { + sequenceStart = Date() + attemptsMade = 0 + lastFailureCode = nil + } + + // `player.isPlaying` reports true for a while after the session is taken, so + // recovery runs unconditionally. Reattempting against a playing player is + // harmless: `playAudio` activates the session before touching `player`, leaving + // a working one untouched when an attempt fails. + // + // The assertion is taken before the delay so the first attempt is covered too. + beginAssertion() + + guard delay > 0 else { + attempt(1, of: reason) + return + } + + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.recoveryWorkItem = nil + self.attempt(1, of: reason) + } + recoveryWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + } + + private func attempt(_ number: Int, of reason: String) { + attemptsMade = number + if startedByInterruption, number == 1 { + // Reached only when the settle window elapsed without an `.ended`, so the + // interrupter holds the session and the app may be suspended before the + // ladder finishes. + BackgroundRefreshManager.shared.scheduleImmediateRefresh() + } + if playAudio(attempt: number, reason: reason) { + finishRecovery(success: true) + return + } + + guard number < maxAttempts else { + LogManager.shared.log( + category: .general, + message: "Silent audio recovery gave up after \(number) attempts over \(elapsedDescription()) (\(reason)), last error: \(Self.describeSessionError(lastFailureCode ?? 0))" + ) + lastSequenceGaveUp = true + if !startedByInterruption { + NotificationCenter.default.post(name: .backgroundAudioFailed, object: nil) + } + // The attempts are spent and there is no audio claim left, so a background + // refresh is the only remaining route back to running code. + BackgroundRefreshManager.shared.scheduleImmediateRefresh() + finishRecovery(success: false) + return + } + + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + self.recoveryWorkItem = nil + self.attempt(number + 1, of: reason) + } + recoveryWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + retryInterval, execute: work) + } + + /// - Returns: True when the silent loop is confirmed playing. + private func playAudio(attempt: Int, reason: String) -> Bool { do { - let bundle = Bundle.main.path(forResource: "blank", ofType: "wav") - let alertSound = URL(fileURLWithPath: bundle!) + guard let path = Bundle.main.path(forResource: "blank", ofType: "wav") else { + LogManager.shared.log(category: .general, message: "playAudio failed: blank.wav missing from bundle") + return false + } try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, options: .mixWithOthers) try AVAudioSession.sharedInstance().setActive(true) - try player = AVAudioPlayer(contentsOf: alertSound) + player = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path)) // Play audio forever by setting num of loops to -1 player.numberOfLoops = -1 player.volume = 0.01 player.prepareToPlay() player.play() - retryCount = 0 - LogManager.shared.log(category: .general, message: "Silent audio playing (\(attemptDesc))", isDebug: true) - } catch { - LogManager.shared.log(category: .general, message: "playAudio failed (\(attemptDesc)), error: \(error)") - if retryCount < maxRetries { - retryCount += 1 - LogManager.shared.log(category: .general, message: "playAudio scheduling retry \(retryCount)/\(maxRetries) in 2s") - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in - self?.playAudio() - } + guard player.isPlaying else { + LogManager.shared.log(category: .general, message: "playAudio: play() did not start the player (attempt \(attempt)/\(maxAttempts), \(reason))") + return false + } + if attempt > 1 || lastFailureCode != nil || lastSequenceGaveUp { + // A recovery following a logged failure reports itself, so the log + // always says whether the failure resolved. `lastFailureCode` survives + // a supersede, so the elapsed figure spans the whole window. + LogManager.shared.log(category: .general, message: "Silent audio playing again after \(attempt) attempt(s) over \(elapsedDescription()) (\(reason))") } else { - LogManager.shared.log(category: .general, message: "playAudio failed after \(maxRetries) retries — posting BackgroundAudioFailed") - NotificationCenter.default.post(name: .backgroundAudioFailed, object: nil) + LogManager.shared.log(category: .general, message: "Silent audio playing (\(reason))", isDebug: true) } + lastSequenceGaveUp = false + return true + } catch { + let code = (error as NSError).code + // Every attempt against the same holder reports the same code; log the + // first and any change, so a 10-attempt ladder can't bury the log. + let isNewFailure = code != lastFailureCode + lastFailureCode = code + LogManager.shared.log( + category: .general, + message: "playAudio failed (attempt \(attempt)/\(maxAttempts), \(reason)), code \(code) \(Self.describeSessionError(code)): \(error.localizedDescription)", + isDebug: !isNewFailure + ) + return false + } + } + + private func elapsedDescription() -> String { + guard let start = sequenceStart else { return "unknown" } + return String(format: "%.1fs", Date().timeIntervalSince(start)) + } + + private func finishRecovery(success: Bool) { + recoveryWorkItem = nil + let completions = pendingCompletions + pendingCompletions = [] + startedByInterruption = false + sequenceStart = nil + attemptsMade = 0 + lastFailureCode = nil + endAssertion() + for completion in completions { + completion(success) + } + } + + private func cancelRecovery() { + recoveryWorkItem?.cancel() + finishRecovery(success: player.isPlaying) + } + + // MARK: - Runtime assertion + + /// Holds runtime while the audio claim is gone, so queued retries actually run. + private func beginAssertion() { + guard assertionID == .invalid else { return } + // UIKit invokes the expiration handler on the main thread, which is the only + // queue that touches the recovery state. + assertionID = UIApplication.shared.beginBackgroundTask(withName: "SilentAudioRecovery") { [weak self] in + guard let self else { return } + LogManager.shared.log( + category: .general, + message: "Silent audio recovery assertion expired after \(self.attemptsMade) attempts over \(self.elapsedDescription()); the app is about to be suspended without an audio claim" + ) + self.lastSequenceGaveUp = true + // A success ends the assertion, so reaching expiration means the claim was + // never re-established — arm the net without consulting `player.isPlaying`. + BackgroundRefreshManager.shared.scheduleImmediateRefresh() + self.cancelRecovery() + } + } + + private func endAssertion() { + guard assertionID != .invalid else { return } + UIApplication.shared.endBackgroundTask(assertionID) + assertionID = .invalid + } + + // MARK: - Helpers + + private func onMain(_ work: @escaping () -> Void) { + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.async(execute: work) + } + } + + /// `AVAudioSession.ErrorCode` values are four-character codes; the raw number + /// alone is unreadable in a shared log. + static func describeSessionError(_ code: Int) -> String { + switch AVAudioSession.ErrorCode(rawValue: code) { + case .cannotInterruptOthers: "cannotInterruptOthers" + case .siriIsRecording: "siriIsRecording" + case .cannotStartPlaying: "cannotStartPlaying" + case .cannotStartRecording: "cannotStartRecording" + case .insufficientPriority: "insufficientPriority" + case .resourceNotAvailable: "resourceNotAvailable" + case .mediaServicesFailed: "mediaServicesFailed" + case .isBusy: "isBusy" + case .incompatibleCategory: "incompatibleCategory" + case .expiredSession: "expiredSession" + case .sessionNotActive: "sessionNotActive" + case .badParam: "badParam" + case .none: "unspecified" + default: "other" } } } diff --git a/LoopFollow/Task/TaskScheduler.swift b/LoopFollow/Task/TaskScheduler.swift index b76ac3022..95a2d49ff 100644 --- a/LoopFollow/Task/TaskScheduler.swift +++ b/LoopFollow/Task/TaskScheduler.swift @@ -28,6 +28,24 @@ class TaskScheduler { private var tasks: [TaskID: ScheduledTask] = [:] private var currentTimer: DispatchSourceTimer? + /// When tasks last fired. `minAgoUpdate` reschedules itself at most 60s out, so + /// with runtime this advances at least once a minute; a larger jump means the + /// process was suspended and is the window the background alerts fire in. + private var lastFireDate: Date? + + /// Counterpart to `lastFireDate` that includes time asleep and cannot be moved by + /// a clock correction. The difference between the two measures a clock step. + private var lastFireUptime: UInt64? + + /// Above normal tick jitter, below the 6-minute first background alert. + private let runtimeGapThreshold: TimeInterval = 120 + + /// Queue-confined park tracking. A normal park clears within milliseconds, so a + /// survivor at this age is wedged or was suspended mid-park. + private var parkedSince: Date? + private var parkedReporter: DispatchWorkItem? + private let parkedReportDelay: TimeInterval = 5 + private init() {} // MARK: - Public API @@ -72,6 +90,12 @@ class TaskScheduler { return } + if earliestTask.nextRun == .distantFuture { + noteTimerParked() + } else { + clearTimerParked() + } + let interval = earliestTask.nextRun.timeIntervalSinceNow let safeInterval = max(interval, 0) @@ -90,6 +114,7 @@ class TaskScheduler { BackgroundAlertManager.shared.scheduleBackgroundAlert() let now = Date() + noteRuntimeGap(at: now) for taskID in TaskID.allCases { guard let task = tasks[taskID], task.nextRun <= now else { @@ -108,6 +133,61 @@ class TaskScheduler { } } + /// `fireOverdueTasks` parks a task at `.distantFuture` and its action reschedules + /// it asynchronously, so every task being parked at once is normal for the + /// milliseconds in between. A park outliving that leaves nothing to wake the timer, + /// so it is reported by duration and the routine case stays silent. + private func noteTimerParked() { + guard parkedSince == nil else { return } + let since = Date() + parkedSince = since + let work = DispatchWorkItem { [weak self] in + guard let self, self.parkedSince == since else { return } + LogManager.shared.log( + category: .taskScheduler, + message: "Timer still parked after \(Int(Date().timeIntervalSince(since)))s: every task is awaiting its action to reschedule it" + ) + } + parkedReporter = work + queue.asyncAfter(deadline: .now() + parkedReportDelay, execute: work) + } + + private func clearTimerParked() { + parkedReporter?.cancel() + parkedReporter = nil + parkedSince = nil + } + + /// Records one line per lost-runtime window, giving the length of a background + /// stall directly. + private func noteRuntimeGap(at now: Date) { + // CLOCK_MONOTONIC keeps counting while the device sleeps, so it measures a + // suspension. + let uptime = clock_gettime_nsec_np(CLOCK_MONOTONIC) + defer { + lastFireDate = now + lastFireUptime = uptime + } + guard let last = lastFireDate, let lastUptime = lastFireUptime else { return } + // Boot time is authoritative: a wall-clock correction must not hide a stall. + let gap = Double(uptime &- lastUptime) / 1_000_000_000 + let wallGap = now.timeIntervalSince(last) + guard gap >= runtimeGapThreshold else { return } + // Silent Tune is the only mode whose invariant is continuous runtime, which is + // what this measures. `.none` is meant to be suspended and the Bluetooth modes + // tick at heartbeat cadence, so for both a gap is normal. + guard Storage.shared.backgroundRefreshType.value == .silentTune else { return } + let alerts = BackgroundAlertDuration.allCases + .filter { gap >= $0.rawValue } + .map { "\(Int($0.rawValue / 60))" } + let fired = alerts.isEmpty ? "none" : alerts.joined(separator: "/") + " min" + var message = "Regained runtime after \(Int(gap))s with no scheduler tick; background alerts fired: \(fired)" + if abs(wallGap - gap) >= 5 { + message += "; wall clock moved \(Int(wallGap - gap))s relative to boot time" + } + LogManager.shared.log(category: .taskScheduler, message: message) + } + private func formatTime(_ date: Date) -> String { let formatter = DateFormatter() formatter.dateStyle = .none diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index ed1ce880f..9c96252d5 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -579,6 +579,11 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { } @objc func appMovedToBackground() { + LogManager.shared.log( + category: .general, + message: "App moved to background (refreshType=\(Storage.shared.backgroundRefreshType.value.rawValue), lowPowerMode=\(ProcessInfo.processInfo.isLowPowerModeEnabled), backgroundRefreshStatus=\(Self.describe(UIApplication.shared.backgroundRefreshStatus)))" + ) + // Allow screen to turn off UIApplication.shared.isIdleTimerDisabled = false @@ -688,7 +693,18 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { scheduleAllTasks() } + private static func describe(_ status: UIBackgroundRefreshStatus) -> String { + switch status { + case .available: "available" + case .denied: "denied" + case .restricted: "restricted" + @unknown default: "unknown" + } + } + @objc func appCameToForeground() { + LogManager.shared.log(category: .general, message: "App came to foreground") + // BFU recovery (StorageReadiness.recover) is driven by AppDelegate before this // controller exists (the readiness gate), so handleBFUReloadCompleted() above // is a vestigial no-op in the gated flow. From 48e21c416d649862dc67fcaa2f3eb4f19d44c132 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 26 Aug 2026 20:59:05 +0000 Subject: [PATCH 10/25] CI: Bump dev version to 7.0.5 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 6b255a7d8..ed1efc03a 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.0.4 +LOOP_FOLLOW_MARKETING_VERSION = 7.0.5 From 1f390574fcefd8085ea63b02dab7ea9a8f7eff07 Mon Sep 17 00:00:00 2001 From: Auggie <659845+aug0211@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:48:42 -0400 Subject: [PATCH 11/25] Add lines to graph and mini graph for "current time" on prior days (#644) * Add lines to graph and mini graph for this time prior days - Add vertical dotted orange lines to main graph for "this time" on prior days when scrolling back - Add vertical dotted orange lines to the mini graph for "this time" on prior days * White space cleanup - Removed unneeded empty lines * Put prior-day time lines behind a Graph Settings toggle The vertical dotted orange lines marking the current time on prior days rendered unconditionally on both the main and mini graphs. Gate them on a new "Show Prior Day Time Lines" toggle in Graph Settings, defaulting to off, so they behave like Show Midnight Lines and the other time markers. The new storage key is unset for existing users, which reads as false, so the lines are hidden until explicitly enabled. --------- Co-authored-by: Auggie Fisher --- LoopFollow/Charts/BGChartModel.swift | 14 ++++++++++++++ LoopFollow/Charts/BGChartView.swift | 12 ++++++++++++ LoopFollow/Settings/GraphSettingsView.swift | 4 ++++ LoopFollow/Settings/SettingsMenuView.swift | 1 + LoopFollow/Storage/Storage.swift | 1 + 5 files changed, 32 insertions(+) diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index 9b451ac6a..37091fb68 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -157,6 +157,7 @@ final class BGChartModel: ObservableObject { @Published var now: Date = .init() @Published var diaMarkers: [Date] = [] @Published var midnightMarkers: [Date] = [] + @Published var priorDayTimeMarkers: [Date] = [] @Published var thirtyMinMark: Date? = nil @Published var ninetyMinMark: Date? = nil @@ -179,6 +180,7 @@ final class BGChartModel: ObservableObject { @Published var show90Min: Bool = false @Published var showMidnight: Bool = false @Published var smallGraphTreatments: Bool = true + @Published var showPriorDayTime: Bool = false private static let doseFormatter: NumberFormatter = { let nf = NumberFormatter() @@ -411,6 +413,7 @@ final class BGChartModel: ObservableObject { show90Min = Storage.shared.show90MinLine.value showMidnight = Storage.shared.showMidnightLines.value smallGraphTreatments = Storage.shared.smallGraphTreatments.value + showPriorDayTime = Storage.shared.showPriorDayTimeLines.value // Advanced-settings visibility toggles. The Nightscout controllers // collect the data regardless (it also feeds the info rows), so hidden @@ -596,6 +599,17 @@ final class BGChartModel: ObservableObject { } return cal }() + + // Mark the current local time on each prior day. Calendar arithmetic + // preserves the displayed time of day across daylight-saving changes. + var priorDayTimes: [Date] = [] + var priorDayTime = calendar.date(byAdding: .day, value: -1, to: currentNow) + while let marker = priorDayTime, marker > domainStart { + priorDayTimes.append(marker) + priorDayTime = calendar.date(byAdding: .day, value: -1, to: marker) + } + priorDayTimeMarkers = priorDayTimes + var cursor = calendar.startOfDay(for: domainStart) while cursor <= domainEnd { if cursor >= domainStart { diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index 9098bd123..1b7dc9407 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -1095,6 +1095,9 @@ private struct BGChartCanvas: View, Equatable { if showTreatments { treatmentMarks } + if model.showPriorDayTime { + priorDayTimeRuleMarks + } if !isSmall { ruleMarks } else if model.showMidnight { @@ -1462,6 +1465,15 @@ private struct BGChartCanvas: View, Equatable { } } + @ChartContentBuilder + private var priorDayTimeRuleMarks: some ChartContent { + ForEach(model.priorDayTimeMarkers.filter { $0 >= windowStart && $0 <= windowEnd }, id: \.self) { d in + RuleMark(x: .value("same time on prior day", d)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: isSmall ? [2, 2] : [2, 5])) + .foregroundStyle(Color.orange.opacity(isSmall ? 1 : 0.5)) + } + } + @ChartContentBuilder private var ruleMarks: some ChartContent { RuleMark(y: .value("low", model.lowLine)) diff --git a/LoopFollow/Settings/GraphSettingsView.swift b/LoopFollow/Settings/GraphSettingsView.swift index 07d9d8d91..7efb38c40 100644 --- a/LoopFollow/Settings/GraphSettingsView.swift +++ b/LoopFollow/Settings/GraphSettingsView.swift @@ -12,6 +12,7 @@ struct GraphSettingsView: View { @ObservedObject private var show30MinLine = Storage.shared.show30MinLine @ObservedObject private var show90MinLine = Storage.shared.show90MinLine @ObservedObject private var showMidnightLines = Storage.shared.showMidnightLines + @ObservedObject private var showPriorDayTimeLines = Storage.shared.showPriorDayTimeLines @ObservedObject private var showYesterdayLine = Storage.shared.showYesterdayLine @ObservedObject private var smallGraphTreatments = Storage.shared.smallGraphTreatments @@ -50,6 +51,9 @@ struct GraphSettingsView: View { Toggle("Show Midnight Lines", isOn: $showMidnightLines.value) .onChange(of: showMidnightLines.value) { _ in markDirty() } + + Toggle("Show Prior Day Time Lines", isOn: $showPriorDayTimeLines.value) + .onChange(of: showPriorDayTimeLines.value) { _ in markDirty() } } // ── Treatments ─────────────────────────────────────────────── diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 38fb991fb..93fd0fd93 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -176,6 +176,7 @@ enum SettingsRoute: Hashable, Identifiable { SettingsLeaf("Show −90 min Line", ["-90"]), SettingsLeaf("Show Yesterday's BG", ["yesterday"]), SettingsLeaf("Show Midnight Lines"), + SettingsLeaf("Show Prior Day Time Lines", ["prior day", "same time", "yesterday"]), SettingsLeaf("Show Carb/Bolus Values", ["carbs"]), SettingsLeaf("Show Carb Absorption"), SettingsLeaf("Treatments on Small Graph"), diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 4876924e2..32f099148 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -128,6 +128,7 @@ class Storage { var show30MinLine = StorageValue(key: "show30MinLine", defaultValue: false) var show90MinLine = StorageValue(key: "show90MinLine", defaultValue: false) var showMidnightLines = StorageValue(key: "showMidnightMarkers", defaultValue: false) + var showPriorDayTimeLines = StorageValue(key: "showPriorDayTimeMarkers", defaultValue: false) var showYesterdayLine = StorageValue(key: "showYesterdayLine", defaultValue: false) var smallGraphTreatments = StorageValue(key: "smallGraphTreatments", defaultValue: true) From dc93d6fdca20dd7e9d0f4151c35d34912fbf64f4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 17 Sep 2026 23:48:51 +0000 Subject: [PATCH 12/25] CI: Bump dev version to 7.0.6 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index ed1efc03a..4095b33de 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.0.5 +LOOP_FOLLOW_MARKETING_VERSION = 7.0.6 From f6d4ca62809f5bebfdccc34a8ee363d8c280f6ba Mon Sep 17 00:00:00 2001 From: skimaniac <44727542+skimaniac@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:59:46 -0400 Subject: [PATCH 13/25] Add App Intents to enable/disable BG speech via Shortcuts (#727) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add App Intents to enable/disable BG speech via Shortcuts * Keep project signing settings on the shared team variable * Run Speak BG intents on the main actor, check storage readiness, and use the Speak BG name --------- Co-authored-by: Jonas Björkert --- LoopFollow/Controllers/SpeakBGIntents.swift | 49 +++++++++++++++++++ .../RestartLiveActivityIntent.swift | 12 +++++ 2 files changed, 61 insertions(+) create mode 100644 LoopFollow/Controllers/SpeakBGIntents.swift diff --git a/LoopFollow/Controllers/SpeakBGIntents.swift b/LoopFollow/Controllers/SpeakBGIntents.swift new file mode 100644 index 000000000..995df2071 --- /dev/null +++ b/LoopFollow/Controllers/SpeakBGIntents.swift @@ -0,0 +1,49 @@ +// LoopFollow +// SpeakBGIntents.swift + +import AppIntents + +struct EnableSpeakBGIntent: AppIntent { + static var title: LocalizedStringResource = "Turn On Speak BG" + static var description = IntentDescription("Turns on Speak BG so LoopFollow reads glucose values aloud.") + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + try SpeakBGIntentSupport.set(true) + return .result(dialog: "Speak BG is now on.") + } +} + +struct DisableSpeakBGIntent: AppIntent { + static var title: LocalizedStringResource = "Turn Off Speak BG" + static var description = IntentDescription("Turns off Speak BG so LoopFollow stops reading glucose values aloud.") + + @MainActor + func perform() async throws -> some IntentResult & ProvidesDialog { + try SpeakBGIntentSupport.set(false) + return .result(dialog: "Speak BG is now off.") + } +} + +enum SpeakBGIntentSupport { + /// Storage writes are memory-only during a suspected before-first-unlock + /// launch, so the change would be lost on hydration. + @MainActor + static func set(_ enabled: Bool) throws { + guard !StorageReadiness.isSuppressingWrites else { + throw SpeakBGIntentError.storageUnavailable + } + Storage.shared.speakBG.value = enabled + } +} + +enum SpeakBGIntentError: Error, CustomLocalizedStringResourceConvertible { + case storageUnavailable + + var localizedStringResource: LocalizedStringResource { + switch self { + case .storageUnavailable: + return "LoopFollow can't change Speak BG until your iPhone has been unlocked once after restarting." + } + } +} diff --git a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift index e92ee5778..5a25afdae 100644 --- a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift +++ b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift @@ -40,6 +40,18 @@ shortTitle: "Restart Live Activity", systemImageName: "dot.radiowaves.left.and.right" ) + AppShortcut( + intent: EnableSpeakBGIntent(), + phrases: ["Turn on Speak BG in \(.applicationName)"], + shortTitle: "Turn On Speak BG", + systemImageName: "speaker.wave.2" + ) + AppShortcut( + intent: DisableSpeakBGIntent(), + phrases: ["Turn off Speak BG in \(.applicationName)"], + shortTitle: "Turn Off Speak BG", + systemImageName: "speaker.slash" + ) } } #endif From 85ef02ccc4ebbc8068324b4225acc17f24d09c64 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 17 Sep 2026 23:59:57 +0000 Subject: [PATCH 14/25] CI: Bump dev version to 7.0.7 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 4095b33de..147aea5ac 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.0.6 +LOOP_FOLLOW_MARKETING_VERSION = 7.0.7 From ea65ec17e03afdf8e4ac60ecf951033c0a4c919c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 19 Sep 2026 01:35:21 +0200 Subject: [PATCH 15/25] CI: Move remaining actions to Node 24 runtimes (#752) --- .github/workflows/lint.yml | 2 +- .github/workflows/warn_main_pr.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8b1c217f3..9d5005aa1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -29,7 +29,7 @@ jobs: uses: actions/checkout@v5 - name: Cache SwiftFormat build - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: BuildTools/.build key: ${{ runner.os }}-swiftformat-${{ hashFiles('BuildTools/Package.resolved', 'BuildTools/Package.swift') }} diff --git a/.github/workflows/warn_main_pr.yml b/.github/workflows/warn_main_pr.yml index 7d79ebb53..3f918f8f8 100644 --- a/.github/workflows/warn_main_pr.yml +++ b/.github/workflows/warn_main_pr.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Comment on PR - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | await github.rest.issues.createComment({ From 6624aedc8290bdd0321d3963dbe316f2ae7bc47d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 18 Sep 2026 23:35:32 +0000 Subject: [PATCH 16/25] CI: Bump dev version to 7.0.8 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 147aea5ac..ab0940bc5 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.0.7 +LOOP_FOLLOW_MARKETING_VERSION = 7.0.8 From 9a221c1456c12e2f7fae191c3ea991b708a00bba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 20 Sep 2026 18:45:57 +0200 Subject: [PATCH 17/25] Carry the pump reservoir value across records that omit it (#746) Loop uploads pump.reservoir in only some device status records while an Omnipod is below 50U. The last exact reading is kept per pump and reused for 30 minutes when the field is missing, and the pump row shows an em dash once it is older than that. A missing reading reads as 50+U for pumps that report a volume only when it is low, and for records that name no pump at all, which is how Trio and iAPS upload. Pumps that name themselves and report a volume in every record get the em dash instead. A reading that arrives within 15 minutes of a pump first appearing can still be the previous pod's, so it is not taken as evidence that the pod is below its reporting limit. latestPumpVolume is optional so an unknown volume cannot reach the reservoir alarm as 50. --- .../Controllers/Nightscout/DeviceStatus.swift | 25 ++- .../Nightscout/PumpReservoir.swift | 104 ++++++++++++ LoopFollow/Storage/Storage.swift | 1 + .../ViewControllers/MainViewController.swift | 2 +- Tests/PumpReservoirTests.swift | 151 ++++++++++++++++++ 5 files changed, 277 insertions(+), 6 deletions(-) create mode 100644 LoopFollow/Controllers/Nightscout/PumpReservoir.swift create mode 100644 Tests/PumpReservoirTests.swift diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index 89126e6b1..4cd3a65a1 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -112,16 +112,31 @@ extension MainViewController { Storage.shared.lastLoopTime.value = lastPumpTime } - if let reservoirData = lastPumpRecord["reservoir"] as? Double { - latestPumpVolume = reservoirData - infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U", numericValue: reservoirData) - Storage.shared.lastPumpReservoirU.value = reservoirData - } else { + let reservoir = PumpReservoirResolver.resolve( + reservoir: lastPumpRecord["reservoir"] as? Double, + pumpID: lastPumpRecord["pumpID"] as? String, + manufacturer: lastPumpRecord["manufacturer"] as? String, + model: lastPumpRecord["model"] as? String, + cache: Storage.shared.pumpReservoirCache.value, + now: Date() + ) + Storage.shared.pumpReservoirCache.value = reservoir.cache + + switch reservoir.state { + case let .units(units): + latestPumpVolume = units + infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", units) + "U", numericValue: units) + Storage.shared.lastPumpReservoirU.value = units + case .aboveReportingLimit: // Pumps that only report "50+" get treated as exactly 50, both // for the volume alarm and for the info row's coloring. latestPumpVolume = 50.0 infoManager.updateInfoData(type: .pump, value: "50+U", numericValue: 50.0) Storage.shared.lastPumpReservoirU.value = nil + case .unknown: + // The row stays cleared, which the info table renders as an em dash. + latestPumpVolume = nil + Storage.shared.lastPumpReservoirU.value = nil } } diff --git a/LoopFollow/Controllers/Nightscout/PumpReservoir.swift b/LoopFollow/Controllers/Nightscout/PumpReservoir.swift new file mode 100644 index 000000000..ad843a296 --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/PumpReservoir.swift @@ -0,0 +1,104 @@ +// LoopFollow +// PumpReservoir.swift + +import Foundation + +/// What is known about one pump's reservoir, carried between device status records. +struct PumpReservoirCache: Codable, Equatable { + struct Reading: Codable, Equatable { + let units: Double + let date: Date + } + + let pumpID: String + /// When this pump first appeared in a device status record LoopFollow fetched. + let pumpSince: Date + var reading: Reading? +} + +/// What a device status record says about the reservoir. +enum PumpReservoirState: Equatable { + /// An exact volume, from the record itself or from a recent reading for the same pump. + case units(Double) + /// A pump that reports a volume only once it drops below 50U, and is above it. + case aboveReportingLimit + /// No volume to show. + case unknown +} + +enum PumpReservoirResolver { + /// Omnipod reports the reservoir in only some of the device status records it uploads + /// while the pod is below 50U. A reading carries across those gaps for this long. + static let maxReadingAge: TimeInterval = 30 * 60 + + /// A volume reported within this long of a pump first appearing can still be the + /// previous pod's final reading, so it says nothing about the pump now on. + static let pumpSettleTime: TimeInterval = 15 * 60 + + struct Resolution: Equatable { + let state: PumpReservoirState + /// What to keep for the next record, `nil` to store nothing. + let cache: PumpReservoirCache? + } + + static func resolve( + reservoir: Double?, + pumpID: String?, + manufacturer: String?, + model: String?, + cache storedCache: PumpReservoirCache?, + now: Date + ) -> Resolution { + let withoutReading: PumpReservoirState = reportsVolumeOnlyWhenLow(manufacturer: manufacturer, model: model) + ? .aboveReportingLimit + : .unknown + + // A reading is carried between records only when the uploader names the pump it + // came from, so that a pod change discards it. + guard let pumpID = identifiedPump(pumpID) else { + guard let reservoir else { return Resolution(state: withoutReading, cache: nil) } + return Resolution(state: .units(reservoir), cache: nil) + } + + var cache = storedCache?.pumpID == pumpID + ? storedCache! + : PumpReservoirCache(pumpID: pumpID, pumpSince: now, reading: nil) + + if let reservoir { + cache.reading = PumpReservoirCache.Reading(units: reservoir, date: now) + return Resolution(state: .units(reservoir), cache: cache) + } + + if let reading = cache.reading { + let age = now.timeIntervalSince(reading.date) + if age >= 0, age <= maxReadingAge { + return Resolution(state: .units(reading.units), cache: cache) + } + if reading.date.timeIntervalSince(cache.pumpSince) >= pumpSettleTime { + // The pump has reported a volume long enough after coming online for that + // to be its own, so it is below its reporting limit and the only thing + // missing is a fresh number. A pump first seen mid-pod has no such gap, + // so its first reading is treated as a pod change's and dropped below. + return Resolution(state: .unknown, cache: cache) + } + cache.reading = nil + } + + return Resolution(state: withoutReading, cache: cache) + } + + private static func identifiedPump(_ pumpID: String?) -> String? { + let trimmed = pumpID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + // Loop reports "Unknown" while no pod is paired. + guard !trimmed.isEmpty, trimmed != "Unknown" else { return nil } + return trimmed + } + + private static func reportsVolumeOnlyWhenLow(manufacturer: String?, model: String?) -> Bool { + let pump = [manufacturer, model].compactMap { $0 }.joined(separator: " ").lowercased() + // An uploader that names no pump cannot be told apart from one that reports its + // volume only when low, so it gets the same reading. + guard !pump.isEmpty else { return true } + return pump.contains("insulet") || pump.contains("omnipod") || pump.contains("dash") + } +} diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 32f099148..dd63f6dd6 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -99,6 +99,7 @@ class Storage { // Live Activity extended InfoType data var lastBasal = StorageValue(key: "lastBasal", defaultValue: "") var lastPumpReservoirU = StorageValue(key: "lastPumpReservoirU", defaultValue: nil) + var pumpReservoirCache = StorageValue(key: "pumpReservoirCache", defaultValue: nil) var lastAutosens = StorageValue(key: "lastAutosens", defaultValue: nil) var lastTdd = StorageValue(key: "lastTdd", defaultValue: nil) var lastTargetLowMgdl = StorageValue(key: "lastTargetLowMgdl", defaultValue: nil) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 9c96252d5..06c5e1f76 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -103,7 +103,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { var latestLoopStatusString = "" var latestCOB: CarbMetric? var latestBasal = "" - var latestPumpVolume: Double = 50.0 + var latestPumpVolume: Double? var latestIOB: InsulinMetric? var lastOverrideStartTime: TimeInterval = 0 var lastOverrideEndTime: TimeInterval = 0 diff --git a/Tests/PumpReservoirTests.swift b/Tests/PumpReservoirTests.swift new file mode 100644 index 000000000..625e43802 --- /dev/null +++ b/Tests/PumpReservoirTests.swift @@ -0,0 +1,151 @@ +// LoopFollow +// PumpReservoirTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct PumpReservoirTests { + private let now = Date(timeIntervalSince1970: 1_700_000_000) + private let pump = "17CB71F7" + + private func resolve( + reservoir: Double? = nil, + pumpID: String? = "17CB71F7", + manufacturer: String? = "Insulet", + model: String? = "Omnipod DASH", + cache: PumpReservoirCache? = nil, + at date: Date? = nil + ) -> PumpReservoirResolver.Resolution { + PumpReservoirResolver.resolve( + reservoir: reservoir, + pumpID: pumpID, + manufacturer: manufacturer, + model: model, + cache: cache, + now: date ?? now + ) + } + + /// A pump that came online `settledFor` ago and reported `units` `readingAge` ago. + private func cache(units: Double, readingAge: TimeInterval, settledFor: TimeInterval = 60 * 60) -> PumpReservoirCache { + PumpReservoirCache( + pumpID: pump, + pumpSince: now.addingTimeInterval(-settledFor), + reading: .init(units: units, date: now.addingTimeInterval(-readingAge)) + ) + } + + @Test("a reported volume is used and kept for the pump it came from") + func reportedVolume() { + let result = resolve(reservoir: 12.5) + #expect(result.state == .units(12.5)) + #expect(result.cache?.pumpID == pump) + #expect(result.cache?.reading == .init(units: 12.5, date: now)) + } + + @Test("zero is a volume, not a missing reading") + func zeroVolume() { + #expect(resolve(reservoir: 0).state == .units(0)) + } + + @Test("a record without a volume reuses a recent reading from the same pump") + func carriesRecentReading() { + let result = resolve(cache: cache(units: 9.9, readingAge: 25 * 60)) + #expect(result.state == .units(9.9)) + #expect(result.cache?.reading?.units == 9.9) + } + + @Test("a reading older than 30 minutes is not shown, and does not become 50+") + func staleReadingIsUnknown() { + let result = resolve(cache: cache(units: 9.9, readingAge: 31 * 60)) + #expect(result.state == .unknown) + // Kept, so the next record still reads as unknown. + #expect(result.cache?.reading?.units == 9.9) + #expect(resolve(cache: result.cache, at: now.addingTimeInterval(5 * 60)).state == .unknown) + } + + @Test("a reading dated in the future is treated as stale") + func futureReadingIsUnknown() { + #expect(resolve(cache: cache(units: 9.9, readingAge: -60 * 60)).state == .unknown) + } + + @Test("a volume reported right after a pod change is shown but not trusted once stale") + func podChangeCarryoverIsDiscarded() { + // Loop's first records for a new pod still carry the previous pod's final volume. + let carryover = resolve(reservoir: 9.9, cache: PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60), reading: nil)) + #expect(carryover.state == .units(9.9)) + #expect(carryover.cache?.pumpID == pump) + + let laterOnTheSamePod = resolve(cache: carryover.cache, at: now.addingTimeInterval(31 * 60)) + #expect(laterOnTheSamePod.state == .aboveReportingLimit) + #expect(laterOnTheSamePod.cache?.pumpID == pump) + #expect(laterOnTheSamePod.cache?.reading == nil) + } + + @Test("a pod change drops the previous pod's reading") + func podChangeDropsReading() { + let previousPod = PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60), reading: .init(units: 9.9, date: now.addingTimeInterval(-5 * 60))) + let result = resolve(cache: previousPod) + #expect(result.state == .aboveReportingLimit) + #expect(result.cache?.reading == nil) + #expect(result.cache?.pumpSince == now) + } + + @Test("an Omnipod that has never reported a volume reads as 50+") + func omnipodWithoutReading() { + #expect(resolve().state == .aboveReportingLimit) + #expect(resolve(manufacturer: "Insulet", model: "Dash").state == .aboveReportingLimit) + #expect(resolve(manufacturer: nil, model: "Omnipod").state == .aboveReportingLimit) + } + + @Test("a pump that reports its volume gives no number when the field is missing") + func otherPumpWithoutReading() { + let result = resolve(manufacturer: "Medtronic", model: "723") + #expect(result.state == .unknown) + #expect(result.cache?.pumpID == pump) + #expect(result.cache?.reading == nil) + } + + @Test("an uploader that names no pump resolves from the record alone") + func unidentifiedPump() { + // Trio and iAPS name no pump, so there is nothing to tie a reading to. + let withVolume = resolve(reservoir: 18, pumpID: nil, manufacturer: nil, model: nil) + #expect(withVolume.state == .units(18)) + #expect(withVolume.cache == nil) + + let withoutVolume = resolve(pumpID: nil, manufacturer: nil, model: nil) + #expect(withoutVolume.state == .aboveReportingLimit) + #expect(withoutVolume.cache == nil) + } + + @Test("a pump with no pod paired is not an identity to cache against") + func unknownPumpID() { + let result = resolve(pumpID: "Unknown", cache: cache(units: 9.9, readingAge: 5 * 60)) + #expect(result.state == .aboveReportingLimit) + #expect(result.cache == nil) + } + + @Test("a settled pump stays unknown for as long as it reports nothing") + func settledUnknownPersists() { + var result = resolve(cache: cache(units: 9.9, readingAge: 31 * 60)) + #expect(result.state == .unknown) + result = resolve(cache: result.cache, at: now.addingTimeInterval(3 * 60 * 60)) + #expect(result.state == .unknown) + } + + @Test("a settled pump recovers as soon as it reports again") + func settledUnknownRecovers() { + let stale = resolve(cache: cache(units: 9.9, readingAge: 31 * 60)) + let reported = resolve(reservoir: 8.4, cache: stale.cache, at: now.addingTimeInterval(5 * 60)) + #expect(reported.state == .units(8.4)) + #expect(reported.cache?.reading?.units == 8.4) + } + + @Test("the cache survives a round trip through storage") + func cacheRoundTrips() throws { + let original = cache(units: 12.5, readingAge: 5 * 60) + let decoded = try JSONDecoder().decode(PumpReservoirCache.self, from: JSONEncoder().encode(original)) + #expect(decoded == original) + } +} From 48078d44574e5bddf994f7defc481d76dfd09ad2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 20 Sep 2026 16:46:06 +0000 Subject: [PATCH 18/25] CI: Bump dev version to 7.0.9 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index ab0940bc5..3f7541f61 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.0.8 +LOOP_FOLLOW_MARKETING_VERSION = 7.0.9 From 193914711ea5b697fa9a44810f7da3359ca5b205 Mon Sep 17 00:00:00 2001 From: Daniel Mini Johansson <42831533+codebymini@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:52:28 +0200 Subject: [PATCH 19/25] Clarify remote command sent confirmation message (#750) * Clarify remote command sent confirmation message * Fix formatting by adding a newline at the end of RemoteCommandMessage.swift --- LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift | 4 ++-- LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift | 6 ++---- LoopFollow/Remote/RemoteCommandMessage.swift | 6 ++++++ LoopFollow/Remote/TRC/BolusView.swift | 4 ++-- LoopFollow/Remote/TRC/MealView.swift | 4 ++-- LoopFollow/Remote/TRC/OverrideView.swift | 6 +++--- LoopFollow/Remote/TRC/TempTargetView.swift | 6 +++--- 7 files changed, 20 insertions(+), 16 deletions(-) create mode 100644 LoopFollow/Remote/RemoteCommandMessage.swift diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift index 2cc834fc3..09222270c 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift @@ -270,7 +270,7 @@ struct LoopAPNSBolusView: View { switch alertType { case .success: return Alert( - title: Text("Success"), + title: Text("Command Sent"), message: Text(alertMessage), dismissButton: .default(Text("OK")) { presentationMode.wrappedValue.dismiss() @@ -409,7 +409,7 @@ struct LoopAPNSBolusView: View { } // Mark TOTP code as used TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) - self.alertMessage = "Insulin sent successfully!" + self.alertMessage = RemoteCommandMessage.sent self.alertType = .success LogManager.shared.log( category: .apns, diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift index 32db0f9c9..537fa29d2 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift @@ -455,7 +455,7 @@ struct LoopAPNSCarbsView: View { switch alertType { case .success: return Alert( - title: Text("Success"), + title: Text("Command Sent"), message: Text(alertMessage), dismissButton: .default(Text("OK")) { presentationMode.wrappedValue.dismiss() @@ -571,9 +571,7 @@ struct LoopAPNSCarbsView: View { } // Mark TOTP code as used TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) - let timeFormatter = DateFormatter() - timeFormatter.timeStyle = .short - self.alertMessage = "Carbs sent successfully for \(timeFormatter.string(from: adjustedConsumedDate))!" + self.alertMessage = RemoteCommandMessage.sent self.alertType = .success LogManager.shared.log( category: .apns, diff --git a/LoopFollow/Remote/RemoteCommandMessage.swift b/LoopFollow/Remote/RemoteCommandMessage.swift new file mode 100644 index 000000000..686734b88 --- /dev/null +++ b/LoopFollow/Remote/RemoteCommandMessage.swift @@ -0,0 +1,6 @@ +// LoopFollow +// RemoteCommandMessage.swift + +enum RemoteCommandMessage { + static let sent = "Remote command sent. Wait for a notification confirming the result." +} diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index 7a59c87de..e41ab89ce 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -180,7 +180,7 @@ struct BolusView: View { ) case .statusSuccess: return Alert( - title: Text("Status"), + title: Text("Command Sent"), message: Text(statusMessage ?? ""), dismissButton: .default(Text("OK"), action: { presentationMode.wrappedValue.dismiss() @@ -322,7 +322,7 @@ struct BolusView: View { if sentUnits > 0 { QuickPickBolusesManager.shared.recordBolus(units: sentUnits) } - statusMessage = "Bolus command sent successfully." + statusMessage = RemoteCommandMessage.sent LogManager.shared.log( category: .apns, message: "sendBolusPushNotification succeeded - Bolus: \(InsulinFormatter.shared.string(bolusAmount)) U" diff --git a/LoopFollow/Remote/TRC/MealView.swift b/LoopFollow/Remote/TRC/MealView.swift index 5938735d1..5db2ef14b 100644 --- a/LoopFollow/Remote/TRC/MealView.swift +++ b/LoopFollow/Remote/TRC/MealView.swift @@ -299,7 +299,7 @@ struct MealView: View { ) case .statusSuccess: return Alert( - title: Text("Status"), + title: Text("Command Sent"), message: Text(statusMessage ?? ""), dismissButton: .default(Text("OK"), action: { presentationMode.wrappedValue.dismiss() @@ -362,7 +362,7 @@ struct MealView: View { bolus: bolusAmount.doubleValue(for: .internationalUnit()) ) } - statusMessage = "Meal command sent successfully." + statusMessage = RemoteCommandMessage.sent LogManager.shared.log( category: .apns, message: "sendMealPushNotification succeeded - Carbs: \(carbs.doubleValue(for: .gram())) g, Protein: \(protein.doubleValue(for: .gram())) g, Fat: \(fat.doubleValue(for: .gram())) g, Bolus: \(bolusAmount.doubleValue(for: .internationalUnit())) U, Scheduled: \(scheduledDate != nil ? formatDate(scheduledDate!) : "now")" diff --git a/LoopFollow/Remote/TRC/OverrideView.swift b/LoopFollow/Remote/TRC/OverrideView.swift index 703201b07..3ef038e03 100644 --- a/LoopFollow/Remote/TRC/OverrideView.swift +++ b/LoopFollow/Remote/TRC/OverrideView.swift @@ -147,7 +147,7 @@ struct OverrideView: View { ) case .statusSuccess: return Alert( - title: Text("Success"), + title: Text("Command Sent"), message: Text(statusMessage ?? ""), dismissButton: .default(Text("OK"), action: { presentationMode.wrappedValue.dismiss() @@ -181,7 +181,7 @@ struct OverrideView: View { DispatchQueue.main.async { self.isLoading = false if success { - self.statusMessage = "Override command sent successfully." + self.statusMessage = RemoteCommandMessage.sent self.alertType = .statusSuccess LogManager.shared.log(category: .apns, message: "sendOverridePushNotification succeeded for override: \(override.name)") } else { @@ -201,7 +201,7 @@ struct OverrideView: View { DispatchQueue.main.async { self.isLoading = false if success { - self.statusMessage = "Cancel override command sent successfully." + self.statusMessage = RemoteCommandMessage.sent self.alertType = .statusSuccess LogManager.shared.log(category: .apns, message: "sendCancelOverridePushNotification succeeded") } else { diff --git a/LoopFollow/Remote/TRC/TempTargetView.swift b/LoopFollow/Remote/TRC/TempTargetView.swift index ac2fa7303..08d316f35 100644 --- a/LoopFollow/Remote/TRC/TempTargetView.swift +++ b/LoopFollow/Remote/TRC/TempTargetView.swift @@ -189,7 +189,7 @@ struct TempTargetView: View { ) case .statusSuccess: return Alert( - title: Text("Status"), + title: Text("Command Sent"), message: Text(statusMessage ?? ""), dismissButton: .default(Text("OK"), action: { presentationMode.wrappedValue.dismiss() @@ -261,7 +261,7 @@ struct TempTargetView: View { DispatchQueue.main.async { self.isLoading = false if success { - self.statusMessage = "Temp target command successfully sent." + self.statusMessage = RemoteCommandMessage.sent self.alertType = .statusSuccess LogManager.shared.log(category: .apns, message: "sendTempTargetPushNotification succeeded with target: \(newHKTarget), duration: \(duration)") } else { @@ -281,7 +281,7 @@ struct TempTargetView: View { DispatchQueue.main.async { self.isLoading = false if success { - self.statusMessage = "Cancel temp target command successfully sent." + self.statusMessage = RemoteCommandMessage.sent self.alertType = .statusSuccess LogManager.shared.log(category: .apns, message: "sendCancelTempTargetPushNotification succeeded") } else { From 9a1ec28a1bd1d78424357695ce83aee871c0c891 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 20 Sep 2026 16:52:37 +0000 Subject: [PATCH 20/25] CI: Bump dev version to 7.0.10 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 3f7541f61..05f1ba1b4 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.0.9 +LOOP_FOLLOW_MARKETING_VERSION = 7.0.10 From db1824da0b5e8dc71edf417ebe5d9227e6cd3318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 20 Sep 2026 18:58:04 +0200 Subject: [PATCH 21/25] Snap chart scrubbing to a five-minute grid locked to the readings (#748) --- LoopFollow/Charts/BGChartModel.swift | 3 + LoopFollow/Charts/BGChartScrubSlots.swift | 148 ++++++++++++++++++++ LoopFollow/Charts/BGChartView.swift | 133 +++++++----------- Tests/Charts/BGChartScrubSlotsTests.swift | 156 ++++++++++++++++++++++ 4 files changed, 353 insertions(+), 87 deletions(-) create mode 100644 LoopFollow/Charts/BGChartScrubSlots.swift create mode 100644 Tests/Charts/BGChartScrubSlotsTests.swift diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index 37091fb68..8acc2859c 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -111,6 +111,8 @@ final class BGChartModel: ObservableObject { } @Published var bg: [BGPoint] = [] + /// Scrub timeline on the BG cadence; rebuilt together with `bg`. + private(set) var scrubSlots = BGChartScrubSlots(readingDates: []) @Published var bgRuns: [BGRun] = [] @Published var yesterday: [BGPoint] = [] @Published var prediction: [BGPoint] = [] @@ -433,6 +435,7 @@ final class BGChartModel: ObservableObject { let maxDisplay = globalVariables.maxDisplayGlucose func clampSgv(_ sgv: Int) -> Double { Double(min(max(sgv, minDisplay), maxDisplay)) } + scrubSlots = BGChartScrubSlots(readingDates: vc.bgData.map { Date(timeIntervalSince1970: $0.date) }) bg = vc.bgData.map { BGPoint(date: Date(timeIntervalSince1970: $0.date), value: clampSgv($0.sgv), color: colorFor($0.sgv, thresholds: thresholds)) } bgRuns = Self.makeRuns(bg) diff --git a/LoopFollow/Charts/BGChartScrubSlots.swift b/LoopFollow/Charts/BGChartScrubSlots.swift new file mode 100644 index 000000000..6bd1e9887 --- /dev/null +++ b/LoopFollow/Charts/BGChartScrubSlots.swift @@ -0,0 +1,148 @@ +// LoopFollow +// BGChartScrubSlots.swift + +import Foundation + +/// Five-minute grid, phase-locked to the readings, that the scrub indicator +/// snaps to. +/// +/// The grid is walked backwards from the newest reading. Each step lands on +/// the reading nearest the expected mark when one lies within half a cadence +/// of it, and on a virtual mark otherwise, so drift is absorbed and gaps are +/// crossed at exactly the cadence. The grid continues before the first and +/// after the last mark. Each mark owns the time between the midpoints to its +/// neighbours, so the blocks tile the timeline and every reading and +/// treatment belongs to exactly one mark. +struct BGChartScrubSlots { + struct Slot: Equatable { + let date: Date + /// Index into `readingDates` for a mark placed on a reading; nil for a + /// virtual mark. + let readingIndex: Int? + /// Owned time, inclusive at the start and exclusive at the end. + let blockStart: Date + let blockEnd: Date + + var isReading: Bool { readingIndex != nil } + + func contains(_ date: Date) -> Bool { + date >= blockStart && date < blockEnd + } + } + + static let cadence: TimeInterval = 5 * 60 + + /// Every reading timestamp, ascending. + let readingDates: [Date] + /// Grid marks inside the data range, ascending. + private let slotDates: [Date] + private let slotReadingIndices: [Int?] + + init(readingDates: [Date]) { + let sorted = readingDates.sorted() + self.readingDates = sorted + + var dates: [Date] = [] + var indices: [Int?] = [] + if let first = sorted.first, let newest = sorted.last { + let cadence = Self.cadence + var cursor = newest + var cursorIndex = sorted.count - 1 + dates.append(cursor) + indices.append(cursorIndex) + while first < cursor.addingTimeInterval(-cadence / 2) { + let target = cursor.addingTimeInterval(-cadence) + if let hit = Self.nearestReading(in: sorted, before: cursorIndex, to: target, within: cadence / 2) { + cursor = sorted[hit] + cursorIndex = hit + dates.append(cursor) + indices.append(hit) + } else { + cursor = target + dates.append(cursor) + indices.append(nil) + } + } + dates.reverse() + indices.reverse() + } + slotDates = dates + slotReadingIndices = indices + } + + /// Index of the reading nearest `target` among those strictly before index + /// `limit`, if it lies within `tolerance` of the target. + private static func nearestReading(in sorted: [Date], before limit: Int, to target: Date, within tolerance: TimeInterval) -> Int? { + guard limit > 0 else { return nil } + // First index in 0 ..< limit whose date is >= target. + var low = 0 + var high = limit + while low < high { + let mid = (low + high) / 2 + if sorted[mid] < target { low = mid + 1 } else { high = mid } + } + var best: Int? + for candidate in [low - 1, low] where candidate >= 0 && candidate < limit { + let distance = abs(sorted[candidate].timeIntervalSince(target)) + if distance <= tolerance, best.map({ distance < abs(sorted[$0].timeIntervalSince(target)) }) ?? true { + best = candidate + } + } + return best + } + + /// The mark whose block contains `date`. + func slot(containing date: Date) -> Slot { + let cadence = Self.cadence + guard let firstSlot = slotDates.first, let lastSlot = slotDates.last else { + return virtualSlot(origin: Date(timeIntervalSince1970: 0), nearestTo: date) + } + if date < firstSlot.addingTimeInterval(-cadence / 2) { + return virtualSlot(origin: firstSlot, nearestTo: date) + } + if date >= lastSlot.addingTimeInterval(cadence / 2) { + return virtualSlot(origin: lastSlot, nearestTo: date) + } + + // Index of the last mark at or before `date`; the midpoint to its + // successor decides between the two. + var low = 0 + var high = slotDates.count - 1 + while low < high { + let mid = (low + high + 1) / 2 + if slotDates[mid] <= date { low = mid } else { high = mid - 1 } + } + var i = low + if date < firstSlot { + i = 0 + } else if i + 1 < slotDates.count, date >= midpoint(slotDates[i], slotDates[i + 1]) { + i += 1 + } + return gridSlot(at: i) + } + + private func gridSlot(at i: Int) -> Slot { + let cadence = Self.cadence + let date = slotDates[i] + let blockStart = i > 0 ? midpoint(slotDates[i - 1], date) : date.addingTimeInterval(-cadence / 2) + let blockEnd = i + 1 < slotDates.count ? midpoint(date, slotDates[i + 1]) : date.addingTimeInterval(cadence / 2) + return Slot(date: date, readingIndex: slotReadingIndices[i], blockStart: blockStart, blockEnd: blockEnd) + } + + /// Virtual mark on the grid anchored at `origin`; a halfway date belongs + /// to the later mark, matching the half-open blocks. + private func virtualSlot(origin: Date, nearestTo date: Date) -> Slot { + let cadence = Self.cadence + let k = (date.timeIntervalSince(origin) / cadence + 0.5).rounded(.down) + return Slot( + date: origin.addingTimeInterval(k * cadence), + readingIndex: nil, + blockStart: origin.addingTimeInterval((k - 0.5) * cadence), + blockEnd: origin.addingTimeInterval((k + 0.5) * cadence) + ) + } + + private func midpoint(_ a: Date, _ b: Date) -> Date { + a.addingTimeInterval(b.timeIntervalSince(a) / 2) + } +} diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index 1b7dc9407..74f9ec03e 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -44,15 +44,6 @@ private enum BGChartConfig { /// How long after the last navigation in history before a data tick pulls /// the chart back to "now". static let autoFollowPause: TimeInterval = 5 * 60 - /// Max distance between the scrub date and an anchor for it to be selected. - static let selectionTolerance: TimeInterval = 20 * 60 - /// Half-width (pt) of the scrub capture band: treatments whose symbol is - /// within this screen distance of the finger join the pill alongside the - /// (ever-present) nearest BG reading. - static let scrubCaptureRadius: CGFloat = 22 - /// Time cap on the capture band, so wide zooms — where a finger-width - /// covers hours — don't sweep far-away treatments into the pill. - static let scrubCaptureMaxSeconds: TimeInterval = 5 * 60 /// Screen-space radius (pt) within which a tap selects a mark. static let tapHitRadius: CGFloat = 30 } @@ -532,9 +523,8 @@ private struct MainBGChart: View { interaction.visibleSeconds * TimeInterval(fraction) ) selection = date - // A featherlight tick whenever the indicator snaps to a different item. - let captureWindow = scrubCaptureWindow(viewportWidth: viewportWidth) - if let anchor = selectionAnchor(for: date, captureWindow: captureWindow), anchor.date != lastHapticAnchorDate { + // A featherlight tick whenever the indicator snaps to a different slot. + if let anchor = selectionAnchor(for: date), anchor.date != lastHapticAnchorDate { lastHapticAnchorDate = anchor.date scrubHaptic.selectionChanged() scrubHaptic.prepare() @@ -688,14 +678,14 @@ private struct MainBGChart: View { let texts: [String] } - /// Feeds every treatment mark to `body` as (drawnDate, value, pillText). - /// Single source for both the scrub lookup and the tap hit test. - private func forEachTreatmentAnchor(_ body: (Date, Double, String) -> Void) { + /// Feeds every treatment mark to `body`. Single source for both the scrub + /// lookup and the tap hit test. + private func forEachTreatmentAnchor(_ body: (BGChartModel.TreatmentPoint) -> Void) { for group in [model.boluses, model.carbs, model.smbs, model.bgChecks, model.notes, model.suspends, model.resumes, model.sensorStarts] { for t in group { - body(t.drawnDate, t.sgv, t.pillText) + body(t) } } } @@ -739,76 +729,45 @@ private struct MainBGChart: View { return nil } - /// Seconds of chart time covered by `scrubCaptureRadius` at the current - /// zoom, bounded by `scrubCaptureMaxSeconds`. - private func scrubCaptureWindow(viewportWidth: CGFloat) -> TimeInterval { - min( - BGChartConfig.scrubCaptureMaxSeconds, - TimeInterval(BGChartConfig.scrubCaptureRadius / viewportWidth) * interaction.visibleSeconds - ) - } - - /// Scrub lookup (time-only). Collects everything under the finger instead - /// of picking a single winner: every treatment inside the capture window - /// joins the pill, and the nearest BG reading always does — so treatments - /// and glucose readings can never hide one another. The indicator snaps - /// to the nearest collected item; the pill stacks them all (treatments in - /// drawn order, BG last). - private func selectionAnchor(for selected: Date, captureWindow: TimeInterval) -> SelectionAnchor? { - struct Item { - let date: Date - let value: Double - let text: String - let distance: TimeInterval - } - - var captured: [Item] = [] - var nearestTreatment: Item? - forEachTreatmentAnchor { date, value, text in - let item = Item(date: date, value: value, text: text, distance: abs(date.timeIntervalSince(selected))) - if item.distance <= captureWindow { - captured.append(item) - } - if item.distance < (nearestTreatment?.distance ?? .greatestFiniteMagnitude) { - nearestTreatment = item + /// Scrub lookup (time-only). The finger resolves to the grid mark whose + /// block contains the scrub time (see BGChartScrubSlots); the indicator + /// stands on the mark. The pill stacks every treatment in the block, then + /// every BG reading in it, then any band at the mark, so it is constant + /// across the block. The indicator's height comes from the reading nearest + /// the mark, else the nearest treatment, else the band; an empty block + /// shows nothing. + private func selectionAnchor(for selected: Date) -> SelectionAnchor? { + let slot = model.scrubSlots.slot(containing: selected) + let mark = slot.date + + var treatments: [BGChartModel.TreatmentPoint] = [] + forEachTreatmentAnchor { t in + if slot.contains(t.date) { treatments.append(t) } + } + treatments.sort { $0.date < $1.date } + let readings = model.bg.filter { slot.contains($0.date) } + + var texts = treatments.map(\.pillText) + readings.map(bgPillText) + let bandTexts = bandPillTexts(at: mark) + texts += bandTexts + + func distanceToMark(_ date: Date) -> TimeInterval { abs(date.timeIntervalSince(mark)) } + + var value: Double? + if let reading = readings.min(by: { distanceToMark($0.date) < distanceToMark($1.date) }) { + value = reading.value + } else if let nearest = treatments.min(by: { distanceToMark($0.date) < distanceToMark($1.date) }) { + value = nearest.sgv + } else if !bandTexts.isEmpty { + if let band = model.overrides.first(where: { mark >= $0.start && mark <= $0.end }) + ?? model.tempTargets.first(where: { mark >= $0.start && mark <= $0.end }) + { + value = (band.yTop + band.yBottom) / 2 } } - captured.sort { $0.date < $1.date } - var nearestBG: Item? - for p in model.bg { - let d = abs(p.date.timeIntervalSince(selected)) - if d < (nearestBG?.distance ?? .greatestFiniteMagnitude) { - nearestBG = Item(date: p.date, value: p.value, text: bgPillText(for: p), distance: d) - } - } - - var items = captured - if let nearestBG, nearestBG.distance <= BGChartConfig.selectionTolerance { - items.append(nearestBG) - } - if let primary = items.min(by: { $0.distance < $1.distance }) { - let texts = items.map(\.text) + bandPillTexts(at: selected) - return SelectionAnchor(date: primary.date, value: primary.value, texts: texts) - } - - // Nothing under the finger. Reach for the nearest treatment (data gaps - // leave treatments without BG neighbors), then for a band (any height) - // at the scrub time. - if let nearestTreatment, nearestTreatment.distance <= BGChartConfig.selectionTolerance { - let texts = [nearestTreatment.text] + bandPillTexts(at: selected) - return SelectionAnchor(date: nearestTreatment.date, value: nearestTreatment.value, texts: texts) - } - for band in model.overrides where selected >= band.start && selected <= band.end { - let midY = (band.yTop + band.yBottom) / 2 - return SelectionAnchor(date: selected, value: midY, texts: [band.pillText]) - } - for band in model.tempTargets where selected >= band.start && selected <= band.end { - let midY = (band.yTop + band.yBottom) / 2 - return SelectionAnchor(date: selected, value: midY, texts: [band.pillText]) - } - - return nil + guard let value else { return nil } + return SelectionAnchor(date: mark, value: value, texts: texts) } /// Tap hit test (screen-space, 2D). Treatments take priority, then BG @@ -829,7 +788,7 @@ private struct MainBGChart: View { } } - forEachTreatmentAnchor(consider) + forEachTreatmentAnchor { consider($0.drawnDate, $0.sgv, $0.pillText) } if best == nil { for p in model.bg { consider(p.date, p.value, bgPillText(for: p)) @@ -854,9 +813,9 @@ private struct MainBGChart: View { } /// The anchor the overlay should show: a live scrub wins over a sticky tap. - private func activeAnchor(viewportWidth: CGFloat) -> SelectionAnchor? { + private func activeAnchor() -> SelectionAnchor? { if isInspectLatched, let selected = selection { - return selectionAnchor(for: selected, captureWindow: scrubCaptureWindow(viewportWidth: viewportWidth)) + return selectionAnchor(for: selected) } return tapped } @@ -922,7 +881,7 @@ private struct MainBGChart: View { /// there is no manual line splitting. @ViewBuilder private func selectionOverlay(viewportWidth: CGFloat) -> some View { - if plotFrame.height > 0, let anchor = activeAnchor(viewportWidth: viewportWidth) { + if plotFrame.height > 0, let anchor = activeAnchor() { let x = xPosition(for: anchor.date, viewportWidth: viewportWidth) if x >= 0, x <= viewportWidth { let y = yPosition(forValue: anchor.value) diff --git a/Tests/Charts/BGChartScrubSlotsTests.swift b/Tests/Charts/BGChartScrubSlotsTests.swift new file mode 100644 index 000000000..cb4b90360 --- /dev/null +++ b/Tests/Charts/BGChartScrubSlotsTests.swift @@ -0,0 +1,156 @@ +// LoopFollow +// BGChartScrubSlotsTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct BGChartScrubSlotsTests { + private let origin = Date(timeIntervalSince1970: 1_700_000_000) + + private func at(_ minutes: Double) -> Date { + origin.addingTimeInterval(minutes * 60) + } + + private func minutes(_ date: Date) -> Double { + date.timeIntervalSince(origin) / 60 + } + + private func slot(_ slot: BGChartScrubSlots.Slot, isAt minute: Double) -> Bool { + abs(minutes(slot.date) - minute) < 0.001 + } + + @Test("five-minute readings are marks that own the time up to the midpoints") + func denseReadingBlocks() { + let slots = BGChartScrubSlots(readingDates: [0, 5, 10, 15].map(at)) + + let second = slots.slot(containing: at(6)) + #expect(second.readingIndex == 1) + #expect(second.date == at(5)) + #expect(minutes(second.blockStart) == 2.5) + #expect(minutes(second.blockEnd) == 7.5) + + #expect(slots.slot(containing: at(7.4)).readingIndex == 1) + #expect(slots.slot(containing: at(7.5)).readingIndex == 2) + } + + @Test("one-minute readings snap to every fifth reading, anchored at the newest") + func oneMinuteDataSnapsToFiveMinutes() { + let slots = BGChartScrubSlots(readingDates: stride(from: 0.0, through: 22.0, by: 1.0).map(at)) + + #expect(slot(slots.slot(containing: at(22)), isAt: 22)) + #expect(slot(slots.slot(containing: at(16)), isAt: 17)) + #expect(slot(slots.slot(containing: at(13)), isAt: 12)) + #expect(slot(slots.slot(containing: at(1)), isAt: 2)) + + let seventeen = slots.slot(containing: at(16)) + #expect(seventeen.isReading) + #expect(minutes(seventeen.blockStart) == 14.5) + #expect(minutes(seventeen.blockEnd) == 19.5) + } + + @Test("sensor drift keeps every reading on the grid") + func driftFollowsReadings() { + let slots = BGChartScrubSlots(readingDates: [0, 5.1, 10.2, 15.3, 20.4].map(at)) + for (index, minute) in [0, 5.1, 10.2, 15.3, 20.4].enumerated() { + let mark = slots.slot(containing: at(minute)) + #expect(mark.readingIndex == index) + #expect(slot(mark, isAt: minute)) + } + } + + @Test("a straggler close to a grid reading joins its block") + func stragglerJoinsBlock() { + let slots = BGChartScrubSlots(readingDates: [0, 5, 5.2, 10].map(at)) + + let mark = slots.slot(containing: at(5.2)) + #expect(mark.date == at(5)) + #expect(mark.contains(at(5.2))) + #expect(slots.slot(containing: at(10)).date == at(10)) + } + + @Test("a gap is crossed at exactly the cadence from the reading that ends it") + func gapUsesVirtualCadence() { + let slots = BGChartScrubSlots(readingDates: [0, 5, 10, 33, 38, 43].map(at)) + + for minute in [18.0, 23.0, 28.0] { + let mark = slots.slot(containing: at(minute + 1)) + #expect(slot(mark, isAt: minute)) + #expect(mark.readingIndex == nil) + #expect(mark.blockEnd.timeIntervalSince(mark.blockStart) == 300) + } + + // The virtual mark next to the reading that resumes the data shares + // the leftover with it at the midpoint. + let edge = slots.slot(containing: at(14)) + #expect(slot(edge, isAt: 13)) + #expect(minutes(edge.blockStart) == 11.5) + #expect(minutes(edge.blockEnd) == 15.5) + + let resumed = slots.slot(containing: at(9)) + #expect(resumed.readingIndex == 2) + #expect(minutes(resumed.blockStart) == 7.5) + #expect(minutes(resumed.blockEnd) == 11.5) + } + + @Test("a reading just off the grid is used instead of a virtual mark") + func nearReadingBeatsVirtualMark() { + let slots = BGChartScrubSlots(readingDates: [0, 7, 30, 35].map(at)) + + #expect(slot(slots.slot(containing: at(24)), isAt: 25)) + #expect(slot(slots.slot(containing: at(19)), isAt: 20)) + #expect(slot(slots.slot(containing: at(14)), isAt: 15)) + #expect(slot(slots.slot(containing: at(11)), isAt: 10)) + + let seven = slots.slot(containing: at(6)) + #expect(seven.readingIndex == 1) + #expect(slot(seven, isAt: 7)) + + #expect(slots.slot(containing: at(3)).readingIndex == 0) + } + + @Test("the grid continues at the cadence beyond the first and last marks") + func gridExtendsBeyondReadings() { + let slots = BGChartScrubSlots(readingDates: [0, 5, 10].map(at)) + + let last = slots.slot(containing: at(12)) + #expect(last.readingIndex == 2) + #expect(minutes(last.blockEnd) == 12.5) + + let future = slots.slot(containing: at(23)) + #expect(future.readingIndex == nil) + #expect(slot(future, isAt: 25)) + + let past = slots.slot(containing: at(-9)) + #expect(past.readingIndex == nil) + #expect(slot(past, isAt: -10)) + } + + @Test("without readings every instant still resolves to a virtual mark") + func noReadingsUsesVirtualGrid() { + let slots = BGChartScrubSlots(readingDates: []) + let mark = slots.slot(containing: at(7)) + #expect(mark.readingIndex == nil) + #expect(mark.contains(at(7))) + #expect(mark.blockEnd.timeIntervalSince(mark.blockStart) == 300) + } + + @Test("blocks tile the timeline with no overlaps or holes") + func blocksTileTimeline() { + let slots = BGChartScrubSlots(readingDates: [0, 1, 2, 5, 7, 10, 33, 38, 43, 60].map(at)) + var previous: BGChartScrubSlots.Slot? + + for tenth in stride(from: -20.0, through: 80.0, by: 0.1) { + let probe = at(tenth) + let mark = slots.slot(containing: probe) + #expect(mark.contains(probe), "\(tenth) min not inside its own block") + if let previous, previous != mark { + #expect(abs(mark.blockStart.timeIntervalSince(previous.blockEnd)) < 1e-6, "hole or overlap at \(tenth) min") + #expect(previous.date < mark.date) + let spacing = mark.date.timeIntervalSince(previous.date) + #expect(spacing >= 150 && spacing <= 450, "mark spacing \(spacing) s at \(tenth) min") + } + previous = mark + } + } +} From 9f6bfea03142d6b9d45a0504b8d2cc59105fb6ee Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 20 Sep 2026 16:58:13 +0000 Subject: [PATCH 22/25] CI: Bump dev version to 7.0.11 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 05f1ba1b4..a68946937 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.0.10 +LOOP_FOLLOW_MARKETING_VERSION = 7.0.11 From 942ceadbf646ce5683420f3af42f41d24598c514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 20 Sep 2026 19:03:13 +0200 Subject: [PATCH 23/25] Zip log files before sharing (#747) Share Logs now bundles the notice file and the log files into a single zip archive before opening the share sheet. The archive is created with NSFileCoordinator, so no new dependency is needed. If zipping fails the files are shared uncompressed as before. --- LoopFollow/Log/LogArchiver.swift | 36 +++++++++++++++++++ LoopFollow/ViewControllers/MoreMenuView.swift | 30 +++++++++++----- 2 files changed, 57 insertions(+), 9 deletions(-) create mode 100644 LoopFollow/Log/LogArchiver.swift diff --git a/LoopFollow/Log/LogArchiver.swift b/LoopFollow/Log/LogArchiver.swift new file mode 100644 index 000000000..a68d61f89 --- /dev/null +++ b/LoopFollow/Log/LogArchiver.swift @@ -0,0 +1,36 @@ +// LoopFollow +// LogArchiver.swift + +import Foundation + +enum LogArchiver { + /// Copies `files` into a folder named `archiveName` and returns a zip archive + /// of that folder in the temporary directory. + static func zip(files: [URL], archiveName: String) throws -> URL { + let fileManager = FileManager.default + let staging = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let folder = staging.appendingPathComponent(archiveName, isDirectory: true) + try fileManager.createDirectory(at: folder, withIntermediateDirectories: true) + defer { try? fileManager.removeItem(at: staging) } + + for file in files { + try fileManager.copyItem(at: file, to: folder.appendingPathComponent(file.lastPathComponent)) + } + + let destination = fileManager.temporaryDirectory.appendingPathComponent("\(archiveName).zip") + try? fileManager.removeItem(at: destination) + + var coordinationError: NSError? + var copyError: Error? + NSFileCoordinator().coordinate(readingItemAt: folder, options: .forUploading, error: &coordinationError) { zipURL in + do { + try fileManager.copyItem(at: zipURL, to: destination) + } catch { + copyError = error + } + } + if let coordinationError { throw coordinationError } + if let copyError { throw copyError } + return destination + } +} diff --git a/LoopFollow/ViewControllers/MoreMenuView.swift b/LoopFollow/ViewControllers/MoreMenuView.swift index 31f3b3915..ed1c8f360 100644 --- a/LoopFollow/ViewControllers/MoreMenuView.swift +++ b/LoopFollow/ViewControllers/MoreMenuView.swift @@ -287,19 +287,31 @@ struct MoreMenuView: View { } private func presentLogShareSheet(noticeText: String, logFiles: [URL]) { - var items: [Any] = logFiles - if let noticeURL = writeShareNoticeFile(text: noticeText) { - items.insert(noticeURL, at: 0) - } - let avc = UIActivityViewController(activityItems: items, applicationActivities: nil) - UIApplication.shared.topMost?.present(avc, animated: true) - } - - private func writeShareNoticeFile(text: String) -> URL? { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd_HHmm" let timestamp = formatter.string(from: Date()) + var files = logFiles + if let noticeURL = writeShareNoticeFile(text: noticeText, timestamp: timestamp) { + files.insert(noticeURL, at: 0) + } + + DispatchQueue.global(qos: .userInitiated).async { + let items: [Any] + do { + items = try [LogArchiver.zip(files: files, archiveName: "LoopFollow Logs \(timestamp)")] + } catch { + LogManager.shared.log(category: .general, message: "Failed to zip log files, sharing them uncompressed: \(error)") + items = files + } + DispatchQueue.main.async { + let avc = UIActivityViewController(activityItems: items, applicationActivities: nil) + UIApplication.shared.topMost?.present(avc, animated: true) + } + } + } + + private func writeShareNoticeFile(text: String, timestamp: String) -> URL? { let version = AppVersionManager().version() let branchAndSha = BuildDetails.default.branchAndSha From 3f68dc8c3c7b40f50289e61475eeaa10679607a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 20 Sep 2026 17:03:23 +0000 Subject: [PATCH 24/25] CI: Bump dev version to 7.0.12 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index a68946937..6efb5d1ec 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.0.11 +LOOP_FOLLOW_MARKETING_VERSION = 7.0.12 From 95db2aafd118bbfeb73bb428445e98e6be13631b Mon Sep 17 00:00:00 2001 From: marionbarker Date: Sun, 20 Sep 2026 10:20:35 -0700 Subject: [PATCH 25/25] update version to 7.1.0 --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 6efb5d1ec..04259f731 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.0.12 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.0