From cd43a116ac2abbe6f088f7c06b91494aa0391280 Mon Sep 17 00:00:00 2001 From: Justin Maier Date: Sun, 12 Jul 2026 12:15:27 -0600 Subject: [PATCH 01/13] Add Stop Live Activity shortcut intent for Focus automations --- .../RestartLiveActivityIntent.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift index 00740e10e..0838f2d11 100644 --- a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift +++ b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift @@ -27,6 +27,19 @@ struct RestartLiveActivityIntent: AppIntent { } } +struct StopLiveActivityIntent: AppIntent { + static var title: LocalizedStringResource = "Stop Live Activity" + static var description = IntentDescription("Ends the LoopFollow Live Activity and keeps it off until restarted.") + + func perform() async throws -> some IntentResult & ProvidesDialog { + Storage.shared.laEnabled.value = false + + await MainActor.run { LiveActivityManager.shared.end(dismissalPolicy: .immediate) } + + return .result(dialog: "Live Activity stopped.") + } +} + struct LoopFollowAppShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { AppShortcut( @@ -35,5 +48,11 @@ struct LoopFollowAppShortcuts: AppShortcutsProvider { shortTitle: "Restart Live Activity", systemImageName: "dot.radiowaves.left.and.right", ) + AppShortcut( + intent: StopLiveActivityIntent(), + phrases: ["Stop Live Activity in \(.applicationName)"], + shortTitle: "Stop Live Activity", + systemImageName: "stop.circle", + ) } } From f62135b7ce2c4ec88d7482e1075c4ae7de4d69e1 Mon Sep 17 00:00:00 2001 From: Justin Maier Date: Sun, 12 Jul 2026 12:35:44 -0600 Subject: [PATCH 02/13] Move Stop Live Activity intent to the compiled intent file The repo has two copies of RestartLiveActivityIntent.swift; only the root-level one is referenced by the Xcode project. The previous commit patched the orphaned copy under LoopFollow/LiveActivity/, so the intent never shipped. Apply the patch to the compiled file and restore the orphan to its upstream state. --- .../RestartLiveActivityIntent.swift | 19 ------------------ RestartLiveActivityIntent.swift | 20 +++++++++++++++++++ 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift index 0838f2d11..00740e10e 100644 --- a/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift +++ b/LoopFollow/LiveActivity/RestartLiveActivityIntent.swift @@ -27,19 +27,6 @@ struct RestartLiveActivityIntent: AppIntent { } } -struct StopLiveActivityIntent: AppIntent { - static var title: LocalizedStringResource = "Stop Live Activity" - static var description = IntentDescription("Ends the LoopFollow Live Activity and keeps it off until restarted.") - - func perform() async throws -> some IntentResult & ProvidesDialog { - Storage.shared.laEnabled.value = false - - await MainActor.run { LiveActivityManager.shared.end(dismissalPolicy: .immediate) } - - return .result(dialog: "Live Activity stopped.") - } -} - struct LoopFollowAppShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { AppShortcut( @@ -48,11 +35,5 @@ struct LoopFollowAppShortcuts: AppShortcutsProvider { shortTitle: "Restart Live Activity", systemImageName: "dot.radiowaves.left.and.right", ) - AppShortcut( - intent: StopLiveActivityIntent(), - phrases: ["Stop Live Activity in \(.applicationName)"], - shortTitle: "Stop Live Activity", - systemImageName: "stop.circle", - ) } } diff --git a/RestartLiveActivityIntent.swift b/RestartLiveActivityIntent.swift index 95563dafc..b4d108a63 100644 --- a/RestartLiveActivityIntent.swift +++ b/RestartLiveActivityIntent.swift @@ -33,6 +33,20 @@ } } + @available(iOS 16.4, *) + struct StopLiveActivityIntent: AppIntent { + static var title: LocalizedStringResource = "Stop Live Activity" + static var description = IntentDescription("Ends the LoopFollow Live Activity and keeps it off until restarted.") + + func perform() async throws -> some IntentResult & ProvidesDialog { + Storage.shared.laEnabled.value = false + + await MainActor.run { LiveActivityManager.shared.end(dismissalPolicy: .immediate) } + + return .result(dialog: "Live Activity stopped.") + } + } + @available(iOS 16.4, *) struct LoopFollowAppShortcuts: AppShortcutsProvider { static var appShortcuts: [AppShortcut] { @@ -42,6 +56,12 @@ shortTitle: "Restart Live Activity", systemImageName: "dot.radiowaves.left.and.right" ) + AppShortcut( + intent: StopLiveActivityIntent(), + phrases: ["Stop Live Activity in \(.applicationName)"], + shortTitle: "Stop Live Activity", + systemImageName: "stop.circle" + ) } } #endif From 6ddd6413dfefc1cc9b922b35f35fcea02b5d1a88 Mon Sep 17 00:00:00 2001 From: Justin Maier Date: Sun, 12 Jul 2026 14:50:00 -0600 Subject: [PATCH 03/13] Add "use Trio's recommended bolus" option to remote meal Adds a toggle to the meal screen that asks Trio to calculate the bolus with its own bolus calculator instead of sending a manual amount. Enabling it hides and clears the manual bolus field, shows a caption explaining Trio computes the dose from its current glucose, IOB, and COB under its own safety limits, requires authentication like a manual bolus, and sends the new use_recommended_bolus payload flag. Mutually exclusive with a manual bolus amount. Because Trio only auto-boluses a meal timed for now, the toggle is disabled and cleared while the meal is scheduled for later. --- LoopFollow/Remote/TRC/MealView.swift | 56 ++++++++++++++----- LoopFollow/Remote/TRC/PushMessage.swift | 2 + .../Remote/TRC/PushNotificationManager.swift | 2 + 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/LoopFollow/Remote/TRC/MealView.swift b/LoopFollow/Remote/TRC/MealView.swift index 5938735d1..c959aaf66 100644 --- a/LoopFollow/Remote/TRC/MealView.swift +++ b/LoopFollow/Remote/TRC/MealView.swift @@ -11,6 +11,7 @@ struct MealView: View { @State private var protein = HKQuantity(unit: .gram(), doubleValue: 0.0) @State private var fat = HKQuantity(unit: .gram(), doubleValue: 0.0) @State private var bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) + @State private var useRecommendedBolus: Bool = false private let pushNotificationManager = PushNotificationManager() @@ -142,23 +143,44 @@ struct MealView: View { } if mealWithBolus.value { - HKQuantityInputView( - label: "Bolus Amount", - quantity: $bolusAmount, - unit: .internationalUnit(), - maxLength: 4, - minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0), - maxValue: maxBolus.value, - isFocused: $bolusFieldIsFocused, - onValidationError: { message in - handleValidationError(message) + Toggle("Use Trio's recommended bolus", isOn: $useRecommendedBolus) + .disabled(isScheduling) + .onChange(of: useRecommendedBolus) { _ in + if useRecommendedBolus { + bolusFieldIsFocused = false + bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) + } } - ) + + if useRecommendedBolus { + Text("Trio will calculate the dose from its current glucose, IOB, and COB using its own bolus calculator and safety limits. This requires \"Auto-bolus for Remote Meals\" to be enabled on the patient's Trio app.") + .font(.footnote) + .foregroundColor(.secondary) + } else { + HKQuantityInputView( + label: "Bolus Amount", + quantity: $bolusAmount, + unit: .internationalUnit(), + maxLength: 4, + minValue: HKQuantity(unit: .internationalUnit(), doubleValue: 0), + maxValue: maxBolus.value, + isFocused: $bolusFieldIsFocused, + onValidationError: { message in + handleValidationError(message) + } + ) + } } } Section(header: Text("Schedule")) { Toggle("Schedule for later", isOn: $isScheduling) + .onChange(of: isScheduling) { _ in + // Trio only auto-boluses a meal timed for now, so clear the recommendation while scheduling. + if isScheduling { + useRecommendedBolus = false + } + } if isScheduling { DatePicker( "Select Time", @@ -220,6 +242,7 @@ struct MealView: View { .onAppear { selectedTime = nil isScheduling = false + useRecommendedBolus = false quickPickMeals.refresh( maxCarbs: maxCarbs.value.doubleValue(for: .gram()), @@ -265,12 +288,16 @@ struct MealView: View { message += String(format: "\nBolus: %.2f U", bolusAmount) } + if useRecommendedBolus { + message += "\nBolus: Trio's recommended amount" + } + return Alert( title: Text("Confirm Meal"), message: Text(message), primaryButton: .default(Text("Confirm"), action: { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if bolusAmount > 0 { + if bolusAmount > 0 || useRecommendedBolus { AuthService.authenticate(reason: "Confirm your identity to send bolus.") { result in DispatchQueue.main.async { switch result { @@ -348,7 +375,8 @@ struct MealView: View { protein: protein, fat: fat, bolusAmount: bolusAmount, - scheduledTime: scheduledDate + scheduledTime: scheduledDate, + useRecommendedBolus: useRecommendedBolus ) { success, errorMessage in DispatchQueue.main.async { isLoading = false @@ -372,6 +400,8 @@ struct MealView: View { carbs = HKQuantity(unit: .gram(), doubleValue: 0.0) protein = HKQuantity(unit: .gram(), doubleValue: 0.0) fat = HKQuantity(unit: .gram(), doubleValue: 0.0) + bolusAmount = HKQuantity(unit: .internationalUnit(), doubleValue: 0.0) + useRecommendedBolus = false selectedTime = nil isScheduling = false alertType = .statusSuccess diff --git a/LoopFollow/Remote/TRC/PushMessage.swift b/LoopFollow/Remote/TRC/PushMessage.swift index 09ea4e817..3e2c2d71e 100644 --- a/LoopFollow/Remote/TRC/PushMessage.swift +++ b/LoopFollow/Remote/TRC/PushMessage.swift @@ -43,6 +43,7 @@ struct CommandPayload: Encodable { var fat: Int? var overrideName: String? var scheduledTime: TimeInterval? + var useRecommendedBolus: Bool? var returnNotification: ReturnNotificationInfo? struct ReturnNotificationInfo: Encodable { @@ -75,6 +76,7 @@ struct CommandPayload: Encodable { case fat case overrideName case scheduledTime = "scheduled_time" + case useRecommendedBolus = "use_recommended_bolus" case returnNotification = "return_notification" } } diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index aa1f661a2..e4ed334a6 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -130,6 +130,7 @@ class PushNotificationManager { fat: HKQuantity, bolusAmount: HKQuantity, scheduledTime: Date?, + useRecommendedBolus: Bool, completion: @escaping (Bool, String?) -> Void ) { func convertToOptionalInt(_ quantity: HKQuantity) -> Int? { @@ -159,6 +160,7 @@ class PushNotificationManager { protein: proteinValue, fat: fatValue, scheduledTime: scheduledTimeInterval, + useRecommendedBolus: useRecommendedBolus ? true : nil, returnNotification: createReturnNotificationInfo() ) sendEncryptedCommand(payload: payload, completion: completion) From b382d7c1903446e4e7cb18740c23f7ac6415d8a5 Mon Sep 17 00:00:00 2001 From: Justin Maier Date: Wed, 15 Jul 2026 16:31:21 -0600 Subject: [PATCH 04/13] Review Trio's recommended bolus from a notification When Trio's Remote Meal Bolus setting is Require Review, Trio sends the recommended amount back instead of dosing. Handle that response by opening the bolus screen pre-filled for the caregiver to confirm through the normal Face ID and guardrail path, so nothing is dosed from the notification itself. A TRIO_RECOMMENDED_BOLUS category with a Review action drives the pre-filled sheet, the recommendation is gated on its age (fresh under 5 min, a warning to 12 min, expired after), and the delivered notification is cleared once acted on or expired so it can't be confirmed twice. --- LoopFollow/Alarm/AlarmManager.swift | 3 +- LoopFollow/Application/AppDelegate.swift | 79 +++++++++++++++++++++++- LoopFollow/Application/MainTabView.swift | 12 ++++ LoopFollow/Remote/TRC/BolusView.swift | 55 +++++++++++++++++ LoopFollow/Remote/TRC/MealView.swift | 2 +- LoopFollow/Storage/Observable.swift | 13 ++++ 6 files changed, 159 insertions(+), 5 deletions(-) diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index 3f5aa84ec..5743d0110 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -194,7 +194,8 @@ class AlarmManager { if let actionTitle = actionTitle { let action = UNNotificationAction(identifier: "snooze", title: actionTitle, options: []) let category = UNNotificationCategory(identifier: "category", actions: [action], intentIdentifiers: [], options: []) - UNUserNotificationCenter.current().setNotificationCategories([category]) + // setNotificationCategories replaces the whole set, so include the base categories too. + UNUserNotificationCenter.current().setNotificationCategories(Set(AppDelegate.baseNotificationCategories() + [category])) } } } diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 6c9d8e884..039009dde 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -9,6 +9,53 @@ import UserNotifications class AppDelegate: UIResponder, UIApplicationDelegate { let notificationCenter = UNUserNotificationCenter.current() + /// Category Trio sets on a "recommended bolus" response; must match Trio's identifier. + static let recommendedBolusCategoryIdentifier = "TRIO_RECOMMENDED_BOLUS" + /// Action on that category that opens the bolus screen pre-filled for review. + static let reviewBolusActionIdentifier = "REVIEW_BOLUS" + + /// The app's full set of notification categories. Single source of truth so every caller of + /// setNotificationCategories (which replaces the whole set) registers all of them — otherwise a caller + /// that sets only its own category silently de-registers the others. + static func baseNotificationCategories() -> [UNNotificationCategory] { + let openAction = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground) + let backgroundCategory = UNNotificationCategory( + identifier: BackgroundAlertIdentifier.categoryIdentifier, + actions: [openAction], + intentIdentifiers: [], + options: [] + ) + + // Category for a Trio "recommended bolus" response. The Review action (and tapping the body) opens + // the bolus screen pre-filled; .authenticationRequired keeps it behind device unlock. + let reviewAction = UNNotificationAction( + identifier: reviewBolusActionIdentifier, + title: "Review", + options: [.foreground, .authenticationRequired] + ) + let recommendedBolusCategory = UNNotificationCategory( + identifier: recommendedBolusCategoryIdentifier, + actions: [reviewAction], + intentIdentifiers: [], + options: [] + ) + + return [backgroundCategory, recommendedBolusCategory] + } + + /// Removes any delivered "recommended bolus" notifications so a recommendation can't be tapped or + /// confirmed twice from Notification Center after it has been acted on or has expired. + static func removeDeliveredRecommendedBolusNotifications() { + UNUserNotificationCenter.current().getDeliveredNotifications { notifications in + let identifiers = notifications + .filter { $0.request.content.categoryIdentifier == recommendedBolusCategoryIdentifier } + .map { $0.request.identifier } + if !identifiers.isEmpty { + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) + } + } + } + func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { LogManager.shared.log(category: .general, message: "App started") LogManager.shared.cleanupOldLogs() @@ -29,9 +76,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - let action = UNNotificationAction(identifier: "OPEN_APP_ACTION", title: "Open App", options: .foreground) - let category = UNNotificationCategory(identifier: BackgroundAlertIdentifier.categoryIdentifier, actions: [action], intentIdentifiers: [], options: []) - UNUserNotificationCenter.current().setNotificationCategories([category]) + UNUserNotificationCenter.current().setNotificationCategories(Set(AppDelegate.baseNotificationCategories())) UNUserNotificationCenter.current().delegate = self @@ -191,9 +236,37 @@ class AppDelegate: UIResponder, UIApplicationDelegate { AlarmManager.shared.performSnooze() } + // A Trio "recommended bolus" review: tapping the body or the Review action opens the bolus screen + // pre-filled; the user still confirms via Face ID and the guardrails, so nothing is dosed here. + if response.actionIdentifier == AppDelegate.reviewBolusActionIdentifier + || response.actionIdentifier == UNNotificationDefaultActionIdentifier + { + if let request = AppDelegate.reviewBolusRequest(from: response.notification.request.content.userInfo) { + // Presented from the tab root; MainTabView defers behind the first-launch consent sheet. + DispatchQueue.main.async { + Observable.shared.pendingReviewBolus.value = request + } + } + } + completionHandler() } + /// Builds a review request from Trio's structured `recommended_bolus` field (units) and the response's + /// `timestamp` (send time). APNs delivers JSON numbers as NSNumber; strings are accepted defensively. + /// Returns nil when the amount is absent or not positive. + static func reviewBolusRequest(from userInfo: [AnyHashable: Any]) -> ReviewBolusRequest? { + func double(_ value: Any?) -> Double? { + if let number = value as? NSNumber { return number.doubleValue } + if let string = value as? String { return Double(string) } + return nil + } + + guard let amount = double(userInfo["recommended_bolus"]), amount > 0 else { return nil } + let sentAt = double(userInfo["timestamp"]) ?? Date().timeIntervalSince1970 + return ReviewBolusRequest(amount: amount, sentAt: sentAt) + } + func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask { let forcePortrait = Storage.shared.forcePortraitMode.value diff --git a/LoopFollow/Application/MainTabView.swift b/LoopFollow/Application/MainTabView.swift index 140204358..c46949524 100644 --- a/LoopFollow/Application/MainTabView.swift +++ b/LoopFollow/Application/MainTabView.swift @@ -14,6 +14,8 @@ struct MainTabView: View { @ObservedObject private var statisticsPosition = Storage.shared.statisticsPosition @ObservedObject private var treatmentsPosition = Storage.shared.treatmentsPosition + @ObservedObject private var pendingReviewBolus = Observable.shared.pendingReviewBolus + @State private var showTelemetryConsent = false private var orderedItems: [TabItem] { @@ -60,6 +62,16 @@ struct MainTabView: View { TelemetryConsentView() .interactiveDismissDisabled(true) } + // Presented from the tab root when the user taps a Trio review notification, gated behind the consent + // sheet so they never contend at first launch. + .sheet(isPresented: Binding( + get: { pendingReviewBolus.value != nil && !showTelemetryConsent }, + set: { if !$0 { pendingReviewBolus.value = nil } } + )) { + if let request = pendingReviewBolus.value { + BolusView(reviewRequest: request) + } + } } @ViewBuilder diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index 7a59c87de..14b3fd25d 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -25,6 +25,26 @@ struct BolusView: View { private let pushNotificationManager = PushNotificationManager() + /// Trio's recommended bolus awaiting review, when this screen was opened from a review notification. + private let reviewRequest: ReviewBolusRequest? + + // Review-recommendation staleness, mirroring the deviceRecBolus age semantics: warn at 5 min, expire at 12. + private static let reviewWarnAge: TimeInterval = 5 * 60 + private static let reviewExpiredAge: TimeInterval = 12 * 60 + + private static func reviewAge(sentAt: TimeInterval) -> TimeInterval { + max(0, Date().timeIntervalSince1970 - sentAt) + } + + init(reviewRequest: ReviewBolusRequest? = nil) { + self.reviewRequest = reviewRequest + if let reviewRequest, Self.reviewAge(sentAt: reviewRequest.sentAt) < Self.reviewExpiredAge { + let maxU = Storage.shared.maxBolus.value.doubleValue(for: .internationalUnit()) + let clamped = max(0, min(reviewRequest.amount, maxU)) + _bolusAmount = State(initialValue: HKQuantity(unit: .internationalUnit(), doubleValue: clamped)) + } + } + enum AlertType { case confirmBolus case statusSuccess @@ -68,6 +88,8 @@ struct BolusView: View { Form { recommendedBlocks(now: context.date) + reviewStalenessBlock(now: context.date) + if !quickPickBoluses.quickPickBoluses.isEmpty { Section(header: QuickPickSectionHeader(title: "Quick-Pick Boluses", infoText: QuickPickSectionHeader.bolusInfoText)) { ScrollView(.horizontal, showsIndicators: false) { @@ -148,6 +170,11 @@ struct BolusView: View { stepIncrement: stepU, maxBolus: maxBolus.value.doubleValue(for: .internationalUnit()) ) + + // Drop the delivered notification for an expired recommendation so it can't be re-tapped. + if let reviewRequest, Self.reviewAge(sentAt: reviewRequest.sentAt) >= Self.reviewExpiredAge { + AppDelegate.removeDeliveredRecommendedBolusNotifications() + } } .alert(isPresented: $showAlert) { switch alertType { @@ -275,6 +302,30 @@ struct BolusView: View { } } + /// Age warning for a review recommendation, mirroring the deviceRecBolus warning styling. + @ViewBuilder + private func reviewStalenessBlock(now: Date) -> some View { + if let reviewRequest { + let ageSec = max(0, now.timeIntervalSince1970 - reviewRequest.sentAt) + + if ageSec >= Self.reviewExpiredAge { + Section { + Text("This recommended bolus expired (calculated \(presentableMinutesFormat(timeInterval: ageSec)) ago). Re-send the meal from your app to get a fresh recommendation.") + .font(.callout) + .foregroundColor(.red) + .multilineTextAlignment(.leading) + } + } else if ageSec >= Self.reviewWarnAge { + Section { + Text("WARNING: This recommended bolus was calculated \(presentableMinutesFormat(timeInterval: ageSec)) ago. New treatments may have occurred since then; review before sending.") + .font(.callout) + .foregroundColor(.red) + .multilineTextAlignment(.leading) + } + } + } + } + private func handleRecommendedBolusTap(rec: Double, ageSec: TimeInterval) { let isStale5 = ageSec >= 5 * 60 let isStale12 = ageSec >= 12 * 60 @@ -322,6 +373,10 @@ struct BolusView: View { if sentUnits > 0 { QuickPickBolusesManager.shared.recordBolus(units: sentUnits) } + // Drop the delivered notification once acted on so it can't be confirmed again. + if reviewRequest != nil { + AppDelegate.removeDeliveredRecommendedBolusNotifications() + } statusMessage = "Bolus command sent successfully." LogManager.shared.log( category: .apns, diff --git a/LoopFollow/Remote/TRC/MealView.swift b/LoopFollow/Remote/TRC/MealView.swift index c959aaf66..4c0d66c67 100644 --- a/LoopFollow/Remote/TRC/MealView.swift +++ b/LoopFollow/Remote/TRC/MealView.swift @@ -153,7 +153,7 @@ struct MealView: View { } if useRecommendedBolus { - Text("Trio will calculate the dose from its current glucose, IOB, and COB using its own bolus calculator and safety limits. This requires \"Auto-bolus for Remote Meals\" to be enabled on the patient's Trio app.") + Text("Trio will calculate the dose from its current glucose, IOB, and COB using its own bolus calculator and safety limits. The patient's Trio Remote Meal Bolus setting decides whether Trio doses it automatically or sends it back here for you to review and confirm. This requires that setting to be enabled (Require Review or Auto) on the patient's Trio app.") .font(.footnote) .foregroundColor(.secondary) } else { diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 4128918db..1b1f8afb9 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -53,5 +53,18 @@ class Observable { /// Selected tab index used by SwiftUI TabView — set from MainViewController to switch tabs var selectedTabIndex = ObservableValue(default: 0) + /// Set when the user taps a Trio "recommended bolus" review notification. Drives a pre-filled bolus + /// sheet; cleared when the sheet is dismissed. + var pendingReviewBolus = ObservableValue(default: nil) + private init() {} } + +/// A recommended bolus from Trio awaiting the caregiver's review, carried from the notification tap to the +/// pre-filled bolus sheet. +struct ReviewBolusRequest: Equatable { + /// Recommended amount in units of insulin. + let amount: Double + /// The notification's send time (seconds since 1970), used to gate on staleness. + let sentAt: TimeInterval +} From edcace2b4166deb020fc5468e473e2e8c2abca18 Mon Sep 17 00:00:00 2001 From: Justin Maier Date: Mon, 27 Jul 2026 16:30:26 -0600 Subject: [PATCH 05/13] Share the Live Activity's glucose formatting and snapshot store The per-metric formatters and the slot value lookup were private to the Live Activity view file, so neither could be reused by another surface. Move both alongside the rest of the shared Live Activity types and list them into the extension target as well, so any surface can render the same strings from the published snapshot. The formatter move is verbatim; only the access level changed. One fix rides along: the timestamp formatter pinned a literal "HH:mm" dateFormat, which overrides locale conventions and forced a 24 hour clock whatever the device was set to. Using a short time style follows the 24-Hour Time setting instead. The snapshot store's save now takes an optional completion, so a caller that has to act on the written file can wait for it. --- LoopFollow.xcodeproj/project.pbxproj | 6 + .../LiveActivity/GlucoseSlotFormat.swift | 222 ++++++++++++++++++ .../LiveActivity/GlucoseSnapshotStore.swift | 6 +- .../LoopFollowLiveActivity.swift | 214 ----------------- 4 files changed, 233 insertions(+), 215 deletions(-) create mode 100644 LoopFollow/LiveActivity/GlucoseSlotFormat.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index b162633e0..29d51a0da 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -13,6 +13,8 @@ B500000000000000000000B4 /* QuickPickMealsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000B3 /* QuickPickMealsManager.swift */; }; 2D8068C66833EEAED7B4BEB8 /* FutureCarbsCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */; }; 374A77992F5BD8B200E96858 /* APNSClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77982F5BD8AB00E96858 /* APNSClient.swift */; }; + 30EBBFA8E833254472B5A8E7 /* GlucoseSlotFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A30D82A0271B22651F843673 /* GlucoseSlotFormat.swift */; }; + DE4C84A4F8C5789D10A5B2B9 /* GlucoseSlotFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A30D82A0271B22651F843673 /* GlucoseSlotFormat.swift */; }; 374A77A52F5BE17000E96858 /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; 374A77A62F5BE17000E96858 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; 374A77A72F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; @@ -483,6 +485,7 @@ 2B9BEC26E4E48EF9B811A372 /* PendingFutureCarb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PendingFutureCarb.swift; sourceTree = ""; }; 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsCondition.swift; sourceTree = ""; }; 374A77982F5BD8AB00E96858 /* APNSClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSClient.swift; sourceTree = ""; }; + A30D82A0271B22651F843673 /* GlucoseSlotFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSlotFormat.swift; sourceTree = ""; }; 374A779F2F5BE17000E96858 /* AppGroupID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupID.swift; sourceTree = ""; }; 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseLiveActivityAttributes.swift; sourceTree = ""; }; 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSnapshot.swift; sourceTree = ""; }; @@ -965,6 +968,7 @@ 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */, 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */, 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */, + A30D82A0271B22651F843673 /* GlucoseSlotFormat.swift */, 374A77982F5BD8AB00E96858 /* APNSClient.swift */, ); path = LiveActivity; @@ -2130,6 +2134,7 @@ 37E4DD112F7E0D35000511C8 /* LALivenessMarker.swift in Sources */, 374A77AC2F5BE17000E96858 /* LAAppGroupSettings.swift in Sources */, 374A77AD2F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift in Sources */, + DE4C84A4F8C5789D10A5B2B9 /* GlucoseSlotFormat.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2160,6 +2165,7 @@ 374A77B52F5BE1AC00E96858 /* GlucoseSnapshotBuilder.swift in Sources */, 374A77B72F5BE1AC00E96858 /* LiveActivityManager.swift in Sources */, 374A77B82F5BE1AC00E96858 /* GlucoseSnapshotStore.swift in Sources */, + 30EBBFA8E833254472B5A8E7 /* GlucoseSlotFormat.swift in Sources */, 374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */, DDE75D232DE5E505007C1FC1 /* Glyph.swift in Sources */, DD4878202C7DAF890048F05C /* PushMessage.swift in Sources */, diff --git a/LoopFollow/LiveActivity/GlucoseSlotFormat.swift b/LoopFollow/LiveActivity/GlucoseSlotFormat.swift new file mode 100644 index 000000000..455a6ce97 --- /dev/null +++ b/LoopFollow/LiveActivity/GlucoseSlotFormat.swift @@ -0,0 +1,222 @@ +// LoopFollow +// GlucoseSlotFormat.swift + +import Foundation + +/// Renders `GlucoseSnapshot` values as the strings shown in the Live Activity +/// and the home screen widget. Both surfaces format identically, so a value +/// reads the same wherever it appears. +enum LAFormat { + private static let mgdlFormatter: NumberFormatter = { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.maximumFractionDigits = 0 + nf.locale = .current + return nf + }() + + private static let mmolFormatter: NumberFormatter = { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.minimumFractionDigits = 1 + nf.maximumFractionDigits = 1 + nf.locale = .current + return nf + }() + + private static func formatGlucoseValue(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> String { + switch unit { + case .mgdl: + return mgdlFormatter.string(from: NSNumber(value: round(mgdl))) ?? "\(Int(round(mgdl)))" + case .mmol: + let mmol = GlucoseConversion.toMmol(mgdl) + return mmolFormatter.string(from: NSNumber(value: mmol)) ?? String(format: "%.1f", mmol) + } + } + + static func glucose(_ s: GlucoseSnapshot) -> String { + formatGlucoseValue(s.glucose, unit: s.unit) + } + + static func delta(_ s: GlucoseSnapshot) -> String { + switch s.unit { + case .mgdl: + let v = Int(round(s.delta)) + if v == 0 { return "0" } + return v > 0 ? "+\(v)" : "\(v)" + case .mmol: + let mmol = GlucoseConversion.toMmol(s.delta) + let d = (abs(mmol) < 0.05) ? 0.0 : mmol + if d == 0 { return mmolFormatter.string(from: 0) ?? "0.0" } + let formatted = mmolFormatter.string(from: NSNumber(value: abs(d))) ?? String(format: "%.1f", abs(d)) + return d > 0 ? "+\(formatted)" : "-\(formatted)" + } + } + + static func trendArrow(_ s: GlucoseSnapshot) -> String { + switch s.trend { + case .upFast: "↑↑" + case .up: "↑" + case .upSlight: "↗" + case .flat: "→" + case .downSlight: "↘︎" + case .down: "↓" + case .downFast: "↓↓" + case .unknown: "–" + } + } + + static func iob(_ s: GlucoseSnapshot) -> String { + guard let v = s.iob else { return "—" } + return String(format: "%.1f", v) + } + + static func cob(_ s: GlucoseSnapshot) -> String { + guard let v = s.cob else { return "—" } + return String(Int(round(v))) + } + + static func projected(_ s: GlucoseSnapshot) -> String { + guard let v = s.projected else { return "—" } + return formatGlucoseValue(v, unit: s.unit) + } + + private static let ageFormatter: DateComponentsFormatter = { + let f = DateComponentsFormatter() + f.unitsStyle = .positional + f.allowedUnits = [.day, .hour] + f.zeroFormattingBehavior = [.pad] + return f + }() + + static func age(insertTime: TimeInterval) -> String { + guard insertTime > 0 else { return "—" } + let secondsAgo = Date().timeIntervalSince1970 - insertTime + return ageFormatter.string(from: secondsAgo) ?? "—" + } + + static func recBolus(_ s: GlucoseSnapshot) -> String { + guard let v = s.recBolus else { return "—" } + return String(format: "%.2fU", v) + } + + static func autosens(_ s: GlucoseSnapshot) -> String { + guard let v = s.autosens else { return "—" } + return String(format: "%.0f%%", v * 100) + } + + static func tdd(_ s: GlucoseSnapshot) -> String { + guard let v = s.tdd else { return "—" } + return String(format: "%.1fU", v) + } + + static func basal(_ s: GlucoseSnapshot) -> String { + s.basalRate.isEmpty ? "—" : s.basalRate + } + + static func pump(_ s: GlucoseSnapshot) -> String { + guard let v = s.pumpReservoirU else { return "50+U" } + return "\(Int(round(v)))U" + } + + static func pumpBattery(_ s: GlucoseSnapshot) -> String { + guard let v = s.pumpBattery else { return "—" } + return String(format: "%.0f%%", v) + } + + static func battery(_ s: GlucoseSnapshot) -> String { + guard let v = s.battery else { return "—" } + return String(format: "%.0f%%", v) + } + + static func target(_ s: GlucoseSnapshot) -> String { + guard let low = s.targetLowMgdl, low > 0 else { return "—" } + let lowStr = formatGlucoseValue(low, unit: s.unit) + if let high = s.targetHighMgdl, high > 0, abs(high - low) > 0.5 { + return "\(lowStr)-\(formatGlucoseValue(high, unit: s.unit))" + } + return lowStr + } + + static func isf(_ s: GlucoseSnapshot) -> String { + guard let v = s.isfMgdlPerU, v > 0 else { return "—" } + return formatGlucoseValue(v, unit: s.unit) + } + + static func carbRatio(_ s: GlucoseSnapshot) -> String { + guard let v = s.carbRatio, v > 0 else { return "—" } + return String(format: "%.0fg", v) + } + + static func carbsToday(_ s: GlucoseSnapshot) -> String { + guard let v = s.carbsToday else { return "—" } + return "\(Int(round(v)))g" + } + + static func minMax(_ s: GlucoseSnapshot) -> String { + guard let mn = s.minBgMgdl, let mx = s.maxBgMgdl else { return "—" } + return "\(formatGlucoseValue(mn, unit: s.unit))/\(formatGlucoseValue(mx, unit: s.unit))" + } + + static func override(_ s: GlucoseSnapshot) -> String { + s.override ?? "—" + } + + static func profileName(_ s: GlucoseSnapshot) -> String { + s.profileName ?? "—" + } + + /// A literal `dateFormat` would pin the clock to 24 hours whatever the device + /// is set to, so the style is left for the locale to resolve. + private static let hhmmFormatter: DateFormatter = { + let df = DateFormatter() + df.locale = .current + df.timeZone = .current + df.dateStyle = .none + df.timeStyle = .short + return df + }() + + private static let hhmmssFormatter: DateFormatter = { + let df = DateFormatter() + df.locale = .current + df.timeZone = .current + df.dateFormat = "HH:mm:ss" + return df + }() + + static func hhmmss(_ date: Date) -> String { + hhmmssFormatter.string(from: date) + } + + static func updated(_ s: GlucoseSnapshot) -> String { + hhmmFormatter.string(from: s.updatedAt) + } +} + +func slotFormattedValue(option: LiveActivitySlotOption, snapshot: GlucoseSnapshot) -> String { + switch option { + case .none: "" + case .delta: LAFormat.delta(snapshot) + case .projectedBG: LAFormat.projected(snapshot) + case .minMax: LAFormat.minMax(snapshot) + case .iob: LAFormat.iob(snapshot) + case .cob: LAFormat.cob(snapshot) + case .recBolus: LAFormat.recBolus(snapshot) + case .autosens: LAFormat.autosens(snapshot) + case .tdd: LAFormat.tdd(snapshot) + case .basal: LAFormat.basal(snapshot) + case .pump: LAFormat.pump(snapshot) + case .pumpBattery: LAFormat.pumpBattery(snapshot) + case .battery: LAFormat.battery(snapshot) + case .target: LAFormat.target(snapshot) + case .isf: LAFormat.isf(snapshot) + case .carbRatio: LAFormat.carbRatio(snapshot) + case .sage: LAFormat.age(insertTime: snapshot.sageInsertTime) + case .cage: LAFormat.age(insertTime: snapshot.cageInsertTime) + case .iage: LAFormat.age(insertTime: snapshot.iageInsertTime) + case .carbsToday: LAFormat.carbsToday(snapshot) + case .override: LAFormat.override(snapshot) + case .profile: LAFormat.profileName(snapshot) + } +} diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift index 7951e122a..4deb4ee81 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotStore.swift @@ -17,8 +17,12 @@ final class GlucoseSnapshotStore { // MARK: - Public API - func save(_ snapshot: GlucoseSnapshot) { + /// The write is asynchronous, so callers that have to act on the stored file + /// (asking the widget to redraw, for one) pass `completion` rather than + /// assuming the snapshot has landed when `save` returns. + func save(_ snapshot: GlucoseSnapshot, completion: (() -> Void)? = nil) { queue.async { + defer { completion?() } do { let url = try self.fileURL() let encoder = JSONEncoder() diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index f854e6615..8410d2661 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -359,33 +359,6 @@ private struct MetricBlock: View { } } -private func slotFormattedValue(option: LiveActivitySlotOption, snapshot: GlucoseSnapshot) -> String { - switch option { - case .none: "" - case .delta: LAFormat.delta(snapshot) - case .projectedBG: LAFormat.projected(snapshot) - case .minMax: LAFormat.minMax(snapshot) - case .iob: LAFormat.iob(snapshot) - case .cob: LAFormat.cob(snapshot) - case .recBolus: LAFormat.recBolus(snapshot) - case .autosens: LAFormat.autosens(snapshot) - case .tdd: LAFormat.tdd(snapshot) - case .basal: LAFormat.basal(snapshot) - case .pump: LAFormat.pump(snapshot) - case .pumpBattery: LAFormat.pumpBattery(snapshot) - case .battery: LAFormat.battery(snapshot) - case .target: LAFormat.target(snapshot) - case .isf: LAFormat.isf(snapshot) - case .carbRatio: LAFormat.carbRatio(snapshot) - case .sage: LAFormat.age(insertTime: snapshot.sageInsertTime) - case .cage: LAFormat.age(insertTime: snapshot.cageInsertTime) - case .iage: LAFormat.age(insertTime: snapshot.iageInsertTime) - case .carbsToday: LAFormat.carbsToday(snapshot) - case .override: LAFormat.override(snapshot) - case .profile: LAFormat.profileName(snapshot) - } -} - private struct SlotView: View { let option: LiveActivitySlotOption let snapshot: GlucoseSnapshot @@ -530,193 +503,6 @@ private struct DynamicIslandMinimalView: View { } } -// MARK: - Formatting - -private enum LAFormat { - private static let mgdlFormatter: NumberFormatter = { - let nf = NumberFormatter() - nf.numberStyle = .decimal - nf.maximumFractionDigits = 0 - nf.locale = .current - return nf - }() - - private static let mmolFormatter: NumberFormatter = { - let nf = NumberFormatter() - nf.numberStyle = .decimal - nf.minimumFractionDigits = 1 - nf.maximumFractionDigits = 1 - nf.locale = .current - return nf - }() - - private static func formatGlucoseValue(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> String { - switch unit { - case .mgdl: - return mgdlFormatter.string(from: NSNumber(value: round(mgdl))) ?? "\(Int(round(mgdl)))" - case .mmol: - let mmol = GlucoseConversion.toMmol(mgdl) - return mmolFormatter.string(from: NSNumber(value: mmol)) ?? String(format: "%.1f", mmol) - } - } - - static func glucose(_ s: GlucoseSnapshot) -> String { - formatGlucoseValue(s.glucose, unit: s.unit) - } - - static func delta(_ s: GlucoseSnapshot) -> String { - switch s.unit { - case .mgdl: - let v = Int(round(s.delta)) - if v == 0 { return "0" } - return v > 0 ? "+\(v)" : "\(v)" - case .mmol: - let mmol = GlucoseConversion.toMmol(s.delta) - let d = (abs(mmol) < 0.05) ? 0.0 : mmol - if d == 0 { return mmolFormatter.string(from: 0) ?? "0.0" } - let formatted = mmolFormatter.string(from: NSNumber(value: abs(d))) ?? String(format: "%.1f", abs(d)) - return d > 0 ? "+\(formatted)" : "-\(formatted)" - } - } - - static func trendArrow(_ s: GlucoseSnapshot) -> String { - switch s.trend { - case .upFast: "↑↑" - case .up: "↑" - case .upSlight: "↗" - case .flat: "→" - case .downSlight: "↘︎" - case .down: "↓" - case .downFast: "↓↓" - case .unknown: "–" - } - } - - static func iob(_ s: GlucoseSnapshot) -> String { - guard let v = s.iob else { return "—" } - return String(format: "%.1f", v) - } - - static func cob(_ s: GlucoseSnapshot) -> String { - guard let v = s.cob else { return "—" } - return String(Int(round(v))) - } - - static func projected(_ s: GlucoseSnapshot) -> String { - guard let v = s.projected else { return "—" } - return formatGlucoseValue(v, unit: s.unit) - } - - private static let ageFormatter: DateComponentsFormatter = { - let f = DateComponentsFormatter() - f.unitsStyle = .positional - f.allowedUnits = [.day, .hour] - f.zeroFormattingBehavior = [.pad] - return f - }() - - static func age(insertTime: TimeInterval) -> String { - guard insertTime > 0 else { return "—" } - let secondsAgo = Date().timeIntervalSince1970 - insertTime - return ageFormatter.string(from: secondsAgo) ?? "—" - } - - static func recBolus(_ s: GlucoseSnapshot) -> String { - guard let v = s.recBolus else { return "—" } - return String(format: "%.2fU", v) - } - - static func autosens(_ s: GlucoseSnapshot) -> String { - guard let v = s.autosens else { return "—" } - return String(format: "%.0f%%", v * 100) - } - - static func tdd(_ s: GlucoseSnapshot) -> String { - guard let v = s.tdd else { return "—" } - return String(format: "%.1fU", v) - } - - static func basal(_ s: GlucoseSnapshot) -> String { - s.basalRate.isEmpty ? "—" : s.basalRate - } - - static func pump(_ s: GlucoseSnapshot) -> String { - guard let v = s.pumpReservoirU else { return "50+U" } - return "\(Int(round(v)))U" - } - - static func pumpBattery(_ s: GlucoseSnapshot) -> String { - guard let v = s.pumpBattery else { return "—" } - return String(format: "%.0f%%", v) - } - - static func battery(_ s: GlucoseSnapshot) -> String { - guard let v = s.battery else { return "—" } - return String(format: "%.0f%%", v) - } - - static func target(_ s: GlucoseSnapshot) -> String { - guard let low = s.targetLowMgdl, low > 0 else { return "—" } - let lowStr = formatGlucoseValue(low, unit: s.unit) - if let high = s.targetHighMgdl, high > 0, abs(high - low) > 0.5 { - return "\(lowStr)-\(formatGlucoseValue(high, unit: s.unit))" - } - return lowStr - } - - static func isf(_ s: GlucoseSnapshot) -> String { - guard let v = s.isfMgdlPerU, v > 0 else { return "—" } - return formatGlucoseValue(v, unit: s.unit) - } - - static func carbRatio(_ s: GlucoseSnapshot) -> String { - guard let v = s.carbRatio, v > 0 else { return "—" } - return String(format: "%.0fg", v) - } - - static func carbsToday(_ s: GlucoseSnapshot) -> String { - guard let v = s.carbsToday else { return "—" } - return "\(Int(round(v)))g" - } - - static func minMax(_ s: GlucoseSnapshot) -> String { - guard let mn = s.minBgMgdl, let mx = s.maxBgMgdl else { return "—" } - return "\(formatGlucoseValue(mn, unit: s.unit))/\(formatGlucoseValue(mx, unit: s.unit))" - } - - static func override(_ s: GlucoseSnapshot) -> String { - s.override ?? "—" - } - - static func profileName(_ s: GlucoseSnapshot) -> String { - s.profileName ?? "—" - } - - private static let hhmmFormatter: DateFormatter = { - let df = DateFormatter() - df.locale = .current - df.timeZone = .current - df.dateFormat = "HH:mm" - return df - }() - - private static let hhmmssFormatter: DateFormatter = { - let df = DateFormatter() - df.locale = .current - df.timeZone = .current - df.dateFormat = "HH:mm:ss" - return df - }() - - static func hhmmss(_ date: Date) -> String { - hhmmssFormatter.string(from: date) - } - - static func updated(_ s: GlucoseSnapshot) -> String { - hhmmFormatter.string(from: s.updatedAt) - } -} - // MARK: - Threshold-driven colors private enum LAColors { From 6be78aa40a42247298e2adfa64548ea576be8dd1 Mon Sep 17 00:00:00 2001 From: Justin Maier Date: Mon, 27 Jul 2026 16:42:17 -0600 Subject: [PATCH 06/13] Add a home screen glucose widget A medium widget showing a glucose chart with four configurable metric slots, alongside the existing Live Activity. The span of history is selectable in Edit Widget from one to twenty four hours, defaulting to three. The widget reads a chart series and the snapshot from the App Group, both published by the Nightscout BG path so a user who has never enabled the Live Activity is still served. When that cache is more than fifteen minutes old it fetches recent entries itself, so it keeps working while the app is not running. Staleness is judged only by the age of the snapshot the numbers come from, never by the chart, because the fallback refreshes the chart alone. Past fifteen minutes the reading is demoted, the trend arrow is dropped and the age is shown, and the metric slots demote with it. The timeline carries entries out to four hours so the displayed age stays honest even when no reload is granted. Thresholds published to the App Group now come from the effective time in range mode rather than the raw low and high lines, so the widget and the Live Activity colour glucose the same way the app's own graph does. The widget links Swift Charts by its SDK path: the CocoaPods framework of the same name is generated into the shared products directory, which the linker searches first, and it would otherwise be picked up instead. --- LoopFollow.xcodeproj/project.pbxproj | 205 ++++++++++++++ .../Controllers/Nightscout/BGData.swift | 61 +++++ LoopFollow/LiveActivity/AppGroupID.swift | 1 + .../LiveActivity/GlucoseChartSeries.swift | 133 +++++++++ .../LiveActivity/LAAppGroupSettings.swift | 77 ++++++ .../LiveActivity/LiveActivityManager.swift | 41 +-- .../LiveActivity/NightscoutChartFetcher.swift | 98 +++++++ LoopFollowWidget/GlucoseWidgetEntry.swift | 28 ++ LoopFollowWidget/LoopFollowWidget.swift | 253 ++++++++++++++++++ LoopFollowWidget/LoopFollowWidgetBundle.swift | 12 + LoopFollowWidget/WidgetChartView.swift | 174 ++++++++++++ .../WidgetConfigurationIntent.swift | 82 ++++++ LoopFollowWidget/WidgetDataSource.swift | 31 +++ LoopFollowWidget/WidgetInfo.plist | 27 ++ LoopFollowWidget/WidgetSlotView.swift | 47 ++++ LoopFollowWidget/WidgetTimelineProvider.swift | 93 +++++++ LoopFollowWidgetExtension.entitlements | 10 + fastlane/Fastfile | 20 +- 18 files changed, 1370 insertions(+), 23 deletions(-) create mode 100644 LoopFollow/LiveActivity/GlucoseChartSeries.swift create mode 100644 LoopFollow/LiveActivity/NightscoutChartFetcher.swift create mode 100644 LoopFollowWidget/GlucoseWidgetEntry.swift create mode 100644 LoopFollowWidget/LoopFollowWidget.swift create mode 100644 LoopFollowWidget/LoopFollowWidgetBundle.swift create mode 100644 LoopFollowWidget/WidgetChartView.swift create mode 100644 LoopFollowWidget/WidgetConfigurationIntent.swift create mode 100644 LoopFollowWidget/WidgetDataSource.swift create mode 100644 LoopFollowWidget/WidgetInfo.plist create mode 100644 LoopFollowWidget/WidgetSlotView.swift create mode 100644 LoopFollowWidget/WidgetTimelineProvider.swift create mode 100644 LoopFollowWidgetExtension.entitlements diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 29d51a0da..9a60721bf 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -7,6 +7,16 @@ objects = { /* Begin PBXBuildFile section */ + F85CF0901B0E0FC11036A6A7 /* GlucoseChartSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35158B4FEF7D97F70DE5027A /* GlucoseChartSeries.swift */; }; + 0D89C9E0A8B6D172D0D08983 /* GlucoseChartSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35158B4FEF7D97F70DE5027A /* GlucoseChartSeries.swift */; }; + F19C8BD4E2827EC60DF73123 /* NightscoutChartFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8F1C3BFC5A3378CE36AF953 /* NightscoutChartFetcher.swift */; }; + 8AD21902935AAD4A6A72AECF /* GlucoseSlotFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A30D82A0271B22651F843673 /* GlucoseSlotFormat.swift */; }; + 3CB7CE68C5C08EF95C7EFD5D /* AppGroupID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A779F2F5BE17000E96858 /* AppGroupID.swift */; }; + 7552252D0D0BDD20AFBB1FD1 /* GlucoseSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */; }; + D49B0D6E8CC25401E7D0043E /* LAAppGroupSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */; }; + E3E72899F78FE1ACDC01E628 /* GlucoseConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD91E4DC2BDEC3F8002D9E97 /* GlucoseConversion.swift */; }; + 135CC03ED32EAA2767137092 /* GlucoseSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 374A77AF2F5BE1AC00E96858 /* GlucoseSnapshotStore.swift */; }; + 9E3E4F99E303C250FC72C969 /* LoopFollowWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 104429F718054304E72C8C77 /* LoopFollowWidget.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; B500000000000000000000A2 /* RemoteBolusHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000A1 /* RemoteBolusHistoryEntry.swift */; }; B500000000000000000000A4 /* QuickPickBolusesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000A3 /* QuickPickBolusesManager.swift */; }; B500000000000000000000B2 /* RemoteMealHistoryEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B500000000000000000000B1 /* RemoteMealHistoryEntry.swift */; }; @@ -460,6 +470,13 @@ remoteGlobalIDString = FC9788132485969B00A7906C; remoteInfo = LoopFollow; }; + 9274652115971A7691241ECC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FC97880C2485969B00A7906C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 1AE4F08CDE3EF443E163CF94; + remoteInfo = LoopFollowWidgetExtension; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -470,6 +487,7 @@ dstSubfolderSpec = 13; files = ( 37A4BDE82F5B6B4C00EEB289 /* LoopFollowLAExtensionExtension.appex in Embed Foundation Extensions */, + 9E3E4F99E303C250FC72C969 /* LoopFollowWidget.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -486,6 +504,10 @@ 2EBAB9EECE7095238A558060 /* FutureCarbsCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FutureCarbsCondition.swift; sourceTree = ""; }; 374A77982F5BD8AB00E96858 /* APNSClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APNSClient.swift; sourceTree = ""; }; A30D82A0271B22651F843673 /* GlucoseSlotFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSlotFormat.swift; sourceTree = ""; }; + 35158B4FEF7D97F70DE5027A /* GlucoseChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseChartSeries.swift; sourceTree = ""; }; + E8F1C3BFC5A3378CE36AF953 /* NightscoutChartFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutChartFetcher.swift; sourceTree = ""; }; + 294EE06A684C3BBBF8702FCC /* LoopFollowWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoopFollowWidgetExtension.entitlements; sourceTree = ""; }; + 104429F718054304E72C8C77 /* LoopFollowWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 374A779F2F5BE17000E96858 /* AppGroupID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroupID.swift; sourceTree = ""; }; 374A77A02F5BE17000E96858 /* GlucoseLiveActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseLiveActivityAttributes.swift; sourceTree = ""; }; 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSnapshot.swift; sourceTree = ""; }; @@ -922,6 +944,7 @@ 65AC25F52ECFD5E800421360 /* Stats */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Stats; sourceTree = ""; }; 65AC26702ED245DF00421360 /* Treatments */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Treatments; sourceTree = ""; }; DDCC3AD72DDE1790006F1C10 /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = ""; }; + EE2CBE5FC6FDD612F92C5DAE /* LoopFollowWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LoopFollowWidget; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -951,6 +974,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CDF61235460EA5C9790F7D0E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -969,6 +999,8 @@ 374A77A12F5BE17000E96858 /* GlucoseSnapshot.swift */, 374A77A32F5BE17000E96858 /* LAAppGroupSettings.swift */, A30D82A0271B22651F843673 /* GlucoseSlotFormat.swift */, + 35158B4FEF7D97F70DE5027A /* GlucoseChartSeries.swift */, + E8F1C3BFC5A3378CE36AF953 /* NightscoutChartFetcher.swift */, 374A77982F5BD8AB00E96858 /* APNSClient.swift */, ); path = LiveActivity; @@ -1674,6 +1706,7 @@ children = ( 379BECA92F6588300069DC62 /* RestartLiveActivityIntent.swift */, 374DAACA2F5B924B00BB663B /* LoopFollowLAExtensionExtension.entitlements */, + 294EE06A684C3BBBF8702FCC /* LoopFollowWidgetExtension.entitlements */, DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */, DDB0AF542BB1B24A00AFA48B /* BuildDetails.plist */, DDB0AF4F2BB1A81F00AFA48B /* Scripts */, @@ -1683,6 +1716,7 @@ FC8DEEE32485D1680075863F /* LoopFollow */, DDCC3AD72DDE1790006F1C10 /* Tests */, 37A4BDDE2F5B6B4A00EEB289 /* LoopFollowLAExtension */, + EE2CBE5FC6FDD612F92C5DAE /* LoopFollowWidget */, FC9788152485969B00A7906C /* Products */, 8E32230C453C93FDCE59C2B9 /* Pods */, 6A5880E0B811AF443B05AB02 /* Frameworks */, @@ -1695,6 +1729,7 @@ FC9788142485969B00A7906C /* Loop Follow.app */, DDCC3AD62DDE1790006F1C10 /* Tests.xctest */, 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */, + 104429F718054304E72C8C77 /* LoopFollowWidget.appex */, ); name = Products; sourceTree = ""; @@ -1835,6 +1870,7 @@ ); dependencies = ( 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */, + 0BA3A403A151D51F96333506 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 65AC25F52ECFD5E800421360 /* Stats */, @@ -1848,6 +1884,28 @@ productReference = FC9788142485969B00A7906C /* Loop Follow.app */; productType = "com.apple.product-type.application"; }; + 1AE4F08CDE3EF443E163CF94 /* LoopFollowWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = A4061ACE56570735F8A5947A /* Build configuration list for PBXNativeTarget "LoopFollowWidgetExtension" */; + buildPhases = ( + 89F80DFEAC27B3727FE4BB09 /* Sources */, + CDF61235460EA5C9790F7D0E /* Frameworks */, + 5690D24475F3FC5A0A89FC92 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + EE2CBE5FC6FDD612F92C5DAE /* LoopFollowWidget */, + ); + name = LoopFollowWidgetExtension; + packageProductDependencies = ( + ); + productName = LoopFollowWidgetExtension; + productReference = 104429F718054304E72C8C77 /* LoopFollowWidget.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -1861,6 +1919,9 @@ 37A4BDD82F5B6B4A00EEB289 = { CreatedOnToolsVersion = 26.2; }; + 1AE4F08CDE3EF443E163CF94 = { + CreatedOnToolsVersion = 26.2; + }; DDCC3AD52DDE1790006F1C10 = { CreatedOnToolsVersion = 16.3; TestTargetID = FC9788132485969B00A7906C; @@ -1889,6 +1950,7 @@ FC9788132485969B00A7906C /* LoopFollow */, DDCC3AD52DDE1790006F1C10 /* Tests */, 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */, + 1AE4F08CDE3EF443E163CF94 /* LoopFollowWidgetExtension */, ); }; /* End PBXProject section */ @@ -2037,6 +2099,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5690D24475F3FC5A0A89FC92 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -2166,6 +2235,7 @@ 374A77B72F5BE1AC00E96858 /* LiveActivityManager.swift in Sources */, 374A77B82F5BE1AC00E96858 /* GlucoseSnapshotStore.swift in Sources */, 30EBBFA8E833254472B5A8E7 /* GlucoseSlotFormat.swift in Sources */, + F85CF0901B0E0FC11036A6A7 /* GlucoseChartSeries.swift in Sources */, 374A77B92F5BE1AC00E96858 /* PreferredGlucoseUnit.swift in Sources */, DDE75D232DE5E505007C1FC1 /* Glyph.swift in Sources */, DD4878202C7DAF890048F05C /* PushMessage.swift in Sources */, @@ -2452,6 +2522,21 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 89F80DFEAC27B3727FE4BB09 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + E3E72899F78FE1ACDC01E628 /* GlucoseConversion.swift in Sources */, + 3CB7CE68C5C08EF95C7EFD5D /* AppGroupID.swift in Sources */, + 7552252D0D0BDD20AFBB1FD1 /* GlucoseSnapshot.swift in Sources */, + D49B0D6E8CC25401E7D0043E /* LAAppGroupSettings.swift in Sources */, + 8AD21902935AAD4A6A72AECF /* GlucoseSlotFormat.swift in Sources */, + 135CC03ED32EAA2767137092 /* GlucoseSnapshotStore.swift in Sources */, + 0D89C9E0A8B6D172D0D08983 /* GlucoseChartSeries.swift in Sources */, + F19C8BD4E2827EC60DF73123 /* NightscoutChartFetcher.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -2466,6 +2551,12 @@ target = FC9788132485969B00A7906C /* LoopFollow */; targetProxy = DDCC3ADA2DDE1790006F1C10 /* PBXContainerItemProxy */; }; + 0BA3A403A151D51F96333506 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = 1AE4F08CDE3EF443E163CF94 /* LoopFollowWidgetExtension */; + targetProxy = 9274652115971A7691241ECC /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -2818,6 +2909,111 @@ }; name = Release; }; + 40CE5E863B3ECE08CBB62C3D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_ENTITLEMENTS = LoopFollowWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = NO; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowWidget/WidgetInfo.plist; + INFOPLIST_KEY_CFBundleDisplayName = LoopFollowWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Jon Fawcett. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = "$(MARKETING_VERSION)"; + OTHER_LDFLAGS = "$(SDKROOT)/System/Library/Frameworks/Charts.framework/Charts.tbd"; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowWidget"; + PRODUCT_MODULE_NAME = LoopFollowWidget; + PRODUCT_NAME = LoopFollowWidget; + SDKROOT = auto; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + XROS_DEPLOYMENT_TARGET = 26.2; + }; + name = Debug; + }; + CBB59BAE4150CBC089990558 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CODE_SIGN_ENTITLEMENTS = LoopFollowWidgetExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + ENABLE_APP_SANDBOX = NO; + ENABLE_HARDENED_RUNTIME = NO; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowWidget/WidgetInfo.plist; + INFOPLIST_KEY_CFBundleDisplayName = LoopFollowWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Jon Fawcett. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@executable_path/../../../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 26.2; + MARKETING_VERSION = "$(MARKETING_VERSION)"; + OTHER_LDFLAGS = "$(SDKROOT)/System/Library/Frameworks/Charts.framework/Charts.tbd"; + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).LoopFollowWidget"; + PRODUCT_MODULE_NAME = LoopFollowWidget; + PRODUCT_NAME = LoopFollowWidget; + SDKROOT = auto; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + XROS_DEPLOYMENT_TARGET = 26.2; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -2857,6 +3053,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + A4061ACE56570735F8A5947A /* Build configuration list for PBXNativeTarget "LoopFollowWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 40CE5E863B3ECE08CBB62C3D /* Debug */, + CBB59BAE4150CBC089990558 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCVersionGroup section */ diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index 2d6c73a77..f1a32e654 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -3,6 +3,7 @@ import Foundation import UIKit +import WidgetKit extension MainViewController { /// Number of days of BG history to request from the source. One extra day is @@ -322,6 +323,9 @@ extension MainViewController { LiveActivityManager.shared.refreshFromCurrentState(reason: "bg") #endif + // Home screen widget update + self.updateWidgetData(entries) + // Update contact if Storage.shared.contactEnabled.value { self.contactImageUpdater @@ -336,4 +340,61 @@ extension MainViewController { Storage.shared.lastBGChecked.value = Date() } } + + /// Must match the kind the widget registers itself under. + private static let widgetKind = "LoopFollowWidget" + + /// Publishes what the home screen widget draws: its settings, the chart series + /// and the matching snapshot. The redraw is requested only once both files are + /// on disk, so the widget never renders a new chart against the previous + /// reading. Kept off `LiveActivityManager.refreshFromCurrentState`, whose + /// 20 second debounce is starved whenever BG fetches are rescheduled more + /// often than that. + /// - Parameter entries: readings ordered oldest first. + func updateWidgetData(_ entries: [ShareGlucoseData]) { + LAAppGroupSettings.setNightscout(url: Storage.shared.url.value, token: Storage.shared.token.value) + LAAppGroupSettings.setDisplayName( + Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow", + show: Storage.shared.showDisplayName.value + ) + + // The widget and the Live Activity color their readings by these, so they + // use the same thresholds as the graph and the stats. LiveActivityManager + // publishes the same values on its own paths. + let thresholds = UnitSettingsStore.shared.effectiveThresholds() + LAAppGroupSettings.setThresholds(lowMgdl: thresholds.low, highMgdl: thresholds.high) + + // The chart outlives the snapshot, so the unit has to be readable on its + // own rather than only from the reading. + LAAppGroupSettings.setPreferredUnit(PreferredGlucoseUnit.snapshotUnit()) + + // Clamp plotted BG to the display range, as the app's own graph does + // (see #600), so an out-of-range sgv cannot distort the chart scale. + let minDisplay = globalVariables.minDisplayGlucose + let maxDisplay = globalVariables.maxDisplayGlucose + + let cutoff = dateTimeUtils.getNowTimeIntervalUTC() - GlucoseChartSeriesStore.window + let points = entries + .filter { $0.date >= cutoff } + .map { GlucoseChartPoint(value: Double(min(max($0.sgv, minDisplay), maxDisplay)), + date: Date(timeIntervalSince1970: $0.date)) } + + // Without a snapshot there is no reading to draw the chart against, so + // skip the whole publish rather than let the two surfaces diverge. + guard let snapshot = GlucoseSnapshotBuilder.build(from: StorageCurrentGlucoseStateProvider()) else { return } + + // A fetch that brings nothing new still lands here, and during a sensor + // gap it lands every few seconds, so reload only on new data. Read both + // stores before saving, or the comparisons are always equal. + let publishedSeriesEnd = GlucoseChartSeriesStore.shared.load()?.points.last?.date + let seriesUnchanged = points.last?.date == publishedSeriesEnd + let snapshotUnchanged = GlucoseSnapshotStore.shared.load() == snapshot + guard !seriesUnchanged || !snapshotUnchanged else { return } + + GlucoseChartSeriesStore.shared.save(GlucoseChartSeries(points: points, updatedAt: Date())) { + GlucoseSnapshotStore.shared.save(snapshot) { + WidgetCenter.shared.reloadTimelines(ofKind: MainViewController.widgetKind) + } + } + } } diff --git a/LoopFollow/LiveActivity/AppGroupID.swift b/LoopFollow/LiveActivity/AppGroupID.swift index 5eb1187b8..1c21e62b1 100644 --- a/LoopFollow/LiveActivity/AppGroupID.swift +++ b/LoopFollow/LiveActivity/AppGroupID.swift @@ -54,6 +54,7 @@ enum AppGroupID { ".LiveActivity", ".LiveActivityExtension", ".LoopFollowLAExtension", + ".LoopFollowWidget", ".Widget", ".WidgetExtension", ".Widgets", diff --git a/LoopFollow/LiveActivity/GlucoseChartSeries.swift b/LoopFollow/LiveActivity/GlucoseChartSeries.swift new file mode 100644 index 000000000..c318ed430 --- /dev/null +++ b/LoopFollow/LiveActivity/GlucoseChartSeries.swift @@ -0,0 +1,133 @@ +// LoopFollow +// GlucoseChartSeries.swift + +import Foundation + +/// A single glucose reading plotted on the home screen widget chart. +struct GlucoseChartPoint: Codable, Equatable, Hashable { + /// Glucose value in mg/dL (canonical internal unit). + let value: Double + + /// Timestamp of the reading. + let date: Date + + init(value: Double, date: Date) { + self.value = value + self.date = date + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(value, forKey: .value) + try container.encode(date.timeIntervalSince1970, forKey: .date) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + value = try container.decode(Double.self, forKey: .value) + date = try Date(timeIntervalSince1970: container.decode(Double.self, forKey: .date)) + } + + private enum CodingKeys: String, CodingKey { + case value, date + } +} + +/// Recent glucose history for the home screen widget chart. +/// +/// Thresholds and the preferred display unit are read separately from +/// `LAAppGroupSettings`, so this stays a plain series of readings. +struct GlucoseChartSeries: Codable, Equatable { + /// Readings ordered oldest first. + let points: [GlucoseChartPoint] + + /// When the app last wrote this series. + let updatedAt: Date + + /// Age of the series in seconds. + var age: TimeInterval { + Date().timeIntervalSince(updatedAt) + } + + init(points: [GlucoseChartPoint], updatedAt: Date) { + self.points = points + self.updatedAt = updatedAt + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(points, forKey: .points) + try container.encode(updatedAt.timeIntervalSince1970, forKey: .updatedAt) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + points = try container.decode([GlucoseChartPoint].self, forKey: .points) + updatedAt = try Date(timeIntervalSince1970: container.decode(Double.self, forKey: .updatedAt)) + } + + private enum CodingKeys: String, CodingKey { + case points, updatedAt + } +} + +/// Persists the recent glucose history into the App Group container so the +/// home screen widget can render a chart without the app running. +/// +/// Uses an atomic JSON file write to avoid partial/corrupt reads across processes. +final class GlucoseChartSeriesStore { + static let shared = GlucoseChartSeriesStore() + private init() {} + + /// Readings older than this are dropped when saving. Covers the longest + /// `WidgetChartDuration`, since the widget can only draw what is stored. + static let window: TimeInterval = 24 * 3600 + + private let fileName = "glucose_chart_series.json" + private let queue = DispatchQueue(label: "com.loopfollow.glucoseChartSeriesStore", qos: .utility) + + // MARK: - Public API + + /// The write is asynchronous, so callers that have to act on the stored file + /// (asking the widget to redraw, for one) pass `completion` rather than + /// assuming the series has landed when `save` returns. + func save(_ series: GlucoseChartSeries, completion: (() -> Void)? = nil) { + queue.async { + defer { completion?() } + do { + let url = try self.fileURL() + let data = try JSONEncoder().encode(series) + try data.write(to: url, options: [.atomic]) + } catch { + // Intentionally silent (extension-safe, no dependencies). + } + } + } + + func load() -> GlucoseChartSeries? { + do { + let url = try fileURL() + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + + let data = try Data(contentsOf: url) + return try JSONDecoder().decode(GlucoseChartSeries.self, from: data) + } catch { + // Intentionally silent (extension-safe, no dependencies). + return nil + } + } + + // MARK: - Helpers + + private func fileURL() throws -> URL { + let groupID = AppGroupID.current() + guard let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: groupID) else { + throw NSError( + domain: "GlucoseChartSeriesStore", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "App Group containerURL is nil for id=\(groupID)"], + ) + } + return containerURL.appendingPathComponent(fileName, isDirectory: false) + } +} diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 6359fe55e..63668fb2d 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -135,6 +135,45 @@ enum LiveActivitySlotDefaults { } } +// MARK: - Widget chart duration + +/// Span of glucose history drawn on the home screen widget chart, chosen from +/// the widget's own Edit Widget sheet. +/// +/// `GlucoseChartSeriesStore.window` has to cover the longest case here, or the +/// longer spans would draw the same readings as the shorter ones. +enum WidgetChartDuration: String, CaseIterable, Codable { + case oneHour + case threeHours + case sixHours + case twelveHours + case twentyFourHours + + /// What the widget draws until the user picks something else. The + /// @Parameter default in the configuration intent has to match. + static let standard: WidgetChartDuration = .threeHours + + var seconds: TimeInterval { + switch self { + case .oneHour: 3600 + case .threeHours: 3 * 3600 + case .sixHours: 6 * 3600 + case .twelveHours: 12 * 3600 + case .twentyFourHours: 24 * 3600 + } + } + + var displayName: String { + switch self { + case .oneHour: "1 hour" + case .threeHours: "3 hours" + case .sixHours: "6 hours" + case .twelveHours: "12 hours" + case .twentyFourHours: "24 hours" + } + } +} + // MARK: - App Group settings /// Minimal App Group settings needed by the Live Activity UI. @@ -149,6 +188,9 @@ enum LAAppGroupSettings { static let smallWidgetSlot = "la.smallWidgetSlot" static let displayName = "la.displayName" static let showDisplayName = "la.showDisplayName" + static let nightscoutURL = "la.nightscout.url" + static let nightscoutToken = "la.nightscout.token" + static let preferredUnit = "la.preferredUnit" } private static var defaults: UserDefaults? { @@ -220,4 +262,39 @@ enum LAAppGroupSettings { static func showDisplayName() -> Bool { defaults?.bool(forKey: Keys.showDisplayName) ?? false } + + // MARK: - Nightscout connection + + /// Mirrors the site the app polls so an extension can fetch on its own when + /// its cached data has gone stale. An empty url means "not configured". + static func setNightscout(url: String, token: String) { + defaults?.set(url, forKey: Keys.nightscoutURL) + defaults?.set(token, forKey: Keys.nightscoutToken) + } + + static func nightscoutURL() -> String { + defaults?.string(forKey: Keys.nightscoutURL) ?? "" + } + + static func nightscoutToken() -> String { + defaults?.string(forKey: Keys.nightscoutToken) ?? "" + } + + // MARK: - Preferred glucose unit + + /// Mirrors the app's unit selection so a surface that has a chart but no + /// snapshot still labels and scales it in the unit the user reads in. + static func setPreferredUnit(_ unit: GlucoseSnapshot.Unit) { + defaults?.set(unit.rawValue, forKey: Keys.preferredUnit) + } + + static func preferredUnit() -> GlucoseSnapshot.Unit { + guard let raw = defaults?.string(forKey: Keys.preferredUnit) else { return .mgdl } + return GlucoseSnapshot.Unit(rawValue: raw) ?? .mgdl + } } + +// Explicit so the widget can use this enum as an AppEnum parameter; the +// implicit conformance would land outside this file. +extension LiveActivitySlotOption: Sendable {} +extension WidgetChartDuration: Sendable {} diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index c3b354a82..eaaafbbf5 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -201,10 +201,7 @@ final class LiveActivityManager { let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return } - LAAppGroupSettings.setThresholds( - lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value, - ) + Self.publishThresholds() GlucoseSnapshotStore.shared.save(snapshot) seq += 1 @@ -733,10 +730,7 @@ final class LiveActivityManager { let provider = StorageCurrentGlucoseStateProvider() if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { - LAAppGroupSettings.setThresholds( - lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value, - ) + Self.publishThresholds() LAAppGroupSettings.setDisplayName( Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow", show: Storage.shared.showDisplayName.value @@ -1086,6 +1080,15 @@ final class LiveActivityManager { } } + // MARK: - Shared surfaces + + /// Publishes the thresholds the app charts and scores with, so the Live + /// Activity and the home screen widget color a reading the way the app does. + private static func publishThresholds() { + let thresholds = UnitSettingsStore.shared.effectiveThresholds() + LAAppGroupSettings.setThresholds(lowMgdl: thresholds.low, highMgdl: thresholds.high) + } + private func performRefresh(reason: String) { let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { @@ -1098,27 +1101,25 @@ final class LiveActivityManager { "cob=\(snapshot.cob?.description ?? "nil") proj=\(snapshot.projected?.description ?? "nil") u=\(snapshot.unit.rawValue)" LogManager.shared.log(category: .general, message: "[LA] snapshot \(fingerprint) reason=\(reason)", isDebug: true) - // Check if the Live Activity is approaching Apple's 8-hour limit and renew if so. - if renewIfNeeded(snapshot: snapshot) { return } - - if snapshot.showRenewalOverlay { - LogManager.shared.log(category: .general, message: "[LA] sending update with renewal overlay visible") - } - let now = Date() let timeSinceLastUpdate = now.timeIntervalSince(lastUpdateTime ?? .distantPast) let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60 // Capture dedup result BEFORE saving so the store comparison is valid. let snapshotUnchanged = GlucoseSnapshotStore.shared.load() == snapshot - // Store + Watch: always update, independent of LA state. - LAAppGroupSettings.setThresholds( - lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value, - ) + // Store + Watch: always update, independent of LA state, and before any + // LA path can return early. + Self.publishThresholds() GlucoseSnapshotStore.shared.save(snapshot) // WatchConnectivityManager.shared.send(snapshot: snapshot) + // Check if the Live Activity is approaching Apple's 8-hour limit and renew if so. + if renewIfNeeded(snapshot: snapshot) { return } + + if snapshot.showRenewalOverlay { + LogManager.shared.log(category: .general, message: "[LA] sending update with renewal overlay visible") + } + // LA update: gated on LA being active, snapshot having changed, and activities enabled. if !Storage.shared.laEnabled.value { LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — laEnabled=false reason=\(reason)", isDebug: true) diff --git a/LoopFollow/LiveActivity/NightscoutChartFetcher.swift b/LoopFollow/LiveActivity/NightscoutChartFetcher.swift new file mode 100644 index 000000000..3d45a67b0 --- /dev/null +++ b/LoopFollow/LiveActivity/NightscoutChartFetcher.swift @@ -0,0 +1,98 @@ +// LoopFollow +// NightscoutChartFetcher.swift + +import Foundation + +/// Fetches recent glucose entries directly from Nightscout for surfaces that run +/// without the app. `NightscoutUtils` is unusable here: it reads `Storage.shared` +/// and logs through `LogManager`, neither of which exists in an extension. +enum NightscoutChartFetcher { + /// Short enough that a timeline reload can afford to wait for it. + static let timeout: TimeInterval = 4 + + /// One reading per five minutes, doubled to leave room for duplicates. + private static let entryCount = Int(GlucoseChartSeriesStore.window / 300) * 2 + + private static let maxPlausibleMgdl: Double = 600 + + /// Display range the app's own graph clamps to (see #600). Sensors report + /// out-of-range and special values, and one of them would otherwise stretch + /// the widget chart until the real readings are unreadable. + private static let minDisplayMgdl: Double = 39 + private static let maxDisplayMgdl: Double = 400 + + // MARK: - Public API + + /// The last `GlucoseChartSeriesStore.window` of readings, oldest first, or nil. + static func fetchSeries(baseURL: String, token: String) async -> GlucoseChartSeries? { + guard let url = entriesURL(baseURL: baseURL, token: token) else { return nil } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.cachePolicy = .reloadIgnoringLocalCacheData + request.timeoutInterval = timeout + + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = timeout + configuration.timeoutIntervalForResource = timeout + let session = URLSession(configuration: configuration) + + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } + return try series(from: JSONDecoder().decode([Entry].self, from: data)) + } catch { + // Intentionally silent (extension-safe, no dependencies). + return nil + } + } + + // MARK: - Helpers + + /// Calibrations and meter values come back without an `sgv`. + private struct Entry: Decodable { + let sgv: Double? + let date: Double? + } + + private static func entriesURL(baseURL: String, token: String) -> URL? { + var components = URLComponents(string: baseURL) + components?.path = "/api/v1/entries.json" + + var queryItems = [URLQueryItem]() + if !token.isEmpty { + queryItems.append(URLQueryItem(name: "token", value: token)) + } + let since = Date().addingTimeInterval(-GlucoseChartSeriesStore.window) + queryItems.append(URLQueryItem(name: "count", value: "\(entryCount)")) + queryItems.append(URLQueryItem(name: "find[date][$gte]", value: "\(Int(since.timeIntervalSince1970 * 1000))")) + queryItems.append(URLQueryItem(name: "find[type][$ne]", value: "cal")) + components?.queryItems = queryItems + + return components?.url + } + + private static func series(from entries: [Entry]) -> GlucoseChartSeries? { + let cutoff = Date().addingTimeInterval(-GlucoseChartSeriesStore.window) + let points = entries.compactMap { entry -> GlucoseChartPoint? in + guard let sgv = entry.sgv, sgv > 0, sgv <= maxPlausibleMgdl, + let milliseconds = entry.date else { return nil } + let date = Date(timeIntervalSince1970: (milliseconds / 1000).rounded()) + guard date >= cutoff else { return nil } + let clamped = min(max(sgv, minDisplayMgdl), maxDisplayMgdl) + return GlucoseChartPoint(value: clamped, date: date) + } + .sorted { $0.date < $1.date } + + // More than one uploader can post the same reading, and the chart + // identifies its points by the whole point, so a repeated timestamp + // would collide. Keep one per timestamp. + var deduped: [GlucoseChartPoint] = [] + for point in points where point.date != deduped.last?.date { + deduped.append(point) + } + + guard !deduped.isEmpty else { return nil } + return GlucoseChartSeries(points: deduped, updatedAt: Date()) + } +} diff --git a/LoopFollowWidget/GlucoseWidgetEntry.swift b/LoopFollowWidget/GlucoseWidgetEntry.swift new file mode 100644 index 000000000..bfdccbec8 --- /dev/null +++ b/LoopFollowWidget/GlucoseWidgetEntry.swift @@ -0,0 +1,28 @@ +// LoopFollow +// GlucoseWidgetEntry.swift + +import WidgetKit + +/// One rendered state of the home screen widget. +struct GlucoseWidgetEntry: TimelineEntry { + let date: Date + let series: GlucoseChartSeries? + let snapshot: GlucoseSnapshot? + let slots: [LiveActivitySlotOption] + + /// Span of history the chart draws, chosen in Edit Widget. + var duration: WidgetChartDuration = .standard + + /// Set on the final entry of a timeline. WidgetKit keeps that entry on screen + /// for as long as it takes to get around to a reload, so its age is a floor + /// rather than a measurement and is shown as such. + var isLast: Bool = false + + /// Age of the reading and metrics, or nil when there is no snapshot. The + /// Nightscout fallback renews only the series, so anything drawn from the + /// snapshot must be judged by this, never by the chart. + var snapshotAge: TimeInterval? { + guard let updatedAt = snapshot?.updatedAt else { return nil } + return max(0, date.timeIntervalSince(updatedAt)) + } +} diff --git a/LoopFollowWidget/LoopFollowWidget.swift b/LoopFollowWidget/LoopFollowWidget.swift new file mode 100644 index 000000000..ce1dd7a0f --- /dev/null +++ b/LoopFollowWidget/LoopFollowWidget.swift @@ -0,0 +1,253 @@ +// LoopFollow +// LoopFollowWidget.swift + +import SwiftUI +import WidgetKit + +/// Medium home screen widget: the configured span of glucose as a full bleed +/// backdrop, the current reading floating over it and four configurable metrics +/// along the base. +struct LoopFollowWidgetView: View { + let entry: GlucoseWidgetEntry + + /// The tinted and clear home screen appearances flatten every colour to one + /// tint, so those layouts have to separate by opacity rather than by hue. + @Environment(\.widgetRenderingMode) private var renderingMode + + /// Past this age the reading is no longer presented as the current value. + private static let staleThreshold: TimeInterval = 15 * 60 + + /// Height of the chart backdrop. What is left below it is the metric band. + private static let chartHeight: CGFloat = 112 + + private static let inset: CGFloat = 15 + + private static let ageFormatter: DateComponentsFormatter = { + let f = DateComponentsFormatter() + f.unitsStyle = .abbreviated + f.allowedUnits = [.day, .hour, .minute] + f.maximumUnitCount = 1 + return f + }() + + /// The series outlives the snapshot, so fall back to what the app last + /// published rather than to mg/dL, which would relabel an mmol/L chart. + private var unit: GlucoseSnapshot.Unit { + entry.snapshot?.unit ?? LAAppGroupSettings.preferredUnit() + } + + private var thresholds: (low: Double, high: Double) { + LAAppGroupSettings.thresholdsMgdl() + } + + private var isFullColor: Bool { + renderingMode == .fullColor + } + + /// Missing data is treated as stale: never show a number without an age. + private var isStale: Bool { + guard let age = entry.snapshotAge else { return true } + return age >= Self.staleThreshold + } + + /// The last entry of a timeline can be left on screen indefinitely, so its + /// age is only a lower bound and is marked with a "+". + private var ageText: String { + guard let age = entry.snapshotAge, let text = Self.ageFormatter.string(from: max(age, 60)) else { return "" } + return entry.isLast ? text + "+" : text + } + + private func color(forMgdl mgdl: Double, thresholds t: (low: Double, high: Double)) -> Color { + if mgdl < t.low { + return Color(.systemRed) + } else if mgdl > t.high { + return Color(.systemOrange) + } else { + return Color(.systemGreen) + } + } + + var body: some View { + ZStack(alignment: .topLeading) { + chart + .frame(height: Self.chartHeight) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .mask(quietCorner) + + reading(thresholds: thresholds) + .padding(.leading, Self.inset) + .padding(.top, 10) + + metricBand + } + .containerBackground(.fill.tertiary, for: .widget) + } + + // MARK: - Reading + + /// Holds the chart back where the reading sits rather than covering it: a + /// dense run of points keeps its shape there at a fraction of the contrast. + /// Both ramps reach zero inside the widget, so the fade has no visible edge. + private var quietCorner: some View { + Rectangle() + .fill(.black) + .overlay { + Rectangle() + .fill(.black.opacity(0.86)) + .mask(Self.ramp(.leading, .trailing, hold: 0.33, fade: 0.62)) + .mask(Self.ramp(.top, .bottom, hold: 0.74, fade: 1)) + .blendMode(.destinationOut) + } + .compositingGroup() + } + + private static func ramp(_ from: UnitPoint, _ to: UnitPoint, hold: Double, fade: Double) -> LinearGradient { + LinearGradient( + stops: [ + .init(color: .black, location: 0), + .init(color: .black, location: hold), + .init(color: .clear, location: fade), + ], + startPoint: from, + endPoint: to + ) + } + + /// A halo in the widget's background colour, so the reading survives where the + /// corner fade has run out. Tinted appearances would flatten it into a glow. + @ViewBuilder + private func halo(_ content: some View) -> some View { + if isFullColor, entry.series != nil { + content + .shadow(color: Color(.systemBackground).opacity(0.9), radius: 1.5) + .shadow(color: Color(.systemBackground).opacity(0.45), radius: 5) + } else { + content + } + } + + @ViewBuilder + private func reading(thresholds t: (low: Double, high: Double)) -> some View { + if let snapshot = entry.snapshot { + halo( + VStack(alignment: .leading, spacing: 1) { + HStack(alignment: .firstTextBaseline, spacing: 1) { + Text(LAFormat.glucose(snapshot)) + .font(.system(size: 40, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(isStale ? AnyShapeStyle(.secondary) : AnyShapeStyle(color(forMgdl: snapshot.glucose, thresholds: t))) + .minimumScaleFactor(0.6) + + if !isStale { + Text(LAFormat.trendArrow(snapshot)) + .font(.system(size: 21, weight: .semibold, design: .rounded)) + .foregroundStyle(color(forMgdl: snapshot.glucose, thresholds: t)) + } + } + .widgetAccentable() + + // The delta holds its place whatever the age: a stale reading + // still moved, and that direction is worth keeping on screen. + Text("\(LAFormat.delta(snapshot)) \(unit.displayName)") + .font(.system(size: 13.5, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.secondary) + + Text(LAFormat.updated(snapshot)) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.secondary) + + if isStale || snapshot.isNotLooping { + HStack(spacing: 7) { + if isStale { + warning("\(ageText) old", color: Color(.systemOrange)) + } + if snapshot.isNotLooping { + warning("Not Looping", color: Color(.systemRed)) + } + } + } + } + .lineLimit(1) + .minimumScaleFactor(0.8) + ) + } else { + halo( + VStack(alignment: .leading, spacing: 3) { + warning("No glucose", color: Color(.systemOrange), size: 16) + Text("Open Loop Follow") + .font(.system(size: 11, weight: .regular, design: .rounded)) + .foregroundStyle(.secondary) + } + .lineLimit(1) + .minimumScaleFactor(0.8) + ) + } + } + + private func warning(_ text: String, color: Color, size: CGFloat = 11.5) -> some View { + HStack(spacing: 3) { + Image(systemName: "exclamationmark.triangle.fill") + .widgetAccentedRenderingMode(.desaturated) + .font(.system(size: size - 1.5)) + Text(text) + .font(.system(size: size, weight: .semibold, design: .rounded)) + } + .foregroundStyle(color) + } + + // MARK: - Metrics + + // Aligned by top edge: an empty slot draws no text, so it has no baseline. + private var metricBand: some View { + HStack(alignment: .top, spacing: 8) { + ForEach(Array(entry.slots.prefix(4).enumerated()), id: \.offset) { _, option in + WidgetSlotView(option: option, snapshot: entry.snapshot, isStale: isStale) + } + } + .padding(.horizontal, Self.inset) + .padding(.bottom, 13) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + + // MARK: - Chart + + @ViewBuilder + private var chart: some View { + if let series = entry.series { + WidgetChartView(series: series, unit: unit, duration: entry.duration) + } else { + // Centred in what the floating reading leaves free, not in the widget. + Text("No recent glucose") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.leading, 110) + } + } +} + +struct LoopFollowWidget: Widget { + private let kind = "LoopFollowWidget" + + /// Keeps side-by-side builds apart in the widget gallery. + private var galleryName: String { + LAAppGroupSettings.showDisplayName() ? LAAppGroupSettings.displayName() : "Loop Follow" + } + + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: kind, + intent: GlucoseWidgetConfigurationIntent.self, + provider: WidgetTimelineProvider() + ) { entry in + LoopFollowWidgetView(entry: entry) + } + .configurationDisplayName(galleryName) + .description("Glucose at a glance.") + .supportedFamilies([.systemMedium]) + // The chart runs to the edges, so the widget insets its own content. + .contentMarginsDisabled() + } +} diff --git a/LoopFollowWidget/LoopFollowWidgetBundle.swift b/LoopFollowWidget/LoopFollowWidgetBundle.swift new file mode 100644 index 000000000..fabae2c3d --- /dev/null +++ b/LoopFollowWidget/LoopFollowWidgetBundle.swift @@ -0,0 +1,12 @@ +// LoopFollow +// LoopFollowWidgetBundle.swift + +import SwiftUI +import WidgetKit + +@main +struct LoopFollowWidgetBundle: WidgetBundle { + var body: some Widget { + LoopFollowWidget() + } +} diff --git a/LoopFollowWidget/WidgetChartView.swift b/LoopFollowWidget/WidgetChartView.swift new file mode 100644 index 000000000..99e667900 --- /dev/null +++ b/LoopFollowWidget/WidgetChartView.swift @@ -0,0 +1,174 @@ +// LoopFollow +// WidgetChartView.swift + +import Charts +import SwiftUI +import WidgetKit + +/// Glucose scatter over the configured duration, drawn edge to edge as the +/// widget's backdrop. +/// +/// Values stay in mg/dL until `display(_:)` converts them to the user's unit. +struct WidgetChartView: View { + let series: GlucoseChartSeries + let unit: GlucoseSnapshot.Unit + let duration: WidgetChartDuration + + /// Tinted and clear appearances flatten the plot to one colour, so the marks + /// fall back to opacity for separation. + @Environment(\.widgetRenderingMode) private var renderingMode + + /// Headroom above and below the extremes, so points are not drawn on the edge. + private static let paddingMgdl: Double = 22 + + /// Extra headroom at the top, where the chart meets the widget's own edge. + private static let topPaddingMgdl: Double = 24 + + /// Narrowest Y domain, so flat data still reads as flat. + private static let minSpanMgdl: Double = 120 + + private var isFullColor: Bool { + renderingMode == .fullColor + } + + private var thresholds: (low: Double, high: Double) { + LAAppGroupSettings.thresholdsMgdl() + } + + /// Window drawn on the X axis. + private var window: TimeInterval { + duration.seconds + } + + /// Keeps the first and last marks clear of the widget's rounded corners. + private var edgeSlack: TimeInterval { + window * 0.02 + } + + /// Dots have to shrink as the span grows or the long windows read as a band. + private var symbolSize: Double { + switch duration { + case .oneHour, .threeHours, .sixHours: 16 + case .twelveHours: 12 + case .twentyFourHours: 9 + } + } + + /// Every reading in the window is drawn. A day is a few hundred marks, well + /// inside what the chart handles, and thinning a glucose chart risks losing + /// the excursion that made it worth looking at. + private var points: [GlucoseChartPoint] { + let start = Date().addingTimeInterval(-window) + let visible = series.points.filter { $0.date >= start } + + // Marks are identified by the whole point, so a reading posted twice + // would collide and one of the two would go missing. + var deduped: [GlucoseChartPoint] = [] + for point in visible where point != deduped.last { + deduped.append(point) + } + return deduped + } + + private func display(_ mgdl: Double) -> Double { + switch unit { + case .mgdl: return mgdl + case .mmol: return GlucoseConversion.toMmol(mgdl) + } + } + + /// Follows the data but always contains both threshold lines, so nothing + /// charted is ever clipped out of view. + private func domainMgdl(for values: [Double], thresholds t: (low: Double, high: Double)) -> ClosedRange { + // Settings import accepts the thresholds unvalidated, so order them here: + // a range whose lower bound exceeds its upper one is a runtime trap. + let low = min(t.low, t.high) + let high = max(t.low, t.high) + let lower = min(values.min() ?? low, low) - Self.paddingMgdl + let upper = max(values.max() ?? high, high) + Self.topPaddingMgdl + guard upper - lower < Self.minSpanMgdl else { return lower ... upper } + + let middle = (lower + upper) / 2 + return (middle - Self.minSpanMgdl / 2) ... (middle + Self.minSpanMgdl / 2) + } + + private func color(forMgdl mgdl: Double, thresholds t: (low: Double, high: Double)) -> Color { + if mgdl < t.low { + return Color(.systemRed) + } else if mgdl > t.high { + return Color(.systemOrange) + } else { + return Color(.systemGreen) + } + } + + var body: some View { + let visible = points + let t = thresholds + + let now = Date() + let start = now.addingTimeInterval(-window - edgeSlack) + let end = max(now, visible.last?.date ?? now).addingTimeInterval(edgeSlack) + + let domain = domainMgdl(for: visible.map(\.value), thresholds: t) + + Chart { + RuleMark(y: .value("High", display(t.high))) + .foregroundStyle(Color(.systemOrange).opacity(isFullColor ? 0.7 : 0.4)) + .lineStyle(.init(lineWidth: 1, dash: [4, 4])) + + RuleMark(y: .value("Low", display(t.low))) + .foregroundStyle(Color(.systemRed).opacity(isFullColor ? 0.7 : 0.4)) + .lineStyle(.init(lineWidth: 1, dash: [4, 4])) + + ForEach(visible, id: \.self) { point in + PointMark( + x: .value("Time", point.date), + y: .value("Glucose", display(point.value)) + ) + .symbolSize(symbolSize) + .foregroundStyle(color(forMgdl: point.value, thresholds: t).opacity(isFullColor ? 1 : 0.55)) + } + } + .chartXScale(domain: start ... end) + .chartYScale(domain: display(domain.lowerBound) ... display(domain.upperBound)) + .chartXAxis { + AxisMarks(position: .automatic) { _ in + AxisGridLine(stroke: .init(lineWidth: 0.65, dash: [2, 3])) + .foregroundStyle(Color.primary.opacity(isFullColor ? 0.16 : 0.25)) + } + } + .chartYAxis { + AxisMarks(position: .trailing) { _ in + AxisGridLine(stroke: .init(lineWidth: 0.65, dash: [2, 3])) + .foregroundStyle(Color.primary.opacity(isFullColor ? 0.16 : 0.25)) + } + } + .chartPlotStyle { $0.frame(maxWidth: .infinity, maxHeight: .infinity) } + .overlay { + if visible.isEmpty { + // Centred in what the floating reading leaves free. + Text("No recent glucose") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.leading, 110) + } + } + } +} + +#Preview { + let now = Date() + let values: [Double] = [64, 72, 88, 104, 126, 149, 171, 188, 196, 184, 160, 138] + let points = values.enumerated().map { index, value in + GlucoseChartPoint(value: value, date: now.addingTimeInterval(Double(index - values.count) * 300)) + } + + return WidgetChartView( + series: GlucoseChartSeries(points: points, updatedAt: now), + unit: .mgdl, + duration: .standard + ) + .frame(height: 103) +} diff --git a/LoopFollowWidget/WidgetConfigurationIntent.swift b/LoopFollowWidget/WidgetConfigurationIntent.swift new file mode 100644 index 000000000..9918ad52d --- /dev/null +++ b/LoopFollowWidget/WidgetConfigurationIntent.swift @@ -0,0 +1,82 @@ +// LoopFollow +// WidgetConfigurationIntent.swift + +import AppIntents + +extension LiveActivitySlotOption: AppEnum { + static var typeDisplayRepresentation: TypeDisplayRepresentation { "Metric" } + + // The AppIntents metadata processor only accepts a literal dictionary here, + // so these titles repeat `displayName` instead of deriving from it. + static var caseDisplayRepresentations: [LiveActivitySlotOption: DisplayRepresentation] { + [ + .none: "Empty", + .delta: "Delta", + .projectedBG: "Projected BG", + .minMax: "Min/Max", + .iob: "IOB", + .cob: "COB", + .recBolus: "Rec. Bolus", + .autosens: "Autosens", + .tdd: "TDD", + .basal: "Basal", + .pump: "Pump", + .pumpBattery: "Pump Battery", + .battery: "Battery", + .target: "Target", + .isf: "ISF", + .carbRatio: "CR", + .sage: "SAGE", + .cage: "CAGE", + .iage: "IAGE", + .carbsToday: "Carbs today", + .override: "Override", + .profile: "Profile", + ] + } +} + +extension WidgetChartDuration: AppEnum { + static var typeDisplayRepresentation: TypeDisplayRepresentation { "Duration" } + + // Literal for the same reason as the slot titles above. + static var caseDisplayRepresentations: [WidgetChartDuration: DisplayRepresentation] { + [ + .oneHour: "1h", + .threeHours: "3h", + .sixHours: "6h", + .twelveHours: "12h", + .twentyFourHours: "24h", + ] + } +} + +/// Configuration presented by Edit Widget: the span of the chart, then one +/// parameter per metric block, using the same options as the Live Activity grid. +struct GlucoseWidgetConfigurationIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource { "Widget Options" } + static var description: IntentDescription { "Choose the chart duration and the metrics shown beside it." } + + // Spelled out rather than read from WidgetChartDuration.standard, for the + // compile-time constant rule noted below; keep the two in step. + @Parameter(title: "Duration", default: .threeHours) + var duration: WidgetChartDuration + + // @Parameter defaults must be compile-time constants, so these are spelled + // out rather than read from LiveActivitySlotDefaults; keep the two in step. + @Parameter(title: "Slot 1", default: .iob) + var slot1: LiveActivitySlotOption + + @Parameter(title: "Slot 2", default: .cob) + var slot2: LiveActivitySlotOption + + @Parameter(title: "Slot 3", default: .projectedBG) + var slot3: LiveActivitySlotOption + + @Parameter(title: "Slot 4", default: LiveActivitySlotOption.none) + var slot4: LiveActivitySlotOption + + var slots: [LiveActivitySlotOption] { + [slot1, slot2, slot3, slot4] + } +} diff --git a/LoopFollowWidget/WidgetDataSource.swift b/LoopFollowWidget/WidgetDataSource.swift new file mode 100644 index 000000000..73dca275f --- /dev/null +++ b/LoopFollowWidget/WidgetDataSource.swift @@ -0,0 +1,31 @@ +// LoopFollow +// WidgetDataSource.swift + +import Foundation + +/// Reads what the app cached in the App Group and, when that has gone stale, +/// tops it up from Nightscout. Nothing here writes back to the cache. +enum WidgetDataSource { + /// Three missed five-minute readings. + static let staleAfter: TimeInterval = 15 * 60 + + static func load() async -> (series: GlucoseChartSeries?, snapshot: GlucoseSnapshot?) { + let cached = GlucoseChartSeriesStore.shared.load() + let snapshot = GlucoseSnapshotStore.shared.load() + + if let cached = cached, cached.age <= staleAfter { + return (cached, snapshot) + } + + let url = LAAppGroupSettings.nightscoutURL() + guard !url.isEmpty else { return (cached, snapshot) } + + // Entries cannot supply the snapshot's secondary metrics. + guard let fetched = await NightscoutChartFetcher.fetchSeries(baseURL: url, token: LAAppGroupSettings.nightscoutToken()), + (fetched.points.last?.date ?? .distantPast) >= (cached?.points.last?.date ?? .distantPast) + else { + return (cached, snapshot) + } + return (fetched, snapshot) + } +} diff --git a/LoopFollowWidget/WidgetInfo.plist b/LoopFollowWidget/WidgetInfo.plist new file mode 100644 index 000000000..fedac909f --- /dev/null +++ b/LoopFollowWidget/WidgetInfo.plist @@ -0,0 +1,27 @@ + + + + + CFBundleDisplayName + LoopFollowWidget + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/LoopFollowWidget/WidgetSlotView.swift b/LoopFollowWidget/WidgetSlotView.swift new file mode 100644 index 000000000..0ff5e7c47 --- /dev/null +++ b/LoopFollowWidget/WidgetSlotView.swift @@ -0,0 +1,47 @@ +// LoopFollow +// WidgetSlotView.swift + +import SwiftUI + +/// One configurable metric block, mirroring the Live Activity grid cell but +/// sized for the home screen and using system colors instead of white on tint. +struct WidgetSlotView: View { + let option: LiveActivitySlotOption + let snapshot: GlucoseSnapshot? + + /// These values come from the same snapshot as the glucose reading, so they + /// are demoted alongside it rather than reading as current. + let isStale: Bool + + private var value: String { + guard let snapshot else { return "—" } + return slotFormattedValue(option: option, snapshot: snapshot) + } + + var body: some View { + if option == .none { + // Height is pinned: an unconstrained Color would claim the whole + // widget and push the metric row off its edge. + Color.clear + .frame(maxWidth: .infinity, maxHeight: 0) + } else { + VStack(alignment: .leading, spacing: 0) { + Text(option.gridLabel.uppercased()) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .tracking(0.4) + .foregroundStyle(isStale ? AnyShapeStyle(.tertiary) : AnyShapeStyle(.secondary)) + .lineLimit(1) + .minimumScaleFactor(0.75) + + Text(value) + .font(.system(size: 17, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(isStale ? AnyShapeStyle(.secondary) : AnyShapeStyle(.primary)) + .lineLimit(1) + .minimumScaleFactor(0.6) + .allowsTightening(true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/LoopFollowWidget/WidgetTimelineProvider.swift b/LoopFollowWidget/WidgetTimelineProvider.swift new file mode 100644 index 000000000..ccd514580 --- /dev/null +++ b/LoopFollowWidget/WidgetTimelineProvider.swift @@ -0,0 +1,93 @@ +// LoopFollow +// WidgetTimelineProvider.swift + +import WidgetKit + +/// Supplies the home screen widget from the App Group caches written by the app. +/// +/// Entries carry the same data at advancing dates, so the reading keeps aging +/// into its stale state even when WidgetKit cannot afford to reload us. +struct WidgetTimelineProvider: AppIntentTimelineProvider { + private static let refreshInterval: TimeInterval = 5 * 60 + + /// WidgetKit keeps drawing the last entry once the timeline runs out, so a + /// short timeline makes an old reading claim to be recent. Cover a stretch + /// long enough that a suspended app cannot hide hours of silence: every five + /// minutes for the first hour, then every fifteen out to the horizon. + private static let horizon: TimeInterval = 4 * 3600 + + private static let entryOffsets: [TimeInterval] = { + let fine = stride(from: 0, to: 3600, by: refreshInterval).map { $0 } + let coarse = stride(from: 3600, through: horizon, by: 15 * 60).map { $0 } + return fine + coarse + }() + + func placeholder(in _: Context) -> GlucoseWidgetEntry { + Self.sampleEntry(slots: LiveActivitySlotDefaults.all, duration: .standard) + } + + func snapshot(for configuration: GlucoseWidgetConfigurationIntent, in context: Context) async -> GlucoseWidgetEntry { + if context.isPreview { + return Self.sampleEntry(slots: configuration.slots, duration: configuration.duration) + } + let (series, snapshot) = await WidgetDataSource.load() + return GlucoseWidgetEntry(date: Date(), series: series, snapshot: snapshot, slots: configuration.slots, duration: configuration.duration) + } + + func timeline(for configuration: GlucoseWidgetConfigurationIntent, in _: Context) async -> Timeline { + let now = Date() + let (series, snapshot) = await WidgetDataSource.load() + + let lastOffset = Self.entryOffsets.last + let entries = Self.entryOffsets.map { offset in + GlucoseWidgetEntry( + date: now.addingTimeInterval(offset), + series: series, + snapshot: snapshot, + slots: configuration.slots, + duration: configuration.duration, + isLast: offset == lastOffset + ) + } + + return Timeline(entries: entries, policy: .after(now.addingTimeInterval(Self.refreshInterval))) + } + + // MARK: - Gallery sample + + private static func sampleEntry(slots: [LiveActivitySlotOption], duration: WidgetChartDuration) -> GlucoseWidgetEntry { + let now = Date() + // Spread across whatever span was picked, so the gallery preview fills + // its chart at every duration. + let values: [Double] = [96, 104, 112, 118, 121, 126, 133, 141, 148, 152, 149, 142, 136, 131, 127, 124] + let spacing = duration.seconds / Double(values.count) + let points = values.enumerated().map { index, value in + GlucoseChartPoint(value: value, date: now.addingTimeInterval(Double(index - values.count + 1) * spacing)) + } + + let snapshot = GlucoseSnapshot( + glucose: 124, + delta: -3, + trend: .downSlight, + updatedAt: now, + iob: 1.2, + cob: 18, + projected: 118, + recBolus: 0.35, + basalRate: "0.75 U/hr", + tdd: 24.6, + targetLowMgdl: 100, + targetHighMgdl: 100, + unit: .mgdl, + isNotLooping: false + ) + + return GlucoseWidgetEntry( + date: now, + series: GlucoseChartSeries(points: points, updatedAt: now), + snapshot: snapshot, + slots: slots, + duration: duration + ) + } +} diff --git a/LoopFollowWidgetExtension.entitlements b/LoopFollowWidgetExtension.entitlements new file mode 100644 index 000000000..5b963cc90 --- /dev/null +++ b/LoopFollowWidgetExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.$(unique_id).LoopFollow$(app_suffix) + + + diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 120b9f061..e8253bcca 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -56,7 +56,8 @@ platform :ios do git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ "com.#{TEAMID}.LoopFollow", - "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension", + "com.#{TEAMID}.LoopFollow.LoopFollowWidget" ] ) @@ -78,6 +79,13 @@ platform :ios do targets: ["LoopFollowLAExtensionExtension"] ) + update_code_signing_settings( + path: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj", + profile_name: mapping["com.#{TEAMID}.LoopFollow.LoopFollowWidget"], + code_sign_identity: "iPhone Distribution", + targets: ["LoopFollowWidgetExtension"] + ) + gym( export_method: "app-store", scheme: "LoopFollow", @@ -88,7 +96,8 @@ platform :ios do export_options: { provisioningProfiles: { "com.#{TEAMID}.LoopFollow" => mapping["com.#{TEAMID}.LoopFollow"], - "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" => mapping["com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"] + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" => mapping["com.#{TEAMID}.LoopFollow.LoopFollowLAExtension"], + "com.#{TEAMID}.LoopFollow.LoopFollowWidget" => mapping["com.#{TEAMID}.LoopFollow.LoopFollowWidget"] } } ) @@ -147,6 +156,10 @@ platform :ios do Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS ]) + configure_bundle_id("LoopFollow Widget Extension", "com.#{TEAMID}.LoopFollow.LoopFollowWidget", [ + Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS + ]) + end desc "Provision Certificates" @@ -167,7 +180,8 @@ platform :ios do git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ "com.#{TEAMID}.LoopFollow", - "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension" + "com.#{TEAMID}.LoopFollow.LoopFollowLAExtension", + "com.#{TEAMID}.LoopFollow.LoopFollowWidget" ] ) end From 1e5de8eca8ea6f91ca992bec07555bcc1ec08773 Mon Sep 17 00:00:00 2001 From: Justin Maier Date: Mon, 27 Jul 2026 19:19:37 -0600 Subject: [PATCH 07/13] Widget: full bleed chart, relative reading age The chart now fills the whole widget instead of a band across the top, with the reading and the metric row floating over it. Legibility comes from holding the plot back underneath the text rather than laying a panel over it: two soft fields, one over the reading and one along the base, combined into a single mask whose ramps all reach zero inside the widget, so there is no edge or isoline anywhere. The widget background shows through where the plot is held back, which leaves the tinted and clear appearances free to substitute their own. The absolute clock is replaced by how long ago the reading was taken, using a date styled Text that the system advances on screen without spending a timeline reload. The age therefore stays true through exactly the stretches where WidgetKit will not refresh us and an old number is most dangerous. The offset style rounds down to a single unit and signs its output, so a reading timestamped in the future by a skewed clock shows as a minus rather than passing for current. Stale no longer states the age twice. The one age line turns orange and takes the warning symbol, alongside the greyed number and the dropped trend arrow. The last entry marker is gone with it: self-updating text keeps counting past the timeline horizon, so the age is never a frozen floor that needs marking. --- LoopFollowWidget/GlucoseWidgetEntry.swift | 5 - LoopFollowWidget/LoopFollowWidget.swift | 102 ++++++++++-------- LoopFollowWidget/WidgetTimelineProvider.swift | 4 +- 3 files changed, 58 insertions(+), 53 deletions(-) diff --git a/LoopFollowWidget/GlucoseWidgetEntry.swift b/LoopFollowWidget/GlucoseWidgetEntry.swift index bfdccbec8..ad54e1740 100644 --- a/LoopFollowWidget/GlucoseWidgetEntry.swift +++ b/LoopFollowWidget/GlucoseWidgetEntry.swift @@ -13,11 +13,6 @@ struct GlucoseWidgetEntry: TimelineEntry { /// Span of history the chart draws, chosen in Edit Widget. var duration: WidgetChartDuration = .standard - /// Set on the final entry of a timeline. WidgetKit keeps that entry on screen - /// for as long as it takes to get around to a reload, so its age is a floor - /// rather than a measurement and is shown as such. - var isLast: Bool = false - /// Age of the reading and metrics, or nil when there is no snapshot. The /// Nightscout fallback renews only the series, so anything drawn from the /// snapshot must be judged by this, never by the chart. diff --git a/LoopFollowWidget/LoopFollowWidget.swift b/LoopFollowWidget/LoopFollowWidget.swift index ce1dd7a0f..ac480f91a 100644 --- a/LoopFollowWidget/LoopFollowWidget.swift +++ b/LoopFollowWidget/LoopFollowWidget.swift @@ -17,19 +17,8 @@ struct LoopFollowWidgetView: View { /// Past this age the reading is no longer presented as the current value. private static let staleThreshold: TimeInterval = 15 * 60 - /// Height of the chart backdrop. What is left below it is the metric band. - private static let chartHeight: CGFloat = 112 - private static let inset: CGFloat = 15 - private static let ageFormatter: DateComponentsFormatter = { - let f = DateComponentsFormatter() - f.unitsStyle = .abbreviated - f.allowedUnits = [.day, .hour, .minute] - f.maximumUnitCount = 1 - return f - }() - /// The series outlives the snapshot, so fall back to what the app last /// published rather than to mg/dL, which would relabel an mmol/L chart. private var unit: GlucoseSnapshot.Unit { @@ -50,13 +39,6 @@ struct LoopFollowWidgetView: View { return age >= Self.staleThreshold } - /// The last entry of a timeline can be left on screen indefinitely, so its - /// age is only a lower bound and is marked with a "+". - private var ageText: String { - guard let age = entry.snapshotAge, let text = Self.ageFormatter.string(from: max(age, 60)) else { return "" } - return entry.isLast ? text + "+" : text - } - private func color(forMgdl mgdl: Double, thresholds t: (low: Double, high: Double)) -> Color { if mgdl < t.low { return Color(.systemRed) @@ -70,9 +52,8 @@ struct LoopFollowWidgetView: View { var body: some View { ZStack(alignment: .topLeading) { chart - .frame(height: Self.chartHeight) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .mask(quietCorner) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .mask(legibilityMask) reading(thresholds: thresholds) .padding(.leading, Self.inset) @@ -83,20 +64,34 @@ struct LoopFollowWidgetView: View { .containerBackground(.fill.tertiary, for: .widget) } - // MARK: - Reading + // MARK: - Legibility - /// Holds the chart back where the reading sits rather than covering it: a - /// dense run of points keeps its shape there at a fraction of the contrast. - /// Both ramps reach zero inside the widget, so the fade has no visible edge. - private var quietCorner: some View { + /// The chart is the whole widget, so the reading and the metrics sit on top of + /// it. Rather than laying a panel over the plot, the plot is held back + /// underneath them: a dense run of points keeps its shape at a fraction of the + /// contrast, and what shows through is the widget's own background, which the + /// tinted and clear appearances are free to replace. + /// + /// Every ramp reaches its end value inside the widget, so no edge is drawn. + private var legibilityMask: some View { Rectangle() .fill(.black) .overlay { - Rectangle() - .fill(.black.opacity(0.86)) - .mask(Self.ramp(.leading, .trailing, hold: 0.33, fade: 0.62)) - .mask(Self.ramp(.top, .bottom, hold: 0.74, fade: 1)) - .blendMode(.destinationOut) + // Union of the two quiet regions. Overlapping soft fields compose + // to a soft field, so the seam between them is not a contour. + ZStack { + Rectangle() + .fill(.black) + .mask(Self.ramp(.leading, .trailing, hold: 0.36, fade: 0.66)) + .mask(Self.ramp(.top, .bottom, hold: 0.5, fade: 0.88)) + + Rectangle() + .fill(.black) + .mask(Self.ramp(.bottom, .top, hold: 0.22, fade: 0.46)) + } + .compositingGroup() + .opacity(0.86) + .blendMode(.destinationOut) } .compositingGroup() } @@ -153,20 +148,10 @@ struct LoopFollowWidgetView: View { .monospacedDigit() .foregroundStyle(.secondary) - Text(LAFormat.updated(snapshot)) - .font(.system(size: 12, weight: .semibold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(.secondary) + age(of: snapshot) - if isStale || snapshot.isNotLooping { - HStack(spacing: 7) { - if isStale { - warning("\(ageText) old", color: Color(.systemOrange)) - } - if snapshot.isNotLooping { - warning("Not Looping", color: Color(.systemRed)) - } - } + if snapshot.isNotLooping { + warning("Not Looping", color: Color(.systemRed)) } } .lineLimit(1) @@ -186,6 +171,31 @@ struct LoopFollowWidgetView: View { } } + /// How long ago the reading was taken, as a clock the system advances itself. + /// A date styled `Text` is re-rendered on screen without spending a timeline + /// reload, so the age stays true through exactly the stretches where WidgetKit + /// is refusing to refresh us and an old number is most dangerous. + /// + /// The offset style rounds down to a single unit, so the age reads as calmly as + /// the five minute data behind it and is never overstated as fresh. It is also + /// the only style that signs its output: a reading timestamped in the future by + /// a skewed clock shows as a minus instead of passing for current. + /// + /// Anchored to the reading, not to the entry that happens to be on screen. + private func age(of snapshot: GlucoseSnapshot) -> some View { + HStack(spacing: 3) { + if isStale { + Image(systemName: "exclamationmark.triangle.fill") + .widgetAccentedRenderingMode(.desaturated) + .font(.system(size: 10.5)) + } + (Text(snapshot.updatedAt, style: .offset) + Text(" ago")) + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .monospacedDigit() + } + .foregroundStyle(isStale ? AnyShapeStyle(Color(.systemOrange)) : AnyShapeStyle(.secondary)) + } + private func warning(_ text: String, color: Color, size: CGFloat = 11.5) -> some View { HStack(spacing: 3) { Image(systemName: "exclamationmark.triangle.fill") @@ -217,7 +227,9 @@ struct LoopFollowWidgetView: View { private var chart: some View { if let series = entry.series { WidgetChartView(series: series, unit: unit, duration: entry.duration) - } else { + } else if entry.snapshot != nil { + // Only worth saying when a reading is on screen without a chart to put + // it in. With nothing at all, the reading block already says so. // Centred in what the floating reading leaves free, not in the widget. Text("No recent glucose") .font(.system(size: 13, weight: .semibold, design: .rounded)) diff --git a/LoopFollowWidget/WidgetTimelineProvider.swift b/LoopFollowWidget/WidgetTimelineProvider.swift index ccd514580..ae977675e 100644 --- a/LoopFollowWidget/WidgetTimelineProvider.swift +++ b/LoopFollowWidget/WidgetTimelineProvider.swift @@ -38,15 +38,13 @@ struct WidgetTimelineProvider: AppIntentTimelineProvider { let now = Date() let (series, snapshot) = await WidgetDataSource.load() - let lastOffset = Self.entryOffsets.last let entries = Self.entryOffsets.map { offset in GlucoseWidgetEntry( date: now.addingTimeInterval(offset), series: series, snapshot: snapshot, slots: configuration.slots, - duration: configuration.duration, - isLast: offset == lastOffset + duration: configuration.duration ) } From 6f623cdba715b0845b55ccf799f98375a00ed1e9 Mon Sep 17 00:00:00 2001 From: Justin Maier Date: Mon, 27 Jul 2026 20:18:47 -0600 Subject: [PATCH 08/13] Widget: keep both threshold lines clear of the metric row, add a line style The metric row overlays the base of the full bleed chart, so the low threshold rule was drawn underneath it and could not be seen. The plot now maps its content into the height between a reserved band at the base and a strip at the top, while the scale still spans the whole view, so the chart keeps bleeding to every edge and both rules stay readable wherever the readings happen to sit. The age drops the signed offset style for the unsigned relative one. The signed style was there to expose a reading stamped in the future by a skewed uploader clock, so that case is now caught in the entry and says "clock ahead" instead of counting up from a future date, and demotes the reading the way any unknown age does. Edit Widget gains a Line Style parameter. Dots stays the default. The line is split into one run per threshold band so the low and high colour signal survives, and cut wherever the sensor stopped reporting for more than twenty minutes rather than curving through the gap. Monotone interpolation, since a spline that overshoots would draw a low that never happened. --- .../LiveActivity/LAAppGroupSettings.swift | 20 +++ LoopFollowWidget/GlucoseWidgetEntry.swift | 16 +++ LoopFollowWidget/LoopFollowWidget.swift | 39 ++++-- LoopFollowWidget/WidgetChartView.swift | 122 ++++++++++++++++-- .../WidgetConfigurationIntent.swift | 23 +++- LoopFollowWidget/WidgetTimelineProvider.swift | 21 ++- 6 files changed, 213 insertions(+), 28 deletions(-) diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 63668fb2d..2cfd23bd8 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -174,6 +174,26 @@ enum WidgetChartDuration: String, CaseIterable, Codable { } } +// MARK: - Widget chart style + +/// How the home screen widget chart draws the readings, chosen from the +/// widget's own Edit Widget sheet. +enum WidgetChartStyle: String, CaseIterable, Codable { + case dots + case line + + /// What the widget draws until the user picks something else. The + /// @Parameter default in the configuration intent has to match. + static let standard: WidgetChartStyle = .dots + + var displayName: String { + switch self { + case .dots: "Dots" + case .line: "Line" + } + } +} + // MARK: - App Group settings /// Minimal App Group settings needed by the Live Activity UI. diff --git a/LoopFollowWidget/GlucoseWidgetEntry.swift b/LoopFollowWidget/GlucoseWidgetEntry.swift index ad54e1740..33da3aa1e 100644 --- a/LoopFollowWidget/GlucoseWidgetEntry.swift +++ b/LoopFollowWidget/GlucoseWidgetEntry.swift @@ -13,6 +13,13 @@ struct GlucoseWidgetEntry: TimelineEntry { /// Span of history the chart draws, chosen in Edit Widget. var duration: WidgetChartDuration = .standard + /// How the readings are drawn, chosen in Edit Widget. + var chartStyle: WidgetChartStyle = .standard + + /// Clock disagreement small enough to be ordinary drift between the phone + /// and whatever uploaded the reading. + private static let clockSkewTolerance: TimeInterval = 60 + /// Age of the reading and metrics, or nil when there is no snapshot. The /// Nightscout fallback renews only the series, so anything drawn from the /// snapshot must be judged by this, never by the chart. @@ -20,4 +27,13 @@ struct GlucoseWidgetEntry: TimelineEntry { guard let updatedAt = snapshot?.updatedAt else { return nil } return max(0, date.timeIntervalSince(updatedAt)) } + + /// A reading stamped far enough ahead of us that its age cannot be told at + /// all: the uploader's clock is wrong, so the reading may be any age. Taken + /// from the raw difference, since `snapshotAge` floors at zero and would + /// report a skewed reading as brand new. + var isTimestampAhead: Bool { + guard let updatedAt = snapshot?.updatedAt else { return false } + return updatedAt.timeIntervalSince(date) > Self.clockSkewTolerance + } } diff --git a/LoopFollowWidget/LoopFollowWidget.swift b/LoopFollowWidget/LoopFollowWidget.swift index ac480f91a..5f82f243f 100644 --- a/LoopFollowWidget/LoopFollowWidget.swift +++ b/LoopFollowWidget/LoopFollowWidget.swift @@ -33,9 +33,16 @@ struct LoopFollowWidgetView: View { renderingMode == .fullColor } - /// Missing data is treated as stale: never show a number without an age. + /// Height the metric row claims along the base, and the strip at the top the + /// reading needs. The chart keeps its plot content out of both, so nothing it + /// draws, the threshold lines above all, ends up underneath the text. + private static let metricBandHeight: CGFloat = 50 + private static let readingHeadroom: CGFloat = 16 + + /// Missing data is treated as stale: never show a number without an age. So + /// is a reading from the future, whose real age is unknown rather than zero. private var isStale: Bool { - guard let age = entry.snapshotAge else { return true } + guard let age = entry.snapshotAge, !entry.isTimestampAhead else { return true } return age >= Self.staleThreshold } @@ -176,10 +183,9 @@ struct LoopFollowWidgetView: View { /// reload, so the age stays true through exactly the stretches where WidgetKit /// is refusing to refresh us and an old number is most dangerous. /// - /// The offset style rounds down to a single unit, so the age reads as calmly as - /// the five minute data behind it and is never overstated as fresh. It is also - /// the only style that signs its output: a reading timestamped in the future by - /// a skewed clock shows as a minus instead of passing for current. + /// The relative style is unsigned, so it would count up from a timestamp in + /// the future and read as fresh. That case is caught before it is drawn and + /// says so plainly instead, since an age cannot be told from a wrong clock. /// /// Anchored to the reading, not to the entry that happens to be on screen. private func age(of snapshot: GlucoseSnapshot) -> some View { @@ -189,9 +195,15 @@ struct LoopFollowWidgetView: View { .widgetAccentedRenderingMode(.desaturated) .font(.system(size: 10.5)) } - (Text(snapshot.updatedAt, style: .offset) + Text(" ago")) - .font(.system(size: 12, weight: .semibold, design: .rounded)) - .monospacedDigit() + Group { + if entry.isTimestampAhead { + Text("clock ahead") + } else { + Text(snapshot.updatedAt, style: .relative) + Text(" ago") + } + } + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .monospacedDigit() } .foregroundStyle(isStale ? AnyShapeStyle(Color(.systemOrange)) : AnyShapeStyle(.secondary)) } @@ -226,7 +238,14 @@ struct LoopFollowWidgetView: View { @ViewBuilder private var chart: some View { if let series = entry.series { - WidgetChartView(series: series, unit: unit, duration: entry.duration) + WidgetChartView( + series: series, + unit: unit, + duration: entry.duration, + style: entry.chartStyle, + bottomReserve: Self.metricBandHeight, + topReserve: Self.readingHeadroom + ) } else if entry.snapshot != nil { // Only worth saying when a reading is on screen without a chart to put // it in. With nothing at all, the reading block already says so. diff --git a/LoopFollowWidget/WidgetChartView.swift b/LoopFollowWidget/WidgetChartView.swift index 99e667900..0c11e286b 100644 --- a/LoopFollowWidget/WidgetChartView.swift +++ b/LoopFollowWidget/WidgetChartView.swift @@ -13,6 +13,13 @@ struct WidgetChartView: View { let series: GlucoseChartSeries let unit: GlucoseSnapshot.Unit let duration: WidgetChartDuration + var style: WidgetChartStyle = .standard + + /// Bands at the base and the top of the widget that the caller draws its own + /// text over. Nothing is plotted into them, so the threshold lines stay + /// readable wherever the data happens to sit. + var bottomReserve: CGFloat = 0 + var topReserve: CGFloat = 0 /// Tinted and clear appearances flatten the plot to one colour, so the marks /// fall back to opacity for separation. @@ -54,6 +61,19 @@ struct WidgetChartView: View { } } + private var lineWidth: Double { + switch duration { + case .oneHour, .threeHours, .sixHours: 2.6 + case .twelveHours: 2.2 + case .twentyFourHours: 1.8 + } + } + + /// Longer than this between two readings and the line is cut rather than + /// carried across: a curve drawn through a sensor dropout is history that + /// never happened. Three missed readings at the usual five minute cadence. + private static let maxGap: TimeInterval = 20 * 60 + /// Every reading in the window is drawn. A day is a few hundred marks, well /// inside what the chart handles, and thinning a glucose chart risks losing /// the excursion that made it worth looking at. @@ -92,6 +112,54 @@ struct WidgetChartView: View { return (middle - Self.minSpanMgdl / 2) ... (middle + Self.minSpanMgdl / 2) } + /// Lifts the plotted range clear of the reserved bands: everything that has to + /// be seen is squeezed into the height between them, while the scale itself + /// still spans the whole view, so the chart keeps bleeding to all four edges. + private func plotted(_ content: ClosedRange, height: CGFloat) -> ClosedRange { + guard height > 0 else { return content } + let below = Double(bottomReserve / height) + let usable = 1 - below - Double(topReserve / height) + guard usable > 0.3 else { return content } + + let span = (content.upperBound - content.lowerBound) / usable + let lower = content.lowerBound - span * below + return lower ... (lower + span) + } + + /// Which threshold band a reading falls in, so a run of readings that share + /// one can be drawn as a single line in a single colour. + private func band(_ mgdl: Double, thresholds t: (low: Double, high: Double)) -> Int { + if mgdl < t.low { return -1 } else if mgdl > t.high { return 1 } else { return 0 } + } + + /// Splits the readings into stretches that can each be drawn as one line: a + /// new run starts wherever the colour changes or the sensor stopped + /// reporting. Runs that meet in time repeat the joining reading, so a change + /// of colour leaves no hole in the trace, while a gap does. + private func runs(_ visible: [GlucoseChartPoint], thresholds t: (low: Double, high: Double)) -> [[GlucoseChartPoint]] { + var result: [[GlucoseChartPoint]] = [] + var current: [GlucoseChartPoint] = [] + + for point in visible { + guard let previous = current.last else { + current = [point] + continue + } + if point.date.timeIntervalSince(previous.date) > Self.maxGap { + result.append(current) + current = [point] + } else if band(previous.value, thresholds: t) != band(point.value, thresholds: t) { + current.append(point) + result.append(current) + current = [point] + } else { + current.append(point) + } + } + if !current.isEmpty { result.append(current) } + return result + } + private func color(forMgdl mgdl: Double, thresholds t: (low: Double, high: Double)) -> Color { if mgdl < t.low { return Color(.systemRed) @@ -103,6 +171,12 @@ struct WidgetChartView: View { } var body: some View { + GeometryReader { proxy in + chart(height: proxy.size.height) + } + } + + private func chart(height: CGFloat) -> some View { let visible = points let t = thresholds @@ -110,9 +184,10 @@ struct WidgetChartView: View { let start = now.addingTimeInterval(-window - edgeSlack) let end = max(now, visible.last?.date ?? now).addingTimeInterval(edgeSlack) - let domain = domainMgdl(for: visible.map(\.value), thresholds: t) + let content = domainMgdl(for: visible.map(\.value), thresholds: t) + let domain = plotted(content, height: height) - Chart { + return Chart { RuleMark(y: .value("High", display(t.high))) .foregroundStyle(Color(.systemOrange).opacity(isFullColor ? 0.7 : 0.4)) .lineStyle(.init(lineWidth: 1, dash: [4, 4])) @@ -121,13 +196,42 @@ struct WidgetChartView: View { .foregroundStyle(Color(.systemRed).opacity(isFullColor ? 0.7 : 0.4)) .lineStyle(.init(lineWidth: 1, dash: [4, 4])) - ForEach(visible, id: \.self) { point in - PointMark( - x: .value("Time", point.date), - y: .value("Glucose", display(point.value)) - ) - .symbolSize(symbolSize) - .foregroundStyle(color(forMgdl: point.value, thresholds: t).opacity(isFullColor ? 1 : 0.55)) + if style == .line { + ForEach(Array(runs(visible, thresholds: t).enumerated()), id: \.offset) { index, run in + // A reading left alone by a gap on both sides has no line to + // be part of, so it is drawn as the point it is. + if run.count == 1, let point = run.first { + PointMark( + x: .value("Time", point.date), + y: .value("Glucose", display(point.value)) + ) + .symbolSize(symbolSize) + .foregroundStyle(color(forMgdl: point.value, thresholds: t).opacity(isFullColor ? 1 : 0.55)) + } else { + ForEach(run, id: \.self) { point in + LineMark( + x: .value("Time", point.date), + y: .value("Glucose", display(point.value)), + series: .value("Run", index) + ) + } + // Monotone, not Catmull-Rom: a spline that overshoots + // would draw a dip below the low line that never + // happened. The colour comes from the run's own band. + .interpolationMethod(.monotone) + .lineStyle(.init(lineWidth: lineWidth, lineCap: .round, lineJoin: .round)) + .foregroundStyle(color(forMgdl: run[0].value, thresholds: t).opacity(isFullColor ? 1 : 0.55)) + } + } + } else { + ForEach(visible, id: \.self) { point in + PointMark( + x: .value("Time", point.date), + y: .value("Glucose", display(point.value)) + ) + .symbolSize(symbolSize) + .foregroundStyle(color(forMgdl: point.value, thresholds: t).opacity(isFullColor ? 1 : 0.55)) + } } } .chartXScale(domain: start ... end) diff --git a/LoopFollowWidget/WidgetConfigurationIntent.swift b/LoopFollowWidget/WidgetConfigurationIntent.swift index 9918ad52d..b8698c3e1 100644 --- a/LoopFollowWidget/WidgetConfigurationIntent.swift +++ b/LoopFollowWidget/WidgetConfigurationIntent.swift @@ -51,17 +51,34 @@ extension WidgetChartDuration: AppEnum { } } -/// Configuration presented by Edit Widget: the span of the chart, then one -/// parameter per metric block, using the same options as the Live Activity grid. +extension WidgetChartStyle: AppEnum { + static var typeDisplayRepresentation: TypeDisplayRepresentation { "Line Style" } + + // Literal for the same reason as the slot titles above. + static var caseDisplayRepresentations: [WidgetChartStyle: DisplayRepresentation] { + [ + .dots: "Dots", + .line: "Line", + ] + } +} + +/// Configuration presented by Edit Widget: the span of the chart and how it is +/// drawn, then one parameter per metric block, using the same options as the +/// Live Activity grid. struct GlucoseWidgetConfigurationIntent: WidgetConfigurationIntent { static var title: LocalizedStringResource { "Widget Options" } - static var description: IntentDescription { "Choose the chart duration and the metrics shown beside it." } + static var description: IntentDescription { "Choose how the chart is drawn and the metrics shown beside it." } // Spelled out rather than read from WidgetChartDuration.standard, for the // compile-time constant rule noted below; keep the two in step. @Parameter(title: "Duration", default: .threeHours) var duration: WidgetChartDuration + // Spelled out rather than read from WidgetChartStyle.standard, same rule. + @Parameter(title: "Line Style", default: .dots) + var chartStyle: WidgetChartStyle + // @Parameter defaults must be compile-time constants, so these are spelled // out rather than read from LiveActivitySlotDefaults; keep the two in step. @Parameter(title: "Slot 1", default: .iob) diff --git a/LoopFollowWidget/WidgetTimelineProvider.swift b/LoopFollowWidget/WidgetTimelineProvider.swift index ae977675e..1ab5c7c8d 100644 --- a/LoopFollowWidget/WidgetTimelineProvider.swift +++ b/LoopFollowWidget/WidgetTimelineProvider.swift @@ -23,15 +23,22 @@ struct WidgetTimelineProvider: AppIntentTimelineProvider { }() func placeholder(in _: Context) -> GlucoseWidgetEntry { - Self.sampleEntry(slots: LiveActivitySlotDefaults.all, duration: .standard) + Self.sampleEntry(slots: LiveActivitySlotDefaults.all, duration: .standard, style: .standard) } func snapshot(for configuration: GlucoseWidgetConfigurationIntent, in context: Context) async -> GlucoseWidgetEntry { if context.isPreview { - return Self.sampleEntry(slots: configuration.slots, duration: configuration.duration) + return Self.sampleEntry(slots: configuration.slots, duration: configuration.duration, style: configuration.chartStyle) } let (series, snapshot) = await WidgetDataSource.load() - return GlucoseWidgetEntry(date: Date(), series: series, snapshot: snapshot, slots: configuration.slots, duration: configuration.duration) + return GlucoseWidgetEntry( + date: Date(), + series: series, + snapshot: snapshot, + slots: configuration.slots, + duration: configuration.duration, + chartStyle: configuration.chartStyle + ) } func timeline(for configuration: GlucoseWidgetConfigurationIntent, in _: Context) async -> Timeline { @@ -44,7 +51,8 @@ struct WidgetTimelineProvider: AppIntentTimelineProvider { series: series, snapshot: snapshot, slots: configuration.slots, - duration: configuration.duration + duration: configuration.duration, + chartStyle: configuration.chartStyle ) } @@ -53,7 +61,7 @@ struct WidgetTimelineProvider: AppIntentTimelineProvider { // MARK: - Gallery sample - private static func sampleEntry(slots: [LiveActivitySlotOption], duration: WidgetChartDuration) -> GlucoseWidgetEntry { + private static func sampleEntry(slots: [LiveActivitySlotOption], duration: WidgetChartDuration, style: WidgetChartStyle) -> GlucoseWidgetEntry { let now = Date() // Spread across whatever span was picked, so the gallery preview fills // its chart at every duration. @@ -85,7 +93,8 @@ struct WidgetTimelineProvider: AppIntentTimelineProvider { series: GlucoseChartSeries(points: points, updatedAt: now), snapshot: snapshot, slots: slots, - duration: duration + duration: duration, + chartStyle: style ) } } From 98f4f2f9068472b10612f92ed24f77e6e5210899 Mon Sep 17 00:00:00 2001 From: Justin Maier Date: Mon, 27 Jul 2026 21:25:58 -0600 Subject: [PATCH 09/13] Widget: a refresh button, and three metric blocks to make room for it The widget could only ever be as current as the last time WidgetKit chose to reload it, which is exactly when an old reading is most dangerous. The base gains a button in its fourth place, so the three configurable blocks stay and the fourth, which shipped empty, becomes the control. Bottom right, where a thumb reaches without crossing the reading. The interesting part is what the button is allowed to change. The reading, the trend, the delta and the chart come from entries; the blocks beside them come from devicestatus; and the widget prints one age over all of it. Refreshing only the entries would put a fresh timestamp over an hour old IOB, so the extension now reads devicestatus too, in both the Loop and the OpenAPS shape, and rebuilds the whole snapshot from the pair. A record the loop wrote too long ago is dropped rather than carried, since it no longer describes the moment the age line claims. What has no source in either response is written empty and reads as unavailable. Basal, override, carbs today, the sensor, cannula and insulin ages and the profile name are built by the app out of treatments and the profile, which is more requests than a tap can wait for. Losing a block to its no value glyph until the app next writes is honest; restating yesterday's number under today's timestamp is not. A tap that cannot reach Nightscout writes nothing at all and turns the glyph to a warning, which later timeline entries age out of on their own. A site the widget has no url for gets no button. --- .../LiveActivity/LAAppGroupSettings.swift | 25 ++ .../LiveActivity/NightscoutChartFetcher.swift | 50 +++- LoopFollowWidget/GlucoseWidgetEntry.swift | 21 ++ LoopFollowWidget/LoopFollowWidget.swift | 52 +++- .../NightscoutDeviceStatusFetcher.swift | 222 ++++++++++++++++++ LoopFollowWidget/RefreshWidgetIntent.swift | 143 +++++++++++ .../WidgetConfigurationIntent.swift | 8 +- LoopFollowWidget/WidgetTimelineProvider.swift | 23 +- 8 files changed, 529 insertions(+), 15 deletions(-) create mode 100644 LoopFollowWidget/NightscoutDeviceStatusFetcher.swift create mode 100644 LoopFollowWidget/RefreshWidgetIntent.swift diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 2cfd23bd8..7ca001727 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -133,6 +133,12 @@ enum LiveActivitySlotDefaults { static var all: [LiveActivitySlotOption] { [slot1, slot2, slot3, slot4] } + + /// The home screen widget shows three: the fourth place along its base is + /// the refresh button. + static var widget: [LiveActivitySlotOption] { + [slot1, slot2, slot3] + } } // MARK: - Widget chart duration @@ -211,6 +217,7 @@ enum LAAppGroupSettings { static let nightscoutURL = "la.nightscout.url" static let nightscoutToken = "la.nightscout.token" static let preferredUnit = "la.preferredUnit" + static let refreshFailedAt = "la.widget.refreshFailedAt" } private static var defaults: UserDefaults? { @@ -312,6 +319,24 @@ enum LAAppGroupSettings { guard let raw = defaults?.string(forKey: Keys.preferredUnit) else { return .mgdl } return GlucoseSnapshot.Unit(rawValue: raw) ?? .mgdl } + + // MARK: - Widget refresh + + /// When the widget's own refresh last failed to reach Nightscout, so the + /// render that follows can say the tap did not land. Nil clears it, which is + /// what a refresh that did land writes. + static func setRefreshFailed(at date: Date?) { + guard let date else { + defaults?.removeObject(forKey: Keys.refreshFailedAt) + return + } + defaults?.set(date.timeIntervalSince1970, forKey: Keys.refreshFailedAt) + } + + static func refreshFailedAt() -> Date? { + guard let seconds = defaults?.object(forKey: Keys.refreshFailedAt) as? Double, seconds > 0 else { return nil } + return Date(timeIntervalSince1970: seconds) + } } // Explicit so the widget can use this enum as an AppEnum parameter; the diff --git a/LoopFollow/LiveActivity/NightscoutChartFetcher.swift b/LoopFollow/LiveActivity/NightscoutChartFetcher.swift index 3d45a67b0..03009739c 100644 --- a/LoopFollow/LiveActivity/NightscoutChartFetcher.swift +++ b/LoopFollow/LiveActivity/NightscoutChartFetcher.swift @@ -3,6 +3,16 @@ import Foundation +/// The newest entry of a fetch, carrying the fields a plain list of chart points +/// drops. The value is the reading as posted, not the clamped one the chart +/// plots, so a reading off the end of the scale is still stated as it stands. +struct NightscoutReading { + let mgdl: Double + let date: Date + let deltaMgdl: Double + let direction: String? +} + /// Fetches recent glucose entries directly from Nightscout for surfaces that run /// without the app. `NightscoutUtils` is unusable here: it reads `Storage.shared` /// and logs through `LogManager`, neither of which exists in an extension. @@ -23,8 +33,22 @@ enum NightscoutChartFetcher { // MARK: - Public API + /// Longer than this between the last two readings and the difference is not + /// a delta: the sensor stopped reporting in between. + private static let maxDeltaGap: TimeInterval = 20 * 60 + /// The last `GlucoseChartSeriesStore.window` of readings, oldest first, or nil. static func fetchSeries(baseURL: String, token: String) async -> GlucoseChartSeries? { + await fetch(baseURL: baseURL, token: token)?.series + } + + /// The same readings, plus the head of them as a reading in its own right, so + /// a caller rebuilding a whole snapshot draws the chart and the number it + /// stands beside out of one response rather than two. + /// + /// Nil means the request did not land. A response that lands with nothing + /// plottable in it is the caller's to interpret, not a failure. + static func fetch(baseURL: String, token: String) async -> (series: GlucoseChartSeries, reading: NightscoutReading?)? { guard let url = entriesURL(baseURL: baseURL, token: token) else { return nil } var request = URLRequest(url: url) @@ -40,7 +64,9 @@ enum NightscoutChartFetcher { do { let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } - return try series(from: JSONDecoder().decode([Entry].self, from: data)) + let entries = try JSONDecoder().decode([Entry].self, from: data) + guard let series = series(from: entries) else { return nil } + return (series, reading(from: entries)) } catch { // Intentionally silent (extension-safe, no dependencies). return nil @@ -53,6 +79,28 @@ enum NightscoutChartFetcher { private struct Entry: Decodable { let sgv: Double? let date: Double? + let direction: String? + } + + /// The two newest plausible readings, so the delta is measured across the + /// pair that produced it rather than assumed. A gap wide enough to have + /// swallowed readings leaves the delta at zero, since the difference across + /// it is not the move the arrow describes. + private static func reading(from entries: [Entry]) -> NightscoutReading? { + let recent = entries.compactMap { entry -> (mgdl: Double, date: Date, direction: String?)? in + guard let sgv = entry.sgv, sgv > 0, sgv <= maxPlausibleMgdl, + let milliseconds = entry.date else { return nil } + return (sgv, Date(timeIntervalSince1970: (milliseconds / 1000).rounded()), entry.direction) + } + .sorted { $0.date > $1.date } + + guard let head = recent.first else { return nil } + let previous = recent.first { $0.date < head.date } + var delta: Double = 0 + if let previous, head.date.timeIntervalSince(previous.date) <= maxDeltaGap { + delta = head.mgdl - previous.mgdl + } + return NightscoutReading(mgdl: head.mgdl, date: head.date, deltaMgdl: delta, direction: head.direction) } private static func entriesURL(baseURL: String, token: String) -> URL? { diff --git a/LoopFollowWidget/GlucoseWidgetEntry.swift b/LoopFollowWidget/GlucoseWidgetEntry.swift index 33da3aa1e..d569e1647 100644 --- a/LoopFollowWidget/GlucoseWidgetEntry.swift +++ b/LoopFollowWidget/GlucoseWidgetEntry.swift @@ -16,10 +16,24 @@ struct GlucoseWidgetEntry: TimelineEntry { /// How the readings are drawn, chosen in Edit Widget. var chartStyle: WidgetChartStyle = .standard + /// Whether there is a Nightscout site for the refresh to ask. Without one, + /// as for a Dexcom-only setup, the button has nothing to fetch and is left + /// out rather than sitting there doing nothing. + var canRefresh: Bool = false + + /// When the refresh button last failed to reach Nightscout, as the widget + /// extension recorded it. Nil once a refresh has landed. + var refreshFailedAt: Date? + /// Clock disagreement small enough to be ordinary drift between the phone /// and whatever uploaded the reading. private static let clockSkewTolerance: TimeInterval = 60 + /// How long the button keeps saying a tap did not land. The timeline is a + /// run of entries at advancing dates, so this is what clears the mark + /// without a reload: the later entries simply fall outside it. + private static let refreshFailureWindow: TimeInterval = 5 * 60 + /// Age of the reading and metrics, or nil when there is no snapshot. The /// Nightscout fallback renews only the series, so anything drawn from the /// snapshot must be judged by this, never by the chart. @@ -36,4 +50,11 @@ struct GlucoseWidgetEntry: TimelineEntry { guard let updatedAt = snapshot?.updatedAt else { return false } return updatedAt.timeIntervalSince(date) > Self.clockSkewTolerance } + + /// Whether this render should still be reporting a refresh that did not land. + var refreshDidFail: Bool { + guard let refreshFailedAt else { return false } + let since = date.timeIntervalSince(refreshFailedAt) + return since >= 0 && since < Self.refreshFailureWindow + } } diff --git a/LoopFollowWidget/LoopFollowWidget.swift b/LoopFollowWidget/LoopFollowWidget.swift index 5f82f243f..f39849831 100644 --- a/LoopFollowWidget/LoopFollowWidget.swift +++ b/LoopFollowWidget/LoopFollowWidget.swift @@ -1,12 +1,13 @@ // LoopFollow // LoopFollowWidget.swift +import AppIntents import SwiftUI import WidgetKit /// Medium home screen widget: the configured span of glucose as a full bleed -/// backdrop, the current reading floating over it and four configurable metrics -/// along the base. +/// backdrop, the current reading floating over it, and along the base three +/// configurable metrics with the refresh button in the fourth place. struct LoopFollowWidgetView: View { let entry: GlucoseWidgetEntry @@ -39,6 +40,12 @@ struct LoopFollowWidgetView: View { private static let metricBandHeight: CGFloat = 50 private static let readingHeadroom: CGFloat = 16 + /// Metric blocks along the base. The fourth place is the refresh button. + private static let slotCount = 3 + + /// Wide enough to take a thumb without the blocks beside it losing room. + private static let refreshDiameter: CGFloat = 36 + /// Missing data is treated as stale: never show a number without an age. So /// is a reading from the future, whose real age is unknown rather than zero. private var isStale: Bool { @@ -221,18 +228,53 @@ struct LoopFollowWidgetView: View { // MARK: - Metrics - // Aligned by top edge: an empty slot draws no text, so it has no baseline. + // Aligned by bottom edge: the button is a fixed circle and the blocks are + // text of whatever height their labels need, so this is what puts them on + // one line along the base. private var metricBand: some View { - HStack(alignment: .top, spacing: 8) { - ForEach(Array(entry.slots.prefix(4).enumerated()), id: \.offset) { _, option in + HStack(alignment: .bottom, spacing: 8) { + ForEach(Array(entry.slots.prefix(Self.slotCount).enumerated()), id: \.offset) { _, option in WidgetSlotView(option: option, snapshot: entry.snapshot, isStale: isStale) } + refreshButton } .padding(.horizontal, Self.inset) .padding(.bottom, 13) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) } + /// Bottom right, where a thumb reaches it. The glyph turns to a warning when + /// the last tap could not reach Nightscout, so a refresh that did not land + /// says so rather than looking like a widget that redrew the same numbers. + /// + /// What it refreshes is the reading, the chart, and whatever the loop posts + /// to devicestatus. The blocks it cannot source are rebuilt empty, so a + /// metric never survives a refresh with an age it no longer has. + @ViewBuilder + private var refreshButton: some View { + if entry.canRefresh { + Button(intent: RefreshWidgetIntent()) { + Image(systemName: entry.refreshDidFail ? "exclamationmark.arrow.circlepath" : "arrow.clockwise") + .widgetAccentedRenderingMode(.desaturated) + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundStyle(entry.refreshDidFail ? AnyShapeStyle(Color(.systemOrange)) : AnyShapeStyle(.secondary)) + .frame(width: Self.refreshDiameter, height: Self.refreshDiameter) + .background( + // The plot runs underneath, so the glyph needs its own + // ground. The widget's background colour is what the mask + // already lets through elsewhere, which keeps the tinted + // and clear appearances free to substitute their own. + Circle().fill(isFullColor ? AnyShapeStyle(Color(.systemBackground).opacity(0.55)) : AnyShapeStyle(.tertiary)) + ) + .overlay( + Circle().strokeBorder(Color.primary.opacity(0.12), lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(entry.refreshDidFail ? "Refresh failed, try again" : "Refresh") + } + } + // MARK: - Chart @ViewBuilder diff --git a/LoopFollowWidget/NightscoutDeviceStatusFetcher.swift b/LoopFollowWidget/NightscoutDeviceStatusFetcher.swift new file mode 100644 index 000000000..ff95dffa0 --- /dev/null +++ b/LoopFollowWidget/NightscoutDeviceStatusFetcher.swift @@ -0,0 +1,222 @@ +// LoopFollow +// NightscoutDeviceStatusFetcher.swift + +import Foundation + +/// What one `/api/v1/devicestatus.json` record can say about the metrics beside +/// the reading. +/// +/// Everything the app builds out of treatments or the profile is missing here on +/// purpose: basal, override, carbs today, the site and sensor ages and the +/// profile name have no source in this response, and the widget writes them +/// empty rather than guessing. +struct NightscoutDeviceStatus { + var iob: Double? + var cob: Double? + var projected: Double? + var recBolus: Double? + var autosens: Double? + var tdd: Double? + var isfMgdlPerU: Double? + var carbRatio: Double? + var targetLowMgdl: Double? + var targetHighMgdl: Double? + var battery: Double? + var pumpBattery: Double? + var pumpReservoirU: Double? + var minBgMgdl: Double? + var maxBgMgdl: Double? + + /// When the loop last reported, from the pump clock the app keys on too. + var loopClock: Date? + + /// Same fifteen minute rule the app applies to the pump clock. A record + /// without one says nothing either way, which is what a site with no loop + /// behind it should say. + var isNotLooping: Bool { + guard let loopClock else { return false } + return Date().timeIntervalSince(loopClock) >= 15 * 60 + } +} + +/// Reads the loop's own numbers straight from Nightscout, for the widget's +/// refresh button. Written the way `NightscoutChartFetcher` is and for the same +/// reason: nothing in `NightscoutUtils` or `MainViewController`, where the app's +/// parsing lives, exists inside an extension. +/// +/// Both device shapes are handled. Loop posts under `loop`, OpenAPS and Trio +/// under `openaps`, and the two are told apart by which key is there. +enum NightscoutDeviceStatusFetcher { + static let timeout: TimeInterval = 4 + + /// Past this the record is no longer describing now, and the widget would be + /// putting one fresh age over a reading and a set of metrics that do not + /// share it. The metrics are dropped instead. + static let recordStaleAfter: TimeInterval = 15 * 60 + + // MARK: - Public API + + /// Nil means the request did not land. An empty array is not a failure: a + /// site with no loop uploading to it answers that way every time, and the + /// caller wants an empty set of metrics, not an error. + static func fetch(baseURL: String, token: String) async -> NightscoutDeviceStatus? { + guard let url = statusURL(baseURL: baseURL, token: token) else { return nil } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.cachePolicy = .reloadIgnoringLocalCacheData + request.timeoutInterval = timeout + + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = timeout + configuration.timeoutIntervalForResource = timeout + let session = URLSession(configuration: configuration) + + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } + guard let records = try JSONSerialization.jsonObject(with: data) as? [[String: Any]], + let record = records.first + else { + return NightscoutDeviceStatus() + } + return parse(record) + } catch { + // Intentionally silent (extension-safe, no dependencies). + return nil + } + } + + // MARK: - Parsing + + static func parse(_ record: [String: Any]) -> NightscoutDeviceStatus { + var status = NightscoutDeviceStatus() + + if let pump = record["pump"] as? [String: Any] { + status.loopClock = date(from: pump["clock"]) + status.pumpReservoirU = double(pump["reservoir"]) + if let battery = pump["battery"] as? [String: Any] { + status.pumpBattery = double(battery["percent"]) + } + } + if let uploader = record["uploader"] as? [String: Any] { + status.battery = double(uploader["battery"]) + } + + if let loop = record["loop"] as? [String: Any] { + apply(loop: loop, to: &status) + } + if let openaps = record["openaps"] as? [String: Any] { + apply(openaps: openaps, to: &status) + } + + // A record from a while back describes a moment the widget is not about + // to claim, so keep only what stays true across the gap. + if let clock = status.loopClock, Date().timeIntervalSince(clock) > recordStaleAfter { + let loopClock = status.loopClock + status = NightscoutDeviceStatus() + status.loopClock = loopClock + } + return status + } + + /// Loop keeps its numbers under nested objects and predicts a curve rather + /// than a single value, so the projection is the end of that curve. + private static func apply(loop: [String: Any], to status: inout NightscoutDeviceStatus) { + // A failed loop reported nothing to read, and the app skips the record + // wholesale in that case. + guard loop["failureReason"] == nil else { return } + + if let iob = loop["iob"] as? [String: Any] { + status.iob = double(iob["iob"]) + } + if let cob = loop["cob"] as? [String: Any] { + status.cob = double(cob["cob"]) + } + status.recBolus = double(loop["recommendedBolus"]) + + if let predicted = loop["predicted"] as? [String: Any], + let values = predicted["values"] as? [Double], !values.isEmpty + { + status.projected = values.last + status.minBgMgdl = values.min() + status.maxBgMgdl = values.max() + } + } + + /// OpenAPS and Trio put the working numbers on `suggested`, which the app + /// prefers over `enacted`, and the recommended bolus one level up. + private static func apply(openaps: [String: Any], to status: inout NightscoutDeviceStatus) { + if let iob = openaps["iob"] as? [String: Any] { + status.iob = double(iob["iob"]) + } + status.recBolus = double(openaps["recommendedBolus"]) + + let block = (openaps["suggested"] as? [String: Any]) ?? (openaps["enacted"] as? [String: Any]) + guard let block else { return } + + status.cob = double(block["COB"]) ?? scraped("COB", from: block["reason"]) + status.projected = double(block["eventualBG"]) + status.autosens = double(block["sensitivityRatio"]) + status.tdd = double(block["TDD"]) + status.isfMgdlPerU = double(block["ISF"]) + status.carbRatio = double(block["CR"]) ?? scraped("CR", from: block["reason"]) + + // One target, so both ends of the range carry it, the way the app does. + // Sites configured in mmol/L post it in mmol/L, and no glucose target is + // ever set as low as 40 mg/dL, so the small numbers are the converted ones. + if let target = double(block["current_target"]) { + let mgdl = target < 40 ? target * GlucoseConversion.mmolToMgDl : target + status.targetLowMgdl = mgdl + status.targetHighMgdl = mgdl + } + + if let predictions = block["predBGs"] as? [String: Any] { + let values = ["ZT", "IOB", "COB", "UAM"].compactMap { predictions[$0] as? [Double] }.flatMap { $0 } + if !values.isEmpty { + status.minBgMgdl = values.min() + status.maxBgMgdl = values.max() + } + } + } + + // MARK: - Helpers + + /// Some of what the loop reports is only ever stated in the prose it writes + /// alongside its numbers, so it is read out of there when the field is gone. + private static func scraped(_ label: String, from reason: Any?) -> Double? { + guard let reason = reason as? String, + let range = reason.range(of: "\(label): [0-9]+(\\.[0-9]+)?", options: .regularExpression) + else { return nil } + return Double(reason[range].dropFirst(label.count + 2)) + } + + private static func double(_ value: Any?) -> Double? { + (value as? NSNumber)?.doubleValue + } + + /// Uploaders differ on whether they print fractional seconds, and a formatter + /// that insists either way silently fails on half the sites. + private static func date(from value: Any?) -> Date? { + guard let text = value as? String else { return nil } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: text) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: text) + } + + private static func statusURL(baseURL: String, token: String) -> URL? { + var components = URLComponents(string: baseURL) + components?.path = "/api/v1/devicestatus.json" + + var queryItems = [URLQueryItem]() + if !token.isEmpty { + queryItems.append(URLQueryItem(name: "token", value: token)) + } + queryItems.append(URLQueryItem(name: "count", value: "1")) + components?.queryItems = queryItems + + return components?.url + } +} diff --git a/LoopFollowWidget/RefreshWidgetIntent.swift b/LoopFollowWidget/RefreshWidgetIntent.swift new file mode 100644 index 000000000..fa66d76dc --- /dev/null +++ b/LoopFollowWidget/RefreshWidgetIntent.swift @@ -0,0 +1,143 @@ +// LoopFollow +// RefreshWidgetIntent.swift + +import AppIntents + +/// The widget's own refresh, run when the button along the base is tapped. +/// +/// This runs inside the widget extension, where none of the app's parsing is +/// reachable, so everything it writes is rebuilt out of the two Nightscout +/// responses it fetches here. The snapshot is rebuilt whole rather than patched: +/// the widget puts a single age over the reading and the metrics beside it, so a +/// field this cannot source is written empty and reads as unavailable. Carrying +/// the app's older value forward would restate it under a timestamp it has not +/// got, and the metrics would be older than the age line claims. +/// +/// What that costs, against what the app itself can show: basal, override, carbs +/// today, the sensor, cannula and insulin ages and the profile name come from +/// treatments and the profile, which are several more requests than a tap can +/// wait for, so those blocks fall back to their no value glyph until the app +/// next writes. Everything the loop posts to devicestatus survives the refresh. +struct RefreshWidgetIntent: AppIntent { + static var title: LocalizedStringResource = "Refresh Glucose" + static var description = IntentDescription("Reads the latest glucose and loop status from Nightscout.") + + /// Reached only from the widget's own button, and it needs the widget's + /// process to write the App Group, so it neither opens the app nor offers + /// itself as a shortcut. + static var openAppWhenRun: Bool = false + static var isDiscoverable: Bool = false + + func perform() async throws -> some IntentResult { + let url = LAAppGroupSettings.nightscoutURL() + // The button is not drawn without a site to ask, so this is the case + // where one was removed between the render and the tap. + guard !url.isEmpty else { return .result() } + let token = LAAppGroupSettings.nightscoutToken() + + async let entriesTask = NightscoutChartFetcher.fetch(baseURL: url, token: token) + async let statusTask = NightscoutDeviceStatusFetcher.fetch(baseURL: url, token: token) + + let entries = await entriesTask + let status = await statusTask + + guard let entries, let reading = entries.reading, let status else { + LAAppGroupSettings.setRefreshFailed(at: Date()) + return .result() + } + + // Nothing to write when the site has not got a newer reading than the + // app already stored. The stored one is at least as recent and carries + // the fields this cannot rebuild, so replacing it would only lose them. + // + // A stored reading stamped ahead of this device cannot be ranked by age + // at all, since the clock that wrote it is wrong, so it does not get to + // hold off a reading that Nightscout is serving as the current one. + let stored = GlucoseSnapshotStore.shared.load()?.updatedAt ?? .distantPast + let storedIsRankable = stored <= Date().addingTimeInterval(60) + guard !storedIsRankable || reading.date > stored else { + LAAppGroupSettings.setRefreshFailed(at: nil) + return .result() + } + + // Chart first. Should the process be stopped between the two writes, a + // renewed chart beside the app's older snapshot is the state the widget + // already lives in whenever its cache runs stale, and the age line still + // describes the snapshot it is drawn from. The other order would put a + // fresh age over an old chart. + await save(entries.series) + await save(snapshot(reading: reading, status: status)) + LAAppGroupSettings.setRefreshFailed(at: nil) + + // WidgetKit reloads the timeline once this returns, so asking it to is + // a second reload for the same change. + return .result() + } + + // MARK: - Assembly + + private func snapshot(reading: NightscoutReading, status: NightscoutDeviceStatus) -> GlucoseSnapshot { + GlucoseSnapshot( + glucose: reading.mgdl, + delta: reading.deltaMgdl, + trend: Self.trend(from: reading.direction), + updatedAt: reading.date, + iob: status.iob, + cob: status.cob, + projected: status.projected, + override: nil, + recBolus: status.recBolus, + battery: status.battery, + pumpBattery: status.pumpBattery, + basalRate: "", + pumpReservoirU: status.pumpReservoirU, + autosens: status.autosens, + tdd: status.tdd, + targetLowMgdl: status.targetLowMgdl, + targetHighMgdl: status.targetHighMgdl, + isfMgdlPerU: status.isfMgdlPerU, + carbRatio: status.carbRatio, + carbsToday: nil, + profileName: nil, + sageInsertTime: 0, + cageInsertTime: 0, + iageInsertTime: 0, + minBgMgdl: status.minBgMgdl, + maxBgMgdl: status.maxBgMgdl, + unit: LAAppGroupSettings.preferredUnit(), + isNotLooping: status.isNotLooping + ) + } + + /// Nightscout's own spelling of the trend, matched the way the app's snapshot + /// builder matches it so the arrow does not change meaning between them. + private static func trend(from direction: String?) -> GlucoseSnapshot.Trend { + guard let direction = direction?.lowercased() else { return .unknown } + switch direction { + case "doubleup", "rapidrise", "up2", "upfast": return .upFast + case "fortyfiveup": return .upSlight + case "singleup", "up", "up1", "rising": return .up + case "flat", "steady", "none": return .flat + case "doubledown", "rapidfall", "down2", "downfast": return .downFast + case "fortyfivedown": return .downSlight + case "singledown", "down", "down1", "falling": return .down + default: return .unknown + } + } + + // MARK: - Storage + + /// Both stores write on their own queue, and the widget is redrawn the moment + /// this returns, so a fire and forget save would race the render. + private func save(_ series: GlucoseChartSeries) async { + await withCheckedContinuation { continuation in + GlucoseChartSeriesStore.shared.save(series) { continuation.resume() } + } + } + + private func save(_ snapshot: GlucoseSnapshot) async { + await withCheckedContinuation { continuation in + GlucoseSnapshotStore.shared.save(snapshot) { continuation.resume() } + } + } +} diff --git a/LoopFollowWidget/WidgetConfigurationIntent.swift b/LoopFollowWidget/WidgetConfigurationIntent.swift index b8698c3e1..6794ab318 100644 --- a/LoopFollowWidget/WidgetConfigurationIntent.swift +++ b/LoopFollowWidget/WidgetConfigurationIntent.swift @@ -65,7 +65,8 @@ extension WidgetChartStyle: AppEnum { /// Configuration presented by Edit Widget: the span of the chart and how it is /// drawn, then one parameter per metric block, using the same options as the -/// Live Activity grid. +/// Live Activity grid. There are three blocks: the fourth place along the base +/// is the refresh button. struct GlucoseWidgetConfigurationIntent: WidgetConfigurationIntent { static var title: LocalizedStringResource { "Widget Options" } static var description: IntentDescription { "Choose how the chart is drawn and the metrics shown beside it." } @@ -90,10 +91,7 @@ struct GlucoseWidgetConfigurationIntent: WidgetConfigurationIntent { @Parameter(title: "Slot 3", default: .projectedBG) var slot3: LiveActivitySlotOption - @Parameter(title: "Slot 4", default: LiveActivitySlotOption.none) - var slot4: LiveActivitySlotOption - var slots: [LiveActivitySlotOption] { - [slot1, slot2, slot3, slot4] + [slot1, slot2, slot3] } } diff --git a/LoopFollowWidget/WidgetTimelineProvider.swift b/LoopFollowWidget/WidgetTimelineProvider.swift index 1ab5c7c8d..332a9d28c 100644 --- a/LoopFollowWidget/WidgetTimelineProvider.swift +++ b/LoopFollowWidget/WidgetTimelineProvider.swift @@ -22,8 +22,14 @@ struct WidgetTimelineProvider: AppIntentTimelineProvider { return fine + coarse }() + /// The refresh fetches from Nightscout, so a setup without one has nothing + /// for the button to do. + private static var canRefresh: Bool { + !LAAppGroupSettings.nightscoutURL().isEmpty + } + func placeholder(in _: Context) -> GlucoseWidgetEntry { - Self.sampleEntry(slots: LiveActivitySlotDefaults.all, duration: .standard, style: .standard) + Self.sampleEntry(slots: LiveActivitySlotDefaults.widget, duration: .standard, style: .standard) } func snapshot(for configuration: GlucoseWidgetConfigurationIntent, in context: Context) async -> GlucoseWidgetEntry { @@ -37,13 +43,19 @@ struct WidgetTimelineProvider: AppIntentTimelineProvider { snapshot: snapshot, slots: configuration.slots, duration: configuration.duration, - chartStyle: configuration.chartStyle + chartStyle: configuration.chartStyle, + canRefresh: Self.canRefresh, + refreshFailedAt: LAAppGroupSettings.refreshFailedAt() ) } func timeline(for configuration: GlucoseWidgetConfigurationIntent, in _: Context) async -> Timeline { let now = Date() let (series, snapshot) = await WidgetDataSource.load() + // Read once and carried on every entry, so the later ones age out of the + // failure window on their own rather than needing a reload to clear it. + let refreshFailedAt = LAAppGroupSettings.refreshFailedAt() + let canRefresh = Self.canRefresh let entries = Self.entryOffsets.map { offset in GlucoseWidgetEntry( @@ -52,7 +64,9 @@ struct WidgetTimelineProvider: AppIntentTimelineProvider { snapshot: snapshot, slots: configuration.slots, duration: configuration.duration, - chartStyle: configuration.chartStyle + chartStyle: configuration.chartStyle, + canRefresh: canRefresh, + refreshFailedAt: refreshFailedAt ) } @@ -94,7 +108,8 @@ struct WidgetTimelineProvider: AppIntentTimelineProvider { snapshot: snapshot, slots: slots, duration: duration, - chartStyle: style + chartStyle: style, + canRefresh: canRefresh ) } } From fef1fdb72862b54131262bf46c4daae7c5d73f34 Mon Sep 17 00:00:00 2001 From: Justin Maier Date: Mon, 27 Jul 2026 22:32:48 -0600 Subject: [PATCH 10/13] Tell an unknown reservoir apart from a full one A pump reservoir the widget and the Live Activity had no value for printed "50+U", which is a reading, and a plausible one. It came from Omnipod, which does not put a number on the reservoir until it drops below fifty and posts a pump record with the field simply missing. The app read that absence correctly and stored nothing, and everything downstream then read the nothing as the Omnipod case, whatever had actually happened. The widget's own refresh made it reachable in earnest: a site whose devicestatus carries no pump block at all now rebuilds the snapshot with an empty reservoir, and the block beside the glucose would state fifty units or more for a pump it had never heard from. So the snapshot carries the pump's silence as its own fact. A record with no reservoir field still means over fifty and still says so; anything else has no reservoir to report and takes the no value glyph the other blocks use. Both parsers set it the same way, from the same absence, and a snapshot written before this reads as unknown, which is what it was. --- .../Controllers/Nightscout/DeviceStatus.swift | 2 ++ LoopFollow/LiveActivity/APNSClient.swift | 1 + LoopFollow/LiveActivity/GlucoseSlotFormat.swift | 7 +++++-- LoopFollow/LiveActivity/GlucoseSnapshot.swift | 14 ++++++++++++-- .../LiveActivity/GlucoseSnapshotBuilder.swift | 7 ++++++- .../StorageCurrentGlucoseStateProvider.swift | 4 ++++ LoopFollow/Storage/Storage.swift | 2 ++ .../NightscoutDeviceStatusFetcher.swift | 5 +++++ 8 files changed, 37 insertions(+), 5 deletions(-) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index d2d6920d8..e1078e46d 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -115,10 +115,12 @@ extension MainViewController { latestPumpVolume = reservoirData infoManager.updateInfoData(type: .pump, value: String(format: "%.0f", reservoirData) + "U") Storage.shared.lastPumpReservoirU.value = reservoirData + Storage.shared.lastPumpReservoirAboveMax.value = false } else { latestPumpVolume = 50.0 infoManager.updateInfoData(type: .pump, value: "50+U") Storage.shared.lastPumpReservoirU.value = nil + Storage.shared.lastPumpReservoirAboveMax.value = true } } diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index d4871f067..ed2c5e9d4 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -251,6 +251,7 @@ class APNSClient { if let pumpBattery = snapshot.pumpBattery { snapshotDict["pumpBattery"] = pumpBattery } if !snapshot.basalRate.isEmpty { snapshotDict["basalRate"] = snapshot.basalRate } if let pumpReservoirU = snapshot.pumpReservoirU { snapshotDict["pumpReservoirU"] = pumpReservoirU } + if snapshot.pumpReservoirAboveMax { snapshotDict["pumpReservoirAboveMax"] = true } if let autosens = snapshot.autosens { snapshotDict["autosens"] = autosens } if let tdd = snapshot.tdd { snapshotDict["tdd"] = tdd } if let targetLowMgdl = snapshot.targetLowMgdl { snapshotDict["targetLowMgdl"] = targetLowMgdl } diff --git a/LoopFollow/LiveActivity/GlucoseSlotFormat.swift b/LoopFollow/LiveActivity/GlucoseSlotFormat.swift index 455a6ce97..afc41239a 100644 --- a/LoopFollow/LiveActivity/GlucoseSlotFormat.swift +++ b/LoopFollow/LiveActivity/GlucoseSlotFormat.swift @@ -114,9 +114,12 @@ enum LAFormat { s.basalRate.isEmpty ? "—" : s.basalRate } + /// Omnipod stops short of a number while the reservoir is full and says only + /// that it is over 50U. With no pump record behind it there is no reservoir + /// to report at all, which is not the same thing and must not read as one. static func pump(_ s: GlucoseSnapshot) -> String { - guard let v = s.pumpReservoirU else { return "50+U" } - return "\(Int(round(v)))U" + if let v = s.pumpReservoirU { return "\(Int(round(v)))U" } + return s.pumpReservoirAboveMax ? "50+U" : "\u{2014}" } static func pumpBattery(_ s: GlucoseSnapshot) -> String { diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index c5f5fffcc..2368fc450 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -64,9 +64,14 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// Formatted current basal rate string (empty if not available) let basalRate: String - /// Pump reservoir in units (nil if >50U or unknown) + /// Pump reservoir in units (nil if unknown, or if above what the pump counts) let pumpReservoirU: Double? + /// True when the pump reported a reservoir it does not put a number on until + /// it drops below 50U, as Omnipod does. Told apart from an unknown reservoir, + /// which is the absence of a pump record rather than a full one. + let pumpReservoirAboveMax: Bool + /// Autosensitivity ratio, e.g. 0.9 = 90% (nil if not available) let autosens: Double? @@ -139,6 +144,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { pumpBattery: Double? = nil, basalRate: String = "", pumpReservoirU: Double? = nil, + pumpReservoirAboveMax: Bool = false, autosens: Double? = nil, tdd: Double? = nil, targetLowMgdl: Double? = nil, @@ -169,6 +175,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { self.pumpBattery = pumpBattery self.basalRate = basalRate self.pumpReservoirU = pumpReservoirU + self.pumpReservoirAboveMax = pumpReservoirAboveMax self.autosens = autosens self.tdd = tdd self.targetLowMgdl = targetLowMgdl @@ -212,6 +219,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { pumpBattery: pumpBattery, basalRate: basalRate, pumpReservoirU: pumpReservoirU, + pumpReservoirAboveMax: pumpReservoirAboveMax, autosens: autosens, tdd: tdd, targetLowMgdl: targetLowMgdl, @@ -248,6 +256,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { try container.encodeIfPresent(pumpBattery, forKey: .pumpBattery) try container.encode(basalRate, forKey: .basalRate) try container.encodeIfPresent(pumpReservoirU, forKey: .pumpReservoirU) + try container.encode(pumpReservoirAboveMax, forKey: .pumpReservoirAboveMax) try container.encodeIfPresent(autosens, forKey: .autosens) try container.encodeIfPresent(tdd, forKey: .tdd) try container.encodeIfPresent(targetLowMgdl, forKey: .targetLowMgdl) @@ -281,6 +290,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { pumpBattery = try container.decodeIfPresent(Double.self, forKey: .pumpBattery) basalRate = try container.decodeIfPresent(String.self, forKey: .basalRate) ?? "" pumpReservoirU = try container.decodeIfPresent(Double.self, forKey: .pumpReservoirU) + pumpReservoirAboveMax = try container.decodeIfPresent(Bool.self, forKey: .pumpReservoirAboveMax) ?? false autosens = try container.decodeIfPresent(Double.self, forKey: .autosens) tdd = try container.decodeIfPresent(Double.self, forKey: .tdd) targetLowMgdl = try container.decodeIfPresent(Double.self, forKey: .targetLowMgdl) @@ -302,7 +312,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { private enum CodingKeys: String, CodingKey { case glucose, delta, trend, updatedAt case iob, cob, projected - case override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU + case override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU, pumpReservoirAboveMax case autosens, tdd, targetLowMgdl, targetHighMgdl, isfMgdlPerU, carbRatio, carbsToday case profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl, maxBgMgdl case unit, isNotLooping, showRenewalOverlay diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index 40ff076af..156e0743f 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -45,9 +45,13 @@ protocol CurrentGlucoseStateProviding { /// Formatted current basal rate string (empty if not available). var basalRate: String { get } - /// Pump reservoir in units (nil if >50U or unknown). + /// Pump reservoir in units (nil if unknown, or above what the pump counts). var pumpReservoirU: Double? { get } + /// True when the pump reported a reservoir it does not number until it falls + /// below 50U. + var pumpReservoirAboveMax: Bool { get } + /// Autosensitivity ratio, e.g. 0.9 = 90%. var autosens: Double? { get } @@ -146,6 +150,7 @@ enum GlucoseSnapshotBuilder { pumpBattery: provider.pumpBattery, basalRate: provider.basalRate, pumpReservoirU: provider.pumpReservoirU, + pumpReservoirAboveMax: provider.pumpReservoirAboveMax, autosens: provider.autosens, tdd: provider.tdd, targetLowMgdl: provider.targetLowMgdl, diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index 1722d49fe..cfe7c3284 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -67,6 +67,10 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { Storage.shared.lastPumpReservoirU.value } + var pumpReservoirAboveMax: Bool { + Storage.shared.lastPumpReservoirAboveMax.value + } + var autosens: Double? { Storage.shared.lastAutosens.value } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 4dd7370a8..4a2d4725e 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -100,6 +100,7 @@ class Storage { // Live Activity extended InfoType data var lastBasal = StorageValue(key: "lastBasal", defaultValue: "") var lastPumpReservoirU = StorageValue(key: "lastPumpReservoirU", defaultValue: nil) + var lastPumpReservoirAboveMax = StorageValue(key: "lastPumpReservoirAboveMax", defaultValue: false) var lastAutosens = StorageValue(key: "lastAutosens", defaultValue: nil) var lastTdd = StorageValue(key: "lastTdd", defaultValue: nil) var lastTargetLowMgdl = StorageValue(key: "lastTargetLowMgdl", defaultValue: nil) @@ -335,6 +336,7 @@ class Storage { lastBasal.reload() lastPumpReservoirU.reload() + lastPumpReservoirAboveMax.reload() lastAutosens.reload() lastTdd.reload() lastTargetLowMgdl.reload() diff --git a/LoopFollowWidget/NightscoutDeviceStatusFetcher.swift b/LoopFollowWidget/NightscoutDeviceStatusFetcher.swift index ff95dffa0..60ad4969a 100644 --- a/LoopFollowWidget/NightscoutDeviceStatusFetcher.swift +++ b/LoopFollowWidget/NightscoutDeviceStatusFetcher.swift @@ -24,6 +24,10 @@ struct NightscoutDeviceStatus { var battery: Double? var pumpBattery: Double? var pumpReservoirU: Double? + + /// A pump record that carries no reservoir has one the pump does not number + /// yet, which is how the app reads the same absence. + var pumpReservoirAboveMax = false var minBgMgdl: Double? var maxBgMgdl: Double? @@ -95,6 +99,7 @@ enum NightscoutDeviceStatusFetcher { if let pump = record["pump"] as? [String: Any] { status.loopClock = date(from: pump["clock"]) status.pumpReservoirU = double(pump["reservoir"]) + status.pumpReservoirAboveMax = pump["reservoir"] == nil if let battery = pump["battery"] as? [String: Any] { status.pumpBattery = double(battery["percent"]) } From 01daa44c4e0520f3ca2db47c7558f4540aa3c978 Mon Sep 17 00:00:00 2001 From: Justin Maier Date: Mon, 27 Jul 2026 22:33:08 -0600 Subject: [PATCH 11/13] Widget: say what a refresh found, not just when it failed The button was silent about every tap that worked. Pressing it because the reading is four minutes old is the ordinary case, and four minutes is not long enough for the site to have anything newer, so the widget fetched, found the same reading, and redrew itself identically. Nothing about that is distinguishable from a button that does nothing. Nothing can be shown while the fetch runs. A widget does not redraw until its intent returns, and iOS gives the button no in progress treatment of its own, which was checked against the real widget over a fetch held open for two seconds: no dimming, no spinner, no change at all. So the tap is answered afterwards, in the render the intent's own reload produces. Two successful outcomes, kept apart, because they are different answers. A refresh that brought a newer reading resets the age line by itself and only needs acknowledging. A refresh that found nothing newer changes nothing on screen, and that is the one worth a sentence: the data is current, which is information rather than consolation. It says so beneath the age and never over it. The age line keeps counting from the reading, and where that reading is stale it keeps its warning triangle and its colour and the confirmation drops to "No newer reading" in grey, since a tick next to a twenty minute old number must not read as reassurance about the number. A stopped loop outranks both and takes the line. The wording is fixed and short, and does not tick. An earlier version counted up from the check, which grew a line of text across the chart that was at its widest exactly when it mattered least. It lasts half a minute, which the timeline pays for with one extra entry at the moment it expires, and the button keeps its own glyph throughout: a control that turns into a tick has stopped looking like something that can be pressed again. --- .../LiveActivity/LAAppGroupSettings.swift | 25 +++++++++ LoopFollowWidget/GlucoseWidgetEntry.swift | 38 +++++++++++++ LoopFollowWidget/LoopFollowWidget.swift | 54 +++++++++++++++++-- LoopFollowWidget/RefreshWidgetIntent.swift | 8 +++ LoopFollowWidget/WidgetTimelineProvider.swift | 27 ++++++++-- 5 files changed, 144 insertions(+), 8 deletions(-) diff --git a/LoopFollow/LiveActivity/LAAppGroupSettings.swift b/LoopFollow/LiveActivity/LAAppGroupSettings.swift index 7ca001727..feb20c4af 100644 --- a/LoopFollow/LiveActivity/LAAppGroupSettings.swift +++ b/LoopFollow/LiveActivity/LAAppGroupSettings.swift @@ -218,6 +218,8 @@ enum LAAppGroupSettings { static let nightscoutToken = "la.nightscout.token" static let preferredUnit = "la.preferredUnit" static let refreshFailedAt = "la.widget.refreshFailedAt" + static let refreshCheckedAt = "la.widget.refreshCheckedAt" + static let refreshBroughtNewData = "la.widget.refreshBroughtNewData" } private static var defaults: UserDefaults? { @@ -337,6 +339,29 @@ enum LAAppGroupSettings { guard let seconds = defaults?.object(forKey: Keys.refreshFailedAt) as? Double, seconds > 0 else { return nil } return Date(timeIntervalSince1970: seconds) } + + /// When the refresh last reached Nightscout, and whether the site answered + /// with a reading newer than the stored one. A tap that finds nothing newer + /// has still done its work, and the render after it is the only chance to + /// say so: nothing can be drawn while the intent is running. + static func setRefreshChecked(at date: Date, broughtNewData: Bool) { + defaults?.set(date.timeIntervalSince1970, forKey: Keys.refreshCheckedAt) + defaults?.set(broughtNewData, forKey: Keys.refreshBroughtNewData) + } + + static func clearRefreshChecked() { + defaults?.removeObject(forKey: Keys.refreshCheckedAt) + defaults?.removeObject(forKey: Keys.refreshBroughtNewData) + } + + static func refreshCheckedAt() -> Date? { + guard let seconds = defaults?.object(forKey: Keys.refreshCheckedAt) as? Double, seconds > 0 else { return nil } + return Date(timeIntervalSince1970: seconds) + } + + static func refreshBroughtNewData() -> Bool { + defaults?.bool(forKey: Keys.refreshBroughtNewData) ?? false + } } // Explicit so the widget can use this enum as an AppEnum parameter; the diff --git a/LoopFollowWidget/GlucoseWidgetEntry.swift b/LoopFollowWidget/GlucoseWidgetEntry.swift index d569e1647..139f2eb2f 100644 --- a/LoopFollowWidget/GlucoseWidgetEntry.swift +++ b/LoopFollowWidget/GlucoseWidgetEntry.swift @@ -1,8 +1,22 @@ // LoopFollow // GlucoseWidgetEntry.swift +import Foundation import WidgetKit +/// What a refresh that reached Nightscout has to say for itself. A widget cannot +/// redraw while its intent is running, so nothing can be shown mid fetch and the +/// render that follows carries the whole of the feedback a tap gets. +enum WidgetRefreshConfirmation { + /// A newer reading was written. The age line resets itself, so this only has + /// to acknowledge the tap. + case updated + + /// The site had nothing newer, which is the answer rather than a dead press. + /// It says the data was checked, never that the reading is any younger. + case upToDate +} + /// One rendered state of the home screen widget. struct GlucoseWidgetEntry: TimelineEntry { let date: Date @@ -25,6 +39,11 @@ struct GlucoseWidgetEntry: TimelineEntry { /// extension recorded it. Nil once a refresh has landed. var refreshFailedAt: Date? + /// When the refresh last reached Nightscout, and whether it brought back a + /// newer reading than the one already stored. + var refreshCheckedAt: Date? + var refreshBroughtNewData: Bool = false + /// Clock disagreement small enough to be ordinary drift between the phone /// and whatever uploaded the reading. private static let clockSkewTolerance: TimeInterval = 60 @@ -34,6 +53,15 @@ struct GlucoseWidgetEntry: TimelineEntry { /// without a reload: the later entries simply fall outside it. private static let refreshFailureWindow: TimeInterval = 5 * 60 + /// How long the wording that acknowledges the tap stays up. Short, because + /// it is an answer to a press and not a state; the provider puts an entry at + /// the end of it so it clears on screen rather than at the next reload. + /// + /// Nothing in it counts, either. How long ago the check was is a fact the + /// reading's own age already covers, and spelling it out a second time grew + /// a line across the chart that was at its widest once it mattered least. + static let confirmationWindow: TimeInterval = 30 + /// Age of the reading and metrics, or nil when there is no snapshot. The /// Nightscout fallback renews only the series, so anything drawn from the /// snapshot must be judged by this, never by the chart. @@ -57,4 +85,14 @@ struct GlucoseWidgetEntry: TimelineEntry { let since = date.timeIntervalSince(refreshFailedAt) return since >= 0 && since < Self.refreshFailureWindow } + + /// What this render should be saying about the last refresh that landed. + /// Expires against the entry's own date, so a run of entries at advancing + /// dates drops it without a reload. + var refreshConfirmation: WidgetRefreshConfirmation? { + guard !refreshDidFail, let refreshCheckedAt else { return nil } + let since = date.timeIntervalSince(refreshCheckedAt) + guard since >= 0, since < Self.confirmationWindow else { return nil } + return refreshBroughtNewData ? .updated : .upToDate + } } diff --git a/LoopFollowWidget/LoopFollowWidget.swift b/LoopFollowWidget/LoopFollowWidget.swift index f39849831..35472fa79 100644 --- a/LoopFollowWidget/LoopFollowWidget.swift +++ b/LoopFollowWidget/LoopFollowWidget.swift @@ -164,8 +164,14 @@ struct LoopFollowWidgetView: View { age(of: snapshot) + // One line under the age, and a loop that has stopped is + // what it says when there is a contest for it. if snapshot.isNotLooping { warning("Not Looping", color: Color(.systemRed)) + } else if entry.refreshDidFail { + warning("Refresh failed", color: Color(.systemOrange)) + } else if let confirmation = entry.refreshConfirmation { + refreshConfirmation(confirmation) } } .lineLimit(1) @@ -215,6 +221,31 @@ struct LoopFollowWidgetView: View { .foregroundStyle(isStale ? AnyShapeStyle(Color(.systemOrange)) : AnyShapeStyle(.secondary)) } + /// The answer to a tap, drawn under the age because that is the line it is + /// most likely to be misread as correcting. It is about the check, never + /// about the reading: the age above it is left exactly as it was, still + /// counting, and where that age is a stale one this says outright that + /// nothing newer exists so the warning above keeps the room. + /// + /// Short and quiet, and no clock of its own. It lies over the chart for the + /// half minute it is up, which a fixed word can afford and a running count + /// cannot. + private func refreshConfirmation(_ state: WidgetRefreshConfirmation) -> some View { + HStack(spacing: 3) { + Image(systemName: "checkmark") + .widgetAccentedRenderingMode(.desaturated) + .font(.system(size: 9, weight: .semibold)) + switch state { + case .updated: + Text("Updated") + case .upToDate: + Text(isStale ? "No newer reading" : "Up to date") + } + } + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + } + private func warning(_ text: String, color: Color, size: CGFloat = 11.5) -> some View { HStack(spacing: 3) { Image(systemName: "exclamationmark.triangle.fill") @@ -243,9 +274,13 @@ struct LoopFollowWidgetView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) } - /// Bottom right, where a thumb reaches it. The glyph turns to a warning when - /// the last tap could not reach Nightscout, so a refresh that did not land - /// says so rather than looking like a widget that redrew the same numbers. + /// Bottom right, where a thumb reaches it. It keeps its glyph whatever the + /// last tap did, and only takes a colour from it: a control that turns into + /// a tick has stopped looking like something that can be pressed again, + /// which is the wrong thing to say to someone waiting on a newer reading. + /// What the tap did is said in words under the age instead, since nothing + /// can be drawn while the intent runs and iOS gives the button no in + /// progress treatment of its own. /// /// What it refreshes is the reading, the chart, and whatever the loop posts /// to devicestatus. The blocks it cannot source are rebuilt empty, so a @@ -254,7 +289,7 @@ struct LoopFollowWidgetView: View { private var refreshButton: some View { if entry.canRefresh { Button(intent: RefreshWidgetIntent()) { - Image(systemName: entry.refreshDidFail ? "exclamationmark.arrow.circlepath" : "arrow.clockwise") + Image(systemName: "arrow.clockwise") .widgetAccentedRenderingMode(.desaturated) .font(.system(size: 15, weight: .bold, design: .rounded)) .foregroundStyle(entry.refreshDidFail ? AnyShapeStyle(Color(.systemOrange)) : AnyShapeStyle(.secondary)) @@ -271,7 +306,16 @@ struct LoopFollowWidgetView: View { ) } .buttonStyle(.plain) - .accessibilityLabel(entry.refreshDidFail ? "Refresh failed, try again" : "Refresh") + .accessibilityLabel(refreshLabel) + } + } + + private var refreshLabel: String { + if entry.refreshDidFail { return "Refresh failed, try again" } + switch entry.refreshConfirmation { + case .updated: return "Refreshed" + case .upToDate: return isStale ? "Refreshed, no newer reading" : "Refreshed, up to date" + case .none: return "Refresh" } } diff --git a/LoopFollowWidget/RefreshWidgetIntent.swift b/LoopFollowWidget/RefreshWidgetIntent.swift index fa66d76dc..d4e59b176 100644 --- a/LoopFollowWidget/RefreshWidgetIntent.swift +++ b/LoopFollowWidget/RefreshWidgetIntent.swift @@ -43,6 +43,7 @@ struct RefreshWidgetIntent: AppIntent { guard let entries, let reading = entries.reading, let status else { LAAppGroupSettings.setRefreshFailed(at: Date()) + LAAppGroupSettings.clearRefreshChecked() return .result() } @@ -53,10 +54,15 @@ struct RefreshWidgetIntent: AppIntent { // A stored reading stamped ahead of this device cannot be ranked by age // at all, since the clock that wrote it is wrong, so it does not get to // hold off a reading that Nightscout is serving as the current one. + // + // Nothing to write is still something to say, though: the reading and + // its age are about to redraw as they were, and without the note the tap + // is indistinguishable from one that did nothing at all. let stored = GlucoseSnapshotStore.shared.load()?.updatedAt ?? .distantPast let storedIsRankable = stored <= Date().addingTimeInterval(60) guard !storedIsRankable || reading.date > stored else { LAAppGroupSettings.setRefreshFailed(at: nil) + LAAppGroupSettings.setRefreshChecked(at: Date(), broughtNewData: false) return .result() } @@ -68,6 +74,7 @@ struct RefreshWidgetIntent: AppIntent { await save(entries.series) await save(snapshot(reading: reading, status: status)) LAAppGroupSettings.setRefreshFailed(at: nil) + LAAppGroupSettings.setRefreshChecked(at: Date(), broughtNewData: true) // WidgetKit reloads the timeline once this returns, so asking it to is // a second reload for the same change. @@ -91,6 +98,7 @@ struct RefreshWidgetIntent: AppIntent { pumpBattery: status.pumpBattery, basalRate: "", pumpReservoirU: status.pumpReservoirU, + pumpReservoirAboveMax: status.pumpReservoirAboveMax, autosens: status.autosens, tdd: status.tdd, targetLowMgdl: status.targetLowMgdl, diff --git a/LoopFollowWidget/WidgetTimelineProvider.swift b/LoopFollowWidget/WidgetTimelineProvider.swift index 332a9d28c..3aa539184 100644 --- a/LoopFollowWidget/WidgetTimelineProvider.swift +++ b/LoopFollowWidget/WidgetTimelineProvider.swift @@ -22,6 +22,17 @@ struct WidgetTimelineProvider: AppIntentTimelineProvider { return fine + coarse }() + /// The confirmation of a tap has to clear well inside the five minutes + /// between the ordinary entries, so the timeline gets one more at the moment + /// it is due to go. Entries are what WidgetKit redraws from, and asking it + /// for a reload on a timer is not something it grants. + private static func offsets(expiringIn deadline: TimeInterval?) -> [TimeInterval] { + guard let deadline, deadline > 0, deadline < horizon, !entryOffsets.contains(deadline) else { + return entryOffsets + } + return (entryOffsets + [deadline]).sorted() + } + /// The refresh fetches from Nightscout, so a setup without one has nothing /// for the button to do. private static var canRefresh: Bool { @@ -45,7 +56,9 @@ struct WidgetTimelineProvider: AppIntentTimelineProvider { duration: configuration.duration, chartStyle: configuration.chartStyle, canRefresh: Self.canRefresh, - refreshFailedAt: LAAppGroupSettings.refreshFailedAt() + refreshFailedAt: LAAppGroupSettings.refreshFailedAt(), + refreshCheckedAt: LAAppGroupSettings.refreshCheckedAt(), + refreshBroughtNewData: LAAppGroupSettings.refreshBroughtNewData() ) } @@ -55,9 +68,15 @@ struct WidgetTimelineProvider: AppIntentTimelineProvider { // Read once and carried on every entry, so the later ones age out of the // failure window on their own rather than needing a reload to clear it. let refreshFailedAt = LAAppGroupSettings.refreshFailedAt() + let refreshCheckedAt = LAAppGroupSettings.refreshCheckedAt() + let refreshBroughtNewData = LAAppGroupSettings.refreshBroughtNewData() let canRefresh = Self.canRefresh - let entries = Self.entryOffsets.map { offset in + let expiry = refreshCheckedAt? + .addingTimeInterval(GlucoseWidgetEntry.confirmationWindow) + .timeIntervalSince(now) + + let entries = Self.offsets(expiringIn: expiry).map { offset in GlucoseWidgetEntry( date: now.addingTimeInterval(offset), series: series, @@ -66,7 +85,9 @@ struct WidgetTimelineProvider: AppIntentTimelineProvider { duration: configuration.duration, chartStyle: configuration.chartStyle, canRefresh: canRefresh, - refreshFailedAt: refreshFailedAt + refreshFailedAt: refreshFailedAt, + refreshCheckedAt: refreshCheckedAt, + refreshBroughtNewData: refreshBroughtNewData ) } From 6154ce2c55ec2d06caef00b5a8137e557cc80575 Mon Sep 17 00:00:00 2001 From: Justin Maier Date: Tue, 28 Jul 2026 00:30:34 -0600 Subject: [PATCH 12/13] Widget: let the refresh button answer the tap it was given The button kept its arrow whatever happened, so a tap read as a dead press until someone noticed the wording under the age. It now changes glyph for four seconds on landing, a checkmark for a refresh that reported and an exclamation for one that did not, then returns to the arrow. The circle and its border are untouched, which is what keeps it looking pressable while it is doing that, and the wording still outlasts it by design. The provider puts an entry at the end of the four seconds as well as at the end of the thirty the wording gets. Those are what redraw the widget, so an interval without one at its end does not end; two deadlines seconds apart in a run spaced minutes apart is also what guarantees each state its time on screen rather than leaving it to whenever the next reload lands. A stopped loop no longer takes the whole line to itself. It led, and the answer to the tap was dropped, which left the one case where the button gets pressed hardest teaching nothing at all. The two now share the line, with the loop warning still first and the refresh half using its own glyph so the warning triangle keeps meaning what it meant. There is still no in flight state. Writing a marker before the fetch and asking for a reload alongside it does not give one: with the intent held open twelve seconds the provider was not asked for a timeline once in that window, and was asked 138ms after perform returned. --- LoopFollowWidget/GlucoseWidgetEntry.swift | 39 +++++++ LoopFollowWidget/LoopFollowWidget.swift | 108 +++++++++++++----- LoopFollowWidget/WidgetTimelineProvider.swift | 36 +++--- 3 files changed, 139 insertions(+), 44 deletions(-) diff --git a/LoopFollowWidget/GlucoseWidgetEntry.swift b/LoopFollowWidget/GlucoseWidgetEntry.swift index 139f2eb2f..9c3b44843 100644 --- a/LoopFollowWidget/GlucoseWidgetEntry.swift +++ b/LoopFollowWidget/GlucoseWidgetEntry.swift @@ -17,6 +17,18 @@ enum WidgetRefreshConfirmation { case upToDate } +/// What the refresh button is drawing. The three answering states are held for a +/// few seconds only: the button has to be recognisable as a control again long +/// before anyone reaches for it a second time, so the wording under the age is +/// what carries the answer afterwards. +enum WidgetRefreshButtonPhase { + case idle + case justUpdated + case justChecked + case justFailed + case failed +} + /// One rendered state of the home screen widget. struct GlucoseWidgetEntry: TimelineEntry { let date: Date @@ -62,6 +74,13 @@ struct GlucoseWidgetEntry: TimelineEntry { /// a line across the chart that was at its widest once it mattered least. static let confirmationWindow: TimeInterval = 30 + /// How long the button itself leaves its idle glyph to acknowledge the tap. + /// Long enough that a glance away and back still catches it, short enough + /// that the control is recognisably a refresh again well before anyone would + /// press it a second time. The wording below the age outlasts it by design, + /// so the answer is never gone with the glyph. + static let buttonFlashWindow: TimeInterval = 4 + /// Age of the reading and metrics, or nil when there is no snapshot. The /// Nightscout fallback renews only the series, so anything drawn from the /// snapshot must be judged by this, never by the chart. @@ -95,4 +114,24 @@ struct GlucoseWidgetEntry: TimelineEntry { guard since >= 0, since < Self.confirmationWindow else { return nil } return refreshBroughtNewData ? .updated : .upToDate } + + /// Whether the answer to the last tap is new enough that the button is still + /// acknowledging it rather than sitting at its idle glyph. + var isFlashing: Bool { + let mark = refreshDidFail ? refreshFailedAt : refreshCheckedAt + guard let mark else { return false } + let since = date.timeIntervalSince(mark) + return since >= 0 && since < Self.buttonFlashWindow + } + + /// What the button draws. The failure state outlives its flash because a tap + /// that did not land is a standing condition rather than an acknowledgement. + var refreshPhase: WidgetRefreshButtonPhase { + if refreshDidFail { return isFlashing ? .justFailed : .failed } + guard isFlashing, let refreshConfirmation else { return .idle } + switch refreshConfirmation { + case .updated: return .justUpdated + case .upToDate: return .justChecked + } + } } diff --git a/LoopFollowWidget/LoopFollowWidget.swift b/LoopFollowWidget/LoopFollowWidget.swift index 35472fa79..1d5587621 100644 --- a/LoopFollowWidget/LoopFollowWidget.swift +++ b/LoopFollowWidget/LoopFollowWidget.swift @@ -164,15 +164,7 @@ struct LoopFollowWidgetView: View { age(of: snapshot) - // One line under the age, and a loop that has stopped is - // what it says when there is a contest for it. - if snapshot.isNotLooping { - warning("Not Looping", color: Color(.systemRed)) - } else if entry.refreshDidFail { - warning("Refresh failed", color: Color(.systemOrange)) - } else if let confirmation = entry.refreshConfirmation { - refreshConfirmation(confirmation) - } + statusLine(snapshot) } .lineLimit(1) .minimumScaleFactor(0.8) @@ -221,29 +213,57 @@ struct LoopFollowWidgetView: View { .foregroundStyle(isStale ? AnyShapeStyle(Color(.systemOrange)) : AnyShapeStyle(.secondary)) } + /// The one line under the age. A loop that has stopped leads it, and the + /// answer to a tap sits beside that rather than waiting for the line to come + /// free: a stopped loop is exactly when the button gets pressed hardest, and + /// a press that teaches nothing is the reason to press again. + /// + /// The refresh half never borrows the warning triangle. That glyph means the + /// reading or the loop is in trouble, and a refresh that failed to reach the + /// site says so in its own word instead. + @ViewBuilder + private func statusLine(_ snapshot: GlucoseSnapshot) -> some View { + HStack(spacing: 7) { + if snapshot.isNotLooping { + warning("Not Looping", color: Color(.systemRed)) + } + + if entry.refreshDidFail { + let orange = AnyShapeStyle(Color(.systemOrange)) + refreshNote("exclamationmark", "Refresh failed", settled: orange, arriving: orange) + } else if let confirmation = entry.refreshConfirmation { + refreshNote("checkmark", word(for: confirmation), settled: AnyShapeStyle(.secondary), arriving: AnyShapeStyle(.primary)) + } + } + } + /// The answer to a tap, drawn under the age because that is the line it is /// most likely to be misread as correcting. It is about the check, never /// about the reading: the age above it is left exactly as it was, still /// counting, and where that age is a stale one this says outright that /// nothing newer exists so the warning above keeps the room. /// - /// Short and quiet, and no clock of its own. It lies over the chart for the - /// half minute it is up, which a fixed word can afford and a running count - /// cannot. - private func refreshConfirmation(_ state: WidgetRefreshConfirmation) -> some View { + /// Short, and no clock of its own. It lies over the chart for the half minute + /// it is up, which a fixed word can afford and a running count cannot. It + /// arrives at full strength and settles to grey once the button is done + /// acknowledging the tap, so the change is caught without the line going on + /// competing with the reading. + private func refreshNote(_ symbol: String, _ text: String, settled: AnyShapeStyle, arriving: AnyShapeStyle) -> some View { HStack(spacing: 3) { - Image(systemName: "checkmark") + Image(systemName: symbol) .widgetAccentedRenderingMode(.desaturated) .font(.system(size: 9, weight: .semibold)) - switch state { - case .updated: - Text("Updated") - case .upToDate: - Text(isStale ? "No newer reading" : "Up to date") - } + Text(text) + } + .font(.system(size: 11, weight: entry.isFlashing ? .bold : .medium, design: .rounded)) + .foregroundStyle(entry.isFlashing ? arriving : settled) + } + + private func word(for state: WidgetRefreshConfirmation) -> String { + switch state { + case .updated: return "Updated" + case .upToDate: return isStale ? "No newer reading" : "Up to date" } - .font(.system(size: 11, weight: .medium, design: .rounded)) - .foregroundStyle(.secondary) } private func warning(_ text: String, color: Color, size: CGFloat = 11.5) -> some View { @@ -274,13 +294,21 @@ struct LoopFollowWidgetView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) } - /// Bottom right, where a thumb reaches it. It keeps its glyph whatever the - /// last tap did, and only takes a colour from it: a control that turns into - /// a tick has stopped looking like something that can be pressed again, - /// which is the wrong thing to say to someone waiting on a newer reading. - /// What the tap did is said in words under the age instead, since nothing - /// can be drawn while the intent runs and iOS gives the button no in - /// progress treatment of its own. + /// Bottom right, where a thumb reaches it. It answers a tap by changing its + /// glyph for a few seconds and then going back to the arrow, so the press + /// visibly lands without the control ending up permanently dressed as + /// something that has already been used. The circle and its border never + /// change, which is what keeps it reading as pressable throughout. + /// + /// It is never the only answer. Everything it shows is said in words under + /// the age as well, and those words outlast it. + /// + /// There is no state for the fetch itself. A marker written before the + /// network call plus a `reloadTimelines` does not produce one: measured with + /// the intent held open twelve seconds, the provider was not asked for a + /// timeline once in that window, then asked 138ms after `perform` returned. + /// The result is the first thing that can be drawn, so the job here is to + /// make it impossible to miss when it lands. /// /// What it refreshes is the reading, the chart, and whatever the loop posts /// to devicestatus. The blocks it cannot source are rebuilt empty, so a @@ -289,10 +317,10 @@ struct LoopFollowWidgetView: View { private var refreshButton: some View { if entry.canRefresh { Button(intent: RefreshWidgetIntent()) { - Image(systemName: "arrow.clockwise") + Image(systemName: Self.symbol(for: entry.refreshPhase)) .widgetAccentedRenderingMode(.desaturated) .font(.system(size: 15, weight: .bold, design: .rounded)) - .foregroundStyle(entry.refreshDidFail ? AnyShapeStyle(Color(.systemOrange)) : AnyShapeStyle(.secondary)) + .foregroundStyle(tint(for: entry.refreshPhase)) .frame(width: Self.refreshDiameter, height: Self.refreshDiameter) .background( // The plot runs underneath, so the glyph needs its own @@ -310,6 +338,24 @@ struct LoopFollowWidgetView: View { } } + /// No glyph here implies motion: nothing animates between two timeline + /// entries, so a part turned arrow would claim a spin that never happens. + private static func symbol(for phase: WidgetRefreshButtonPhase) -> String { + switch phase { + case .idle, .failed: return "arrow.clockwise" + case .justUpdated, .justChecked: return "checkmark" + case .justFailed: return "exclamationmark" + } + } + + private func tint(for phase: WidgetRefreshButtonPhase) -> AnyShapeStyle { + switch phase { + case .idle, .justChecked: return AnyShapeStyle(.secondary) + case .justUpdated: return AnyShapeStyle(Color(.systemGreen)) + case .justFailed, .failed: return AnyShapeStyle(Color(.systemOrange)) + } + } + private var refreshLabel: String { if entry.refreshDidFail { return "Refresh failed, try again" } switch entry.refreshConfirmation { diff --git a/LoopFollowWidget/WidgetTimelineProvider.swift b/LoopFollowWidget/WidgetTimelineProvider.swift index 3aa539184..9d3638bad 100644 --- a/LoopFollowWidget/WidgetTimelineProvider.swift +++ b/LoopFollowWidget/WidgetTimelineProvider.swift @@ -22,15 +22,21 @@ struct WidgetTimelineProvider: AppIntentTimelineProvider { return fine + coarse }() - /// The confirmation of a tap has to clear well inside the five minutes - /// between the ordinary entries, so the timeline gets one more at the moment - /// it is due to go. Entries are what WidgetKit redraws from, and asking it - /// for a reload on a timer is not something it grants. - private static func offsets(expiringIn deadline: TimeInterval?) -> [TimeInterval] { - guard let deadline, deadline > 0, deadline < horizon, !entryOffsets.contains(deadline) else { - return entryOffsets - } - return (entryOffsets + [deadline]).sorted() + /// Everything a tap puts on screen has to clear well inside the five minutes + /// between the ordinary entries, so the timeline gets one extra entry at each + /// moment one of them is due to go. Entries are what WidgetKit redraws from, + /// and asking it for a reload on a timer is not something it grants, so an + /// interval with no entry at its end is an interval that does not end. + /// + /// This is also what guarantees each state a minimum time on screen. The + /// deadlines are seconds apart while the ordinary run is minutes apart, so a + /// state cannot be skipped by a redraw that lands between them. + private static func offsets(expiringIn deadlines: [TimeInterval?]) -> [TimeInterval] { + let extra = deadlines + .compactMap { $0 } + .filter { $0 > 0 && $0 < horizon && !entryOffsets.contains($0) } + guard !extra.isEmpty else { return entryOffsets } + return (entryOffsets + extra).sorted() } /// The refresh fetches from Nightscout, so a setup without one has nothing @@ -72,11 +78,15 @@ struct WidgetTimelineProvider: AppIntentTimelineProvider { let refreshBroughtNewData = LAAppGroupSettings.refreshBroughtNewData() let canRefresh = Self.canRefresh - let expiry = refreshCheckedAt? - .addingTimeInterval(GlucoseWidgetEntry.confirmationWindow) - .timeIntervalSince(now) + // One deadline per thing a tap leaves on screen: the button's own + // acknowledgement, and the wording that outlasts it. + let deadlines: [TimeInterval?] = [ + refreshCheckedAt?.addingTimeInterval(GlucoseWidgetEntry.confirmationWindow).timeIntervalSince(now), + refreshCheckedAt?.addingTimeInterval(GlucoseWidgetEntry.buttonFlashWindow).timeIntervalSince(now), + refreshFailedAt?.addingTimeInterval(GlucoseWidgetEntry.buttonFlashWindow).timeIntervalSince(now), + ] - let entries = Self.offsets(expiringIn: expiry).map { offset in + let entries = Self.offsets(expiringIn: deadlines).map { offset in GlucoseWidgetEntry( date: now.addingTimeInterval(offset), series: series, From b600fc8e5258845b7b8d13009666e6d4ac10ddff Mon Sep 17 00:00:00 2001 From: Justin Maier Date: Tue, 28 Jul 2026 01:00:52 -0600 Subject: [PATCH 13/13] Widget: let a refresh answer for the loop, not just the reading The refresh intent returned early whenever Nightscout had no newer glucose reading, and returning early meant it wrote no snapshot at all. The snapshot also carries everything devicestatus supplies, so IOB, COB, the projection, the recommended bolus, the pump battery and reservoir, and the not-looping verdict all sat frozen until a new CGM reading happened to arrive. A loop that failed or recovered between readings could not be surfaced by tapping refresh, which is the moment the button is most likely to be pressed. The intent now writes on the loop's account as well. Whether the loop moved is judged on the pump clock the record was posted with, newly carried on the snapshot as loopUpdatedAt, and on the fifteen minute not-looping verdict, which turns over on its own once a loop stops reporting and nothing newer will ever arrive to say so. It is never judged on the metrics themselves: the app and the widget's own fetcher read several of the same numbers out of different keys and through different conversions, so a value that differs between them is not evidence of anything. updatedAt keeps its one meaning, the time of the reading the number, delta and trend describe. A loop only refresh copies all four from the snapshot it replaces, so the age line goes on counting from the same reading it counted from before the tap, and a reading cannot be made to look fresh by a loop that moved behind it. The fifteen minute rule that drops the metrics off an old devicestatus record is unchanged, and the fields the intent cannot source are still written empty rather than carried forward. The wording splits to match. A refresh that moved only the loop says "Loop status updated" instead of "Up to date", in grey and with the plain checkmark rather than the green one, since the reading did not move. A stale reading still gets "No newer reading" either way. --- LoopFollow/LiveActivity/GlucoseSnapshot.swift | 13 +- .../LiveActivity/GlucoseSnapshotBuilder.swift | 4 + .../StorageCurrentGlucoseStateProvider.swift | 6 + LoopFollowWidget/GlucoseWidgetEntry.swift | 18 ++- LoopFollowWidget/LoopFollowWidget.swift | 7 ++ LoopFollowWidget/RefreshWidgetIntent.swift | 119 ++++++++++++++++-- LoopFollowWidget/WidgetTimelineProvider.swift | 7 +- 7 files changed, 156 insertions(+), 18 deletions(-) diff --git a/LoopFollow/LiveActivity/GlucoseSnapshot.swift b/LoopFollow/LiveActivity/GlucoseSnapshot.swift index 2368fc450..2a8586bc1 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshot.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshot.swift @@ -36,6 +36,12 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// Timestamp of reading. let updatedAt: Date + /// Pump clock of the devicestatus record the metrics below were read from, + /// or nil for a snapshot written before this was recorded. Deliberately not + /// `updatedAt`: the reading and the loop move independently, and a surface + /// that has refreshed one of them needs to know which. + let loopUpdatedAt: Date? + // MARK: - Secondary Metrics /// Insulin On Board @@ -135,6 +141,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { delta: Double, trend: Trend, updatedAt: Date, + loopUpdatedAt: Date? = nil, iob: Double?, cob: Double?, projected: Double?, @@ -166,6 +173,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { self.delta = delta self.trend = trend self.updatedAt = updatedAt + self.loopUpdatedAt = loopUpdatedAt self.iob = iob self.cob = cob self.projected = projected @@ -210,6 +218,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { delta: delta, trend: trend, updatedAt: updatedAt, + loopUpdatedAt: loopUpdatedAt, iob: iob, cob: cob, projected: projected, @@ -247,6 +256,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { try container.encode(delta, forKey: .delta) try container.encode(trend, forKey: .trend) try container.encode(updatedAt.timeIntervalSince1970, forKey: .updatedAt) + try container.encodeIfPresent(loopUpdatedAt?.timeIntervalSince1970, forKey: .loopUpdatedAt) try container.encodeIfPresent(iob, forKey: .iob) try container.encodeIfPresent(cob, forKey: .cob) try container.encodeIfPresent(projected, forKey: .projected) @@ -281,6 +291,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { delta = try container.decode(Double.self, forKey: .delta) trend = try container.decode(Trend.self, forKey: .trend) updatedAt = try Date(timeIntervalSince1970: container.decode(Double.self, forKey: .updatedAt)) + loopUpdatedAt = try container.decodeIfPresent(Double.self, forKey: .loopUpdatedAt).map { Date(timeIntervalSince1970: $0) } iob = try container.decodeIfPresent(Double.self, forKey: .iob) cob = try container.decodeIfPresent(Double.self, forKey: .cob) projected = try container.decodeIfPresent(Double.self, forKey: .projected) @@ -310,7 +321,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { } private enum CodingKeys: String, CodingKey { - case glucose, delta, trend, updatedAt + case glucose, delta, trend, updatedAt, loopUpdatedAt case iob, cob, projected case override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU, pumpReservoirAboveMax case autosens, tdd, targetLowMgdl, targetHighMgdl, isfMgdlPerU, carbRatio, carbsToday diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index 156e0743f..6263ed16c 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -96,6 +96,9 @@ protocol CurrentGlucoseStateProviding { /// True when LoopFollow detects the loop has not reported in 15+ minutes. var isNotLooping: Bool { get } + /// Pump clock of the devicestatus record the metrics were read from. + var loopUpdatedAt: Date? { get } + // MARK: - Renewal /// True when the Live Activity is within renewalWarning seconds of its deadline. @@ -141,6 +144,7 @@ enum GlucoseSnapshotBuilder { delta: deltaMgdl, trend: trend, updatedAt: updatedAt, + loopUpdatedAt: provider.loopUpdatedAt, iob: provider.iob, cob: provider.cob, projected: provider.projectedMgdl, diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index cfe7c3284..ca8609a9e 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -132,6 +132,12 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { return Date().timeIntervalSince1970 - lastLoopTime >= 15 * 60 } + var loopUpdatedAt: Date? { + let lastLoopTime = Storage.shared.lastLoopTime.value + guard lastLoopTime > 0 else { return nil } + return Date(timeIntervalSince1970: lastLoopTime) + } + // MARK: - Renewal var showRenewalOverlay: Bool { diff --git a/LoopFollowWidget/GlucoseWidgetEntry.swift b/LoopFollowWidget/GlucoseWidgetEntry.swift index 9c3b44843..29271d3a7 100644 --- a/LoopFollowWidget/GlucoseWidgetEntry.swift +++ b/LoopFollowWidget/GlucoseWidgetEntry.swift @@ -12,6 +12,12 @@ enum WidgetRefreshConfirmation { /// to acknowledge the tap. case updated + /// The reading stood, but the loop had moved on since the stored snapshot + /// was written, so the metrics and the loop's own state were replaced. Held + /// apart from `updated` because the age line did not reset: what is on screen + /// is the reading that was already there, at the age it already had. + case loopUpdated + /// The site had nothing newer, which is the answer rather than a dead press. /// It says the data was checked, never that the reading is any younger. case upToDate @@ -51,10 +57,12 @@ struct GlucoseWidgetEntry: TimelineEntry { /// extension recorded it. Nil once a refresh has landed. var refreshFailedAt: Date? - /// When the refresh last reached Nightscout, and whether it brought back a - /// newer reading than the one already stored. + /// When the refresh last reached Nightscout, whether it brought back a newer + /// reading than the one already stored, and failing that whether it found the + /// loop somewhere other than where the snapshot had it. var refreshCheckedAt: Date? var refreshBroughtNewData: Bool = false + var refreshMovedLoop: Bool = false /// Clock disagreement small enough to be ordinary drift between the phone /// and whatever uploaded the reading. @@ -112,7 +120,8 @@ struct GlucoseWidgetEntry: TimelineEntry { guard !refreshDidFail, let refreshCheckedAt else { return nil } let since = date.timeIntervalSince(refreshCheckedAt) guard since >= 0, since < Self.confirmationWindow else { return nil } - return refreshBroughtNewData ? .updated : .upToDate + if refreshBroughtNewData { return .updated } + return refreshMovedLoop ? .loopUpdated : .upToDate } /// Whether the answer to the last tap is new enough that the button is still @@ -130,8 +139,9 @@ struct GlucoseWidgetEntry: TimelineEntry { if refreshDidFail { return isFlashing ? .justFailed : .failed } guard isFlashing, let refreshConfirmation else { return .idle } switch refreshConfirmation { + // Green is the reading's, and the reading did not move for the other two. case .updated: return .justUpdated - case .upToDate: return .justChecked + case .loopUpdated, .upToDate: return .justChecked } } } diff --git a/LoopFollowWidget/LoopFollowWidget.swift b/LoopFollowWidget/LoopFollowWidget.swift index 1d5587621..b3a3f1ce2 100644 --- a/LoopFollowWidget/LoopFollowWidget.swift +++ b/LoopFollowWidget/LoopFollowWidget.swift @@ -259,9 +259,15 @@ struct LoopFollowWidgetView: View { .foregroundStyle(entry.isFlashing ? arriving : settled) } + /// Only `updated` gets to imply the number above it moved. A refresh that + /// found the loop somewhere new says so in those terms and leaves the reading + /// out of it, and once the reading is stale that is the fact worth the line: + /// the loop half of the answer is already on screen beside this, in the + /// warning appearing or going. private func word(for state: WidgetRefreshConfirmation) -> String { switch state { case .updated: return "Updated" + case .loopUpdated: return isStale ? "No newer reading" : "Loop status updated" case .upToDate: return isStale ? "No newer reading" : "Up to date" } } @@ -360,6 +366,7 @@ struct LoopFollowWidgetView: View { if entry.refreshDidFail { return "Refresh failed, try again" } switch entry.refreshConfirmation { case .updated: return "Refreshed" + case .loopUpdated: return isStale ? "Refreshed, no newer reading" : "Refreshed, loop status updated" case .upToDate: return isStale ? "Refreshed, no newer reading" : "Refreshed, up to date" case .none: return "Refresh" } diff --git a/LoopFollowWidget/RefreshWidgetIntent.swift b/LoopFollowWidget/RefreshWidgetIntent.swift index d4e59b176..6d336c6a1 100644 --- a/LoopFollowWidget/RefreshWidgetIntent.swift +++ b/LoopFollowWidget/RefreshWidgetIntent.swift @@ -18,6 +18,12 @@ import AppIntents /// treatments and the profile, which are several more requests than a tap can /// wait for, so those blocks fall back to their no value glyph until the app /// next writes. Everything the loop posts to devicestatus survives the refresh. +/// +/// The reading is not the only thing a tap is asking after. A loop that has +/// failed, or has just recovered, turns over between two CGM readings, and that +/// gap is exactly when the button gets pressed, so the loop's own state is +/// refreshed on its own account as well. The reading that comes back with it +/// does not move in that case, and neither does the age drawn over it. struct RefreshWidgetIntent: AppIntent { static var title: LocalizedStringResource = "Refresh Glucose" static var description = IntentDescription("Reads the latest glucose and loop status from Nightscout.") @@ -47,22 +53,30 @@ struct RefreshWidgetIntent: AppIntent { return .result() } - // Nothing to write when the site has not got a newer reading than the - // app already stored. The stored one is at least as recent and carries - // the fields this cannot rebuild, so replacing it would only lose them. - // // A stored reading stamped ahead of this device cannot be ranked by age // at all, since the clock that wrote it is wrong, so it does not get to // hold off a reading that Nightscout is serving as the current one. + let stored = GlucoseSnapshotStore.shared.load() + let storedAt = stored?.updatedAt ?? .distantPast + let storedIsRankable = storedAt <= Date().addingTimeInterval(60) + let hasNewerReading = !storedIsRankable || reading.date > storedAt + + // Nothing to write when neither the reading nor the loop has moved on. + // The stored snapshot is at least as recent and carries the fields this + // cannot rebuild, so replacing it would only lose them. // // Nothing to write is still something to say, though: the reading and // its age are about to redraw as they were, and without the note the tap // is indistinguishable from one that did nothing at all. - let stored = GlucoseSnapshotStore.shared.load()?.updatedAt ?? .distantPast - let storedIsRankable = stored <= Date().addingTimeInterval(60) - guard !storedIsRankable || reading.date > stored else { + let refreshed: GlucoseSnapshot + if hasNewerReading { + refreshed = snapshot(reading: reading, status: status) + } else if let stored, loopMoved(from: stored, to: status) { + refreshed = snapshot(carrying: stored, status: status) + } else { LAAppGroupSettings.setRefreshFailed(at: nil) LAAppGroupSettings.setRefreshChecked(at: Date(), broughtNewData: false) + WidgetRefreshOutcome.set(movedLoop: false) return .result() } @@ -72,23 +86,83 @@ struct RefreshWidgetIntent: AppIntent { // describes the snapshot it is drawn from. The other order would put a // fresh age over an old chart. await save(entries.series) - await save(snapshot(reading: reading, status: status)) + await save(refreshed) LAAppGroupSettings.setRefreshFailed(at: nil) - LAAppGroupSettings.setRefreshChecked(at: Date(), broughtNewData: true) + LAAppGroupSettings.setRefreshChecked(at: Date(), broughtNewData: hasNewerReading) + WidgetRefreshOutcome.set(movedLoop: !hasNewerReading) // WidgetKit reloads the timeline once this returns, so asking it to is // a second reload for the same change. return .result() } + // MARK: - What moved + + /// Whether a write is owed on the loop's account alone. + /// + /// Two ways it can be. The site can be serving a record later than the one + /// the stored snapshot was read from, which is a loop that has reported + /// since. Or the record can be the same one, aged past the point where it + /// still describes a running loop: a loop that stops does not report that it + /// has stopped, so nothing newer is ever going to arrive and the fifteen + /// minute verdict is the only thing that moves. + /// + /// Judged on the loop's own clock and on that verdict, never on the metrics. + /// The app and this fetcher read several of the same numbers out of different + /// keys and through different conversions, so one of them reading differently + /// is no evidence that anything changed, and acting on it would throw away + /// the app's fuller snapshot on every tap. + private func loopMoved(from stored: GlucoseSnapshot, to status: NightscoutDeviceStatus) -> Bool { + if status.isNotLooping != stored.isNotLooping { return true } + // A snapshot written before the clock was recorded cannot be ranked, so + // it is left alone until the app next writes one that can. + guard let fetched = status.loopClock, let known = stored.loopUpdatedAt else { return false } + return fetched > known + } + // MARK: - Assembly private func snapshot(reading: NightscoutReading, status: NightscoutDeviceStatus) -> GlucoseSnapshot { - GlucoseSnapshot( + snapshot( glucose: reading.mgdl, delta: reading.deltaMgdl, trend: Self.trend(from: reading.direction), - updatedAt: reading.date, + readAt: reading.date, + status: status + ) + } + + /// A loop only refresh. The reading, its delta and its trend are the stored + /// ones untouched, and so is the timestamp the age line counts from, so that + /// line goes on describing the same reading it described before the tap. The + /// rest is rebuilt from the fetch on exactly the terms a full refresh uses, + /// which is what keeps a field this cannot source from outliving the record + /// it came from. + private func snapshot(carrying stored: GlucoseSnapshot, status: NightscoutDeviceStatus) -> GlucoseSnapshot { + snapshot( + glucose: stored.glucose, + delta: stored.delta, + trend: stored.trend, + readAt: stored.updatedAt, + status: status + ) + } + + /// One assembly for both paths, so the reading half and the loop half cannot + /// drift apart in what they write. + private func snapshot( + glucose: Double, + delta: Double, + trend: GlucoseSnapshot.Trend, + readAt: Date, + status: NightscoutDeviceStatus + ) -> GlucoseSnapshot { + GlucoseSnapshot( + glucose: glucose, + delta: delta, + trend: trend, + updatedAt: readAt, + loopUpdatedAt: status.loopClock, iob: status.iob, cob: status.cob, projected: status.projected, @@ -149,3 +223,26 @@ struct RefreshWidgetIntent: AppIntent { } } } + +/// Whether the last refresh that landed had only the loop to report. It sits +/// beside the intent because it never leaves the extension: this is the only +/// thing that writes it and the timeline provider is the only thing that reads +/// it, both of them in the same process. +/// +/// Written on every path that marks a refresh as checked, so it can never be +/// read as the answer to an earlier tap than the one the timestamp names. +enum WidgetRefreshOutcome { + private static let key = "la.widget.refreshMovedLoop" + + private static var defaults: UserDefaults? { + UserDefaults(suiteName: AppGroupID.current()) + } + + static func set(movedLoop: Bool) { + defaults?.set(movedLoop, forKey: key) + } + + static func movedLoop() -> Bool { + defaults?.bool(forKey: key) ?? false + } +} diff --git a/LoopFollowWidget/WidgetTimelineProvider.swift b/LoopFollowWidget/WidgetTimelineProvider.swift index 9d3638bad..8788083c0 100644 --- a/LoopFollowWidget/WidgetTimelineProvider.swift +++ b/LoopFollowWidget/WidgetTimelineProvider.swift @@ -64,7 +64,8 @@ struct WidgetTimelineProvider: AppIntentTimelineProvider { canRefresh: Self.canRefresh, refreshFailedAt: LAAppGroupSettings.refreshFailedAt(), refreshCheckedAt: LAAppGroupSettings.refreshCheckedAt(), - refreshBroughtNewData: LAAppGroupSettings.refreshBroughtNewData() + refreshBroughtNewData: LAAppGroupSettings.refreshBroughtNewData(), + refreshMovedLoop: WidgetRefreshOutcome.movedLoop() ) } @@ -76,6 +77,7 @@ struct WidgetTimelineProvider: AppIntentTimelineProvider { let refreshFailedAt = LAAppGroupSettings.refreshFailedAt() let refreshCheckedAt = LAAppGroupSettings.refreshCheckedAt() let refreshBroughtNewData = LAAppGroupSettings.refreshBroughtNewData() + let refreshMovedLoop = WidgetRefreshOutcome.movedLoop() let canRefresh = Self.canRefresh // One deadline per thing a tap leaves on screen: the button's own @@ -97,7 +99,8 @@ struct WidgetTimelineProvider: AppIntentTimelineProvider { canRefresh: canRefresh, refreshFailedAt: refreshFailedAt, refreshCheckedAt: refreshCheckedAt, - refreshBroughtNewData: refreshBroughtNewData + refreshBroughtNewData: refreshBroughtNewData, + refreshMovedLoop: refreshMovedLoop ) }