From 0007c2c959513b896986bd3b60ded1851a742090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 21 Sep 2026 10:20:38 +0200 Subject: [PATCH 01/84] Add edit_meal and delete_meal TRC commands --- LoopFollow/Remote/TRC/PushMessage.swift | 11 ++++ .../Remote/TRC/PushNotificationManager.swift | 53 ++++++++++++++- LoopFollow/Remote/TRC/TRCCommandType.swift | 9 +++ Tests/TRCMealCommandPayloadTests.swift | 65 +++++++++++++++++++ 4 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 Tests/TRCMealCommandPayloadTests.swift diff --git a/LoopFollow/Remote/TRC/PushMessage.swift b/LoopFollow/Remote/TRC/PushMessage.swift index 09ea4e817..319482c7c 100644 --- a/LoopFollow/Remote/TRC/PushMessage.swift +++ b/LoopFollow/Remote/TRC/PushMessage.swift @@ -43,8 +43,17 @@ struct CommandPayload: Encodable { var fat: Int? var overrideName: String? var scheduledTime: TimeInterval? + var commandID: String? + var mealID: String? var returnNotification: ReturnNotificationInfo? + var apnsCollapseID: String { + if commandType.usesCommandIDAsCollapseID, let commandID { + return commandID + } + return commandType.rawValue + } + struct ReturnNotificationInfo: Encodable { let productionEnvironment: Bool let deviceToken: String @@ -75,6 +84,8 @@ struct CommandPayload: Encodable { case fat case overrideName case scheduledTime = "scheduled_time" + case commandID = "command_id" + case mealID = "meal_id" case returnNotification = "return_notification" } } diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index aa1f661a2..9c94616be 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -164,6 +164,57 @@ class PushNotificationManager { sendEncryptedCommand(payload: payload, completion: completion) } + /// Trio confirms edit/delete through a return notification, so LoopFollow's own APNs credentials are required. + func sendDeleteMealPushNotification(mealID: String, commandID: String, completion: @escaping (Bool, String?) -> Void) { + guard let returnNotification = createReturnNotificationInfo() else { + completion(false, Self.returnNotificationRequiredMessage) + return + } + let payload = CommandPayload( + user: user, + commandType: .deleteMeal, + timestamp: Date().timeIntervalSince1970, + commandID: commandID, + mealID: mealID, + returnNotification: returnNotification + ) + sendEncryptedCommand(payload: payload, completion: completion) + } + + func sendEditMealPushNotification( + mealID: String, + commandID: String, + carbs: Int, + fat: Int, + protein: Int, + scheduledTime: Date, + completion: @escaping (Bool, String?) -> Void + ) { + guard carbs >= 0, fat >= 0, protein >= 0, carbs > 0 || fat > 0 || protein > 0 else { + completion(false, "No nutrient data provided. At least one of carbs, fat, or protein must be greater than 0.") + return + } + guard let returnNotification = createReturnNotificationInfo() else { + completion(false, Self.returnNotificationRequiredMessage) + return + } + let payload = CommandPayload( + user: user, + commandType: .editMeal, + timestamp: Date().timeIntervalSince1970, + carbs: carbs, + protein: protein, + fat: fat, + scheduledTime: scheduledTime.timeIntervalSince1970, + commandID: commandID, + mealID: mealID, + returnNotification: returnNotification + ) + sendEncryptedCommand(payload: payload, completion: completion) + } + + private static let returnNotificationRequiredMessage = "Editing or deleting a meal needs LoopFollow's own APNS credentials so Trio can confirm the result. Configure them in App Settings β†’ APN." + private func validateCredentials() -> [String]? { var errors = [String]() let keyIdPattern = "^[A-Z0-9]{10}$" @@ -263,7 +314,7 @@ class PushNotificationManager { request.setValue("600", forHTTPHeaderField: "apns-expiration") request.setValue(bundleId, forHTTPHeaderField: "apns-topic") request.setValue("alert", forHTTPHeaderField: "apns-push-type") - request.setValue(payload.commandType.rawValue, forHTTPHeaderField: "apns-collapse-id") + request.setValue(payload.apnsCollapseID, forHTTPHeaderField: "apns-collapse-id") request.httpBody = try JSONEncoder().encode(finalMessage) diff --git a/LoopFollow/Remote/TRC/TRCCommandType.swift b/LoopFollow/Remote/TRC/TRCCommandType.swift index 520f8a43a..e444f076b 100644 --- a/LoopFollow/Remote/TRC/TRCCommandType.swift +++ b/LoopFollow/Remote/TRC/TRCCommandType.swift @@ -10,6 +10,13 @@ enum TRCCommandType: String, Encodable { case meal case startOverride = "start_override" case cancelOverride = "cancel_override" + case editMeal = "edit_meal" + case deleteMeal = "delete_meal" + + /// Meal mutations get one APNs collapse id per command so two sends never collapse into one. + var usesCommandIDAsCollapseID: Bool { + self == .editMeal || self == .deleteMeal + } var displayName: String { switch self { @@ -19,6 +26,8 @@ enum TRCCommandType: String, Encodable { case .meal: return "Meal" case .startOverride: return "Start Override" case .cancelOverride: return "Cancel Override" + case .editMeal: return "Edit Meal" + case .deleteMeal: return "Delete Meal" } } } diff --git a/Tests/TRCMealCommandPayloadTests.swift b/Tests/TRCMealCommandPayloadTests.swift new file mode 100644 index 000000000..34f21c484 --- /dev/null +++ b/Tests/TRCMealCommandPayloadTests.swift @@ -0,0 +1,65 @@ +// LoopFollow +// TRCMealCommandPayloadTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct TRCMealCommandPayloadTests { + private func encode(_ payload: CommandPayload) throws -> [String: Any] { + let data = try JSONEncoder().encode(payload) + return try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:] + } + + @Test("edit_meal carries ids, explicit macros and the new time") + func editMealKeys() throws { + let payload = CommandPayload( + user: "u", + commandType: .editMeal, + timestamp: 1_700_000_000, + carbs: 45, + protein: 15, + fat: 0, + scheduledTime: 1_700_000_600, + commandID: "CMD", + mealID: "MEAL" + ) + let json = try encode(payload) + #expect(json["command_type"] as? String == "edit_meal") + #expect(json["command_id"] as? String == "CMD") + #expect(json["meal_id"] as? String == "MEAL") + #expect(json["scheduled_time"] as? Double == 1_700_000_600) + #expect(json["carbs"] as? Int == 45) + #expect(json["protein"] as? Int == 15) + #expect(json["fat"] as? Int == 0) + } + + @Test("delete_meal carries no macros") + func deleteMealKeys() throws { + let payload = CommandPayload( + user: "u", + commandType: .deleteMeal, + timestamp: 1_700_000_000, + commandID: "CMD", + mealID: "MEAL" + ) + let json = try encode(payload) + #expect(json["command_type"] as? String == "delete_meal") + #expect(json["meal_id"] as? String == "MEAL") + #expect(json["carbs"] == nil) + #expect(json["fat"] == nil) + #expect(json["protein"] == nil) + #expect(json["scheduled_time"] == nil) + #expect(json["bolus_amount"] == nil) + } + + @Test("collapse id is the command id for meal mutations only") + func collapseID() { + let edit = CommandPayload(user: "u", commandType: .editMeal, timestamp: 0, commandID: "CMD", mealID: "M") + let delete = CommandPayload(user: "u", commandType: .deleteMeal, timestamp: 0, commandID: "CMD2", mealID: "M") + let meal = CommandPayload(user: "u", commandType: .meal, timestamp: 0, carbs: 10, commandID: "CMD3") + #expect(edit.apnsCollapseID == "CMD") + #expect(delete.apnsCollapseID == "CMD2") + #expect(meal.apnsCollapseID == "meal") + } +} From 562a2550acb19390507346d5840985b5030c08e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 21 Sep 2026 10:21:36 +0200 Subject: [PATCH 02/84] Track acks for remote meal edit and delete commands --- LoopFollow/Application/AppDelegate.swift | 4 + LoopFollow/Remote/TRC/TRCCommandTracker.swift | 184 ++++++++++++++++++ Tests/TRCCommandAckTests.swift | 81 ++++++++ 3 files changed, 269 insertions(+) create mode 100644 LoopFollow/Remote/TRC/TRCCommandTracker.swift create mode 100644 Tests/TRCCommandAckTests.swift diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 3c364fc2c..64099f6f1 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -134,6 +134,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { let userInfoKeys = userInfo.keys.compactMap { $0 as? String }.sorted() LogManager.shared.log(category: .apns, message: "Received remote notification: keys=\(userInfoKeys)") + TRCCommandTracker.shared.handleNotification(userInfo: userInfo) // Check if this is a response notification from Loop or Trio if let aps = userInfo["aps"] as? [String: Any] { @@ -183,6 +184,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + TRCCommandTracker.shared.handleNotification(userInfo: response.notification.request.content.userInfo) + if response.actionIdentifier == "OPEN_APP_ACTION" { // Dismiss any presented modal/sheet so the user actually sees Home UIApplication.shared.topMost?.dismiss(animated: true) @@ -267,6 +270,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { let content = notification.request.content + TRCCommandTracker.shared.handleNotification(userInfo: content.userInfo) let userInfoKeys = content.userInfo.keys.compactMap { $0 as? String }.sorted() LogManager.shared.log( category: .general, diff --git a/LoopFollow/Remote/TRC/TRCCommandTracker.swift b/LoopFollow/Remote/TRC/TRCCommandTracker.swift new file mode 100644 index 000000000..eda64df13 --- /dev/null +++ b/LoopFollow/Remote/TRC/TRCCommandTracker.swift @@ -0,0 +1,184 @@ +// LoopFollow +// TRCCommandTracker.swift + +import Foundation + +/// Return notification Trio sends after processing a command. +struct TRCCommandAck: Equatable { + let commandID: String? + let commandType: String? + let status: String + let result: String? + let mealID: String? + let message: String? + + init?(userInfo: [AnyHashable: Any]) { + guard let status = userInfo["command_status"] as? String else { return nil } + self.status = status + commandID = userInfo["command_id"] as? String + commandType = userInfo["command_type"] as? String + result = userInfo["result"] as? String + mealID = userInfo["meal_id"] as? String + let alert = (userInfo["aps"] as? [String: Any])?["alert"] as? [String: Any] + message = alert?["body"] as? String + } + + var isSuccess: Bool { status == "success" } +} + +extension Notification.Name { + /// Posted when a remote meal edit/delete reaches a terminal state, so treatment lists can refresh. + static let remoteMealCommandDidComplete = Notification.Name("LoopFollow.remoteMealCommandDidComplete") +} + +/// Tracks in-flight meal edit/delete commands to Trio and correlates Trio's acks by command id. +final class TRCCommandTracker: ObservableObject { + static let shared = TRCCommandTracker() + static let timeout: TimeInterval = 60 + + struct PendingCommand: Identifiable, Equatable { + let id: String + let type: TRCCommandType + let mealID: String + let sentAt: Date + } + + enum Outcome: Equatable { + case deleted + case updated(newMealID: String?) + case notFound + case rejected + case failed + case timedOut + case sendFailed + + var isSuccess: Bool { + switch self { + case .deleted, .updated: return true + default: return false + } + } + } + + struct CompletedCommand: Equatable { + let command: PendingCommand + let outcome: Outcome + let message: String? + let completedAt: Date + + var isSuccess: Bool { outcome.isSuccess } + + var displayMessage: String { + if let message, !message.isEmpty { return message } + switch outcome { + case .deleted: return "Meal deleted." + case .updated: return "Meal updated." + case .notFound: return "Trio could not find this meal. It may already have been deleted or changed on the phone." + case .rejected: return "Trio rejected the command." + case .failed: return "Trio reported a failure." + case .timedOut: return "No confirmation from Trio yet. Refresh the list to check whether the change was applied." + case .sendFailed: return "The command could not be sent." + } + } + } + + @Published private(set) var pending: [String: PendingCommand] = [:] + @Published private(set) var lastCompleted: [String: CompletedCommand] = [:] + + private var timeouts: [String: DispatchWorkItem] = [:] + + func pendingCommand(forMealID mealID: String) -> PendingCommand? { + pending.values.first { $0.mealID == mealID } + } + + func lastResult(forMealID mealID: String) -> CompletedCommand? { + lastCompleted[mealID] + } + + func sendDelete(mealID: String, completion: @escaping (Bool, String?) -> Void) { + let command = register(type: .deleteMeal, mealID: mealID) + PushNotificationManager().sendDeleteMealPushNotification(mealID: command.mealID, commandID: command.id) { [weak self] success, error in + self?.handleSendResult(command, success: success, error: error, completion: completion) + } + } + + func sendEdit(mealID: String, carbs: Int, fat: Int, protein: Int, date: Date, completion: @escaping (Bool, String?) -> Void) { + let command = register(type: .editMeal, mealID: mealID) + PushNotificationManager().sendEditMealPushNotification( + mealID: command.mealID, + commandID: command.id, + carbs: carbs, + fat: fat, + protein: protein, + scheduledTime: date + ) { [weak self] success, error in + self?.handleSendResult(command, success: success, error: error, completion: completion) + } + } + + /// Returns true when the notification matched a pending command. + @discardableResult + func handleNotification(userInfo: [AnyHashable: Any]) -> Bool { + guard let ack = TRCCommandAck(userInfo: userInfo) else { return false } + LogManager.shared.log( + category: .apns, + message: "TRC ack: type=\(ack.commandType ?? "-") status=\(ack.status) result=\(ack.result ?? "-") command_id=\(ack.commandID ?? "-")" + ) + guard let commandID = ack.commandID, let command = pending[commandID] else { return false } + + let outcome: Outcome + switch ack.result { + case "deleted": outcome = .deleted + case "updated": outcome = .updated(newMealID: ack.mealID) + case "not_found": outcome = .notFound + case "rejected": outcome = .rejected + default: + if ack.isSuccess { + outcome = command.type == .deleteMeal ? .deleted : .updated(newMealID: ack.mealID) + } else { + outcome = .failed + } + } + complete(command, outcome: outcome, message: ack.message) + return true + } + + // MARK: - Internal + + func register(type: TRCCommandType, mealID: String) -> PendingCommand { + let command = PendingCommand(id: UUID().uuidString, type: type, mealID: mealID, sentAt: Date()) + DispatchQueue.main.async { + self.pending[command.id] = command + self.lastCompleted[mealID] = nil + } + return command + } + + private func handleSendResult(_ command: PendingCommand, success: Bool, error: String?, completion: @escaping (Bool, String?) -> Void) { + DispatchQueue.main.async { + guard success else { + self.complete(command, outcome: .sendFailed, message: error) + completion(false, error) + return + } + let work = DispatchWorkItem { [weak self] in + self?.complete(command, outcome: .timedOut, message: nil) + } + self.timeouts[command.id] = work + DispatchQueue.main.asyncAfter(deadline: .now() + Self.timeout, execute: work) + LogManager.shared.log(category: .apns, message: "\(command.type.rawValue) sent command_id=\(command.id) meal_id=\(command.mealID)") + completion(true, nil) + } + } + + func complete(_ command: PendingCommand, outcome: Outcome, message: String?) { + DispatchQueue.main.async { + self.timeouts[command.id]?.cancel() + self.timeouts[command.id] = nil + guard self.pending[command.id] != nil else { return } + self.pending[command.id] = nil + self.lastCompleted[command.mealID] = CompletedCommand(command: command, outcome: outcome, message: message, completedAt: Date()) + NotificationCenter.default.post(name: .remoteMealCommandDidComplete, object: nil, userInfo: ["mealID": command.mealID]) + } + } +} diff --git a/Tests/TRCCommandAckTests.swift b/Tests/TRCCommandAckTests.swift new file mode 100644 index 000000000..0a994fb3a --- /dev/null +++ b/Tests/TRCCommandAckTests.swift @@ -0,0 +1,81 @@ +// LoopFollow +// TRCCommandAckTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct TRCCommandAckTests { + private func ack(status: String = "success", result: String? = "deleted", commandID: String? = "CMD", mealID: String? = "MEAL", body: String? = "Meal deleted") -> [AnyHashable: Any] { + var info: [AnyHashable: Any] = [ + "command_status": status, + "command_type": "delete_meal", + "timestamp": 1_700_000_000.0, + ] + if let result { info["result"] = result } + if let commandID { info["command_id"] = commandID } + if let mealID { info["meal_id"] = mealID } + if let body { info["aps"] = ["alert": ["title": "Command Successful", "body": body], "sound": "default"] } + return info + } + + @Test("parses Trio's return notification") + func parsesAck() { + let parsed = TRCCommandAck(userInfo: ack()) + #expect(parsed?.status == "success") + #expect(parsed?.isSuccess == true) + #expect(parsed?.commandType == "delete_meal") + #expect(parsed?.result == "deleted") + #expect(parsed?.commandID == "CMD") + #expect(parsed?.mealID == "MEAL") + #expect(parsed?.message == "Meal deleted") + } + + @Test("requires command_status, tolerates legacy acks without ids") + func fallbacks() { + #expect(TRCCommandAck(userInfo: ["command_type": "meal"]) == nil) + let legacy = TRCCommandAck(userInfo: ack(result: nil, commandID: nil, mealID: nil, body: nil)) + #expect(legacy != nil) + #expect(legacy?.commandID == nil) + #expect(legacy?.message == nil) + } + + @Test("unknown command ids are ignored") + func ignoresUnknown() { + #expect(TRCCommandTracker.shared.handleNotification(userInfo: ack(commandID: "nope")) == false) + } + + @Test("a matching ack completes the pending command") + func completesPending() async throws { + let tracker = TRCCommandTracker.shared + let command = tracker.register(type: .deleteMeal, mealID: "MEAL-1") + try await Task.sleep(for: .milliseconds(50)) + #expect(tracker.pendingCommand(forMealID: "MEAL-1")?.id == command.id) + + #expect(tracker.handleNotification(userInfo: ack(commandID: command.id, mealID: "MEAL-1")) == true) + try await Task.sleep(for: .milliseconds(50)) + #expect(tracker.pendingCommand(forMealID: "MEAL-1") == nil) + let result = tracker.lastResult(forMealID: "MEAL-1") + #expect(result?.outcome == .deleted) + #expect(result?.isSuccess == true) + } + + @Test("result values map to outcomes") + func outcomes() async throws { + let tracker = TRCCommandTracker.shared + let cases: [(String?, String, TRCCommandTracker.Outcome)] = [ + ("updated", "success", .updated(newMealID: "NEW")), + ("not_found", "failed", .notFound), + ("rejected", "failed", .rejected), + (nil, "failed", .failed), + ] + for (index, testCase) in cases.enumerated() { + let mealID = "MEAL-\(index)" + let command = tracker.register(type: .editMeal, mealID: mealID) + try await Task.sleep(for: .milliseconds(50)) + tracker.handleNotification(userInfo: ack(status: testCase.1, result: testCase.0, commandID: command.id, mealID: "NEW")) + try await Task.sleep(for: .milliseconds(50)) + #expect(tracker.lastResult(forMealID: mealID)?.outcome == testCase.2) + } + } +} From 2a8e8577d725c70c20e5f92caaaa1769f12ca932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 21 Sep 2026 10:23:09 +0200 Subject: [PATCH 03/84] Keep Trio meal metadata and FPU families in the Treatments list --- .../Controllers/Nightscout/NSProfile.swift | 2 + .../Nightscout/ProfileManager.swift | 1 + LoopFollow/Storage/Storage.swift | 2 + LoopFollow/Treatments/TreatmentsView.swift | 37 +++++-- LoopFollow/Treatments/TrioMealTreatment.swift | 93 ++++++++++++++++++ Tests/TrioMealTreatmentTests.swift | 96 +++++++++++++++++++ 6 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 LoopFollow/Treatments/TrioMealTreatment.swift create mode 100644 Tests/TrioMealTreatmentTests.swift diff --git a/LoopFollow/Controllers/Nightscout/NSProfile.swift b/LoopFollow/Controllers/Nightscout/NSProfile.swift index 669de7297..e4f5146cf 100644 --- a/LoopFollow/Controllers/Nightscout/NSProfile.swift +++ b/LoopFollow/Controllers/Nightscout/NSProfile.swift @@ -50,6 +50,7 @@ struct NSProfile: Decodable { let expirationDate: String? let startDate: String? let createdAt: String? + let remoteCommands: [String]? struct TrioOverrideEntry: Decodable { let name: String @@ -101,5 +102,6 @@ struct NSProfile: Decodable { case expirationDate case startDate case createdAt = "created_at" + case remoteCommands } } diff --git a/LoopFollow/Controllers/Nightscout/ProfileManager.swift b/LoopFollow/Controllers/Nightscout/ProfileManager.swift index 520afde88..2ba7d821e 100644 --- a/LoopFollow/Controllers/Nightscout/ProfileManager.swift +++ b/LoopFollow/Controllers/Nightscout/ProfileManager.swift @@ -122,6 +122,7 @@ final class ProfileManager { } Storage.shared.teamId.value = profileData.teamID ?? Storage.shared.teamId.value ?? "" + Storage.shared.remoteCommands.value = profileData.remoteCommands ?? [] } func currentISF() -> HKQuantity? { diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index dd63f6dd6..64829a325 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -22,6 +22,8 @@ class Storage { var lfKeyId = StorageValue(key: "lfKeyId", defaultValue: "") var bundleId = StorageValue(key: "bundleId", defaultValue: "") var user = StorageValue(key: "user", defaultValue: "") + /// Remote command types the AID app advertises in its Nightscout profile. + var remoteCommands = StorageValue<[String]>(key: "remoteCommands", defaultValue: []) var maxBolus = SecureStorageValue(key: "maxBolus", defaultValue: HKQuantity(unit: .internationalUnit(), doubleValue: 1.0)) var maxCarbs = SecureStorageValue(key: "maxCarbs", defaultValue: HKQuantity(unit: .gram(), doubleValue: 30.0)) diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift index f1b1c7595..d811270eb 100644 --- a/LoopFollow/Treatments/TreatmentsView.swift +++ b/LoopFollow/Treatments/TreatmentsView.swift @@ -121,7 +121,7 @@ struct TreatmentsView: View { .padding(.bottom, 2) .background(Color(.systemBackground)) } else if let treatment = row.treatment { - TreatmentRow(treatment: treatment) + TreatmentRow(treatment: treatment, rootMeal: viewModel.rootMeal(forFPUChild: treatment)) } } } header: { @@ -199,6 +199,9 @@ struct TreatmentsView: View { .onChange(of: device.value) { newValue in normalizeSelectedFilter(for: newValue) } + .onReceive(NotificationCenter.default.publisher(for: .remoteMealCommandDidComplete)) { _ in + viewModel.refreshTreatments() + } } } } @@ -393,6 +396,7 @@ private struct DayRow: Identifiable { struct TreatmentDetailView: View { let treatment: Treatment + var rootMeal: Treatment? = nil @StateObject private var viewModel = TreatmentDetailViewModel() var body: some View { @@ -832,9 +836,10 @@ class TreatmentDetailViewModel: ObservableObject { struct TreatmentRow: View { let treatment: Treatment + var rootMeal: Treatment? = nil var body: some View { - NavigationLink(destination: TreatmentDetailView(treatment: treatment)) { + NavigationLink(destination: TreatmentDetailView(treatment: treatment, rootMeal: rootMeal)) { HStack { Image(systemName: treatment.icon) .foregroundColor(treatment.color) @@ -947,8 +952,9 @@ struct Treatment: Identifiable { let icon: String let color: Color let bgValue: Int + let trioMeal: TrioMealTreatment? - init(id: String? = nil, type: TreatmentType, date: TimeInterval, title: String, subtitle: String?, icon: String, color: Color, bgValue: Int) { + init(id: String? = nil, type: TreatmentType, date: TimeInterval, title: String, subtitle: String?, icon: String, color: Color, bgValue: Int, trioMeal: TrioMealTreatment? = nil) { self.id = id ?? "\(type)-\(date)-\(title)" self.type = type self.date = date @@ -957,6 +963,7 @@ struct Treatment: Identifiable { self.icon = icon self.color = color self.bgValue = bgValue + self.trioMeal = trioMeal } var hourKey: String { @@ -1016,6 +1023,15 @@ class TreatmentsViewModel: ObservableObject { } } + /// The root meal for an FPU child, when the Trio build publishes `fpuID` and the root is loaded. + func rootMeal(forFPUChild treatment: Treatment) -> Treatment? { + guard let child = treatment.trioMeal, child.isFPUChild, let fpuID = child.fpuID else { return nil } + return allTreatments.first { candidate in + guard let meal = candidate.trioMeal, !meal.isFPUChild else { return false } + return meal.fpuID == fpuID + } + } + func refreshTreatments() { allTreatments.removeAll() processedNightscoutIds.removeAll() @@ -1150,6 +1166,7 @@ class TreatmentsViewModel: ObservableObject { var detectedSMB = false var detectedAutomatic = false guard let mainVC = getMainViewController() else { return ([], false, false) } + let siblingIDCounts = TrioMealTreatment.siblingIDCounts(in: entries) for entry in entries { guard let eventType = entry["eventType"] as? String, @@ -1172,17 +1189,23 @@ class TreatmentsViewModel: ObservableObject { switch eventType { case "Carb Correction", "Meal Bolus": - if let carbs = entry["carbs"] as? Double, carbs > 0 { + let trioMeal = eventType == "Carb Correction" + ? TrioMealTreatment(nightscoutEntry: entry, date: timestamp, siblingIDCount: siblingIDCounts[entry["id"] as? String ?? ""] ?? 0) + : nil + let carbs = entry["carbs"] as? Double ?? 0 + if carbs > 0 || trioMeal != nil { let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) + let title = carbs > 0 ? "\(Int(carbs))g" : "F\(trioMeal?.fat ?? 0) P\(trioMeal?.protein ?? 0)" let treatment = Treatment( id: "\(nsId)-carb", type: .carb, date: timestamp, - title: "\(Int(carbs))g", - subtitle: "Carbs", + title: title, + subtitle: trioMeal?.isFPUChild == true ? "Carbs β€’ FPU" : "Carbs", icon: "circle.fill", color: .orange, - bgValue: actualBG + bgValue: actualBG, + trioMeal: trioMeal ) treatments.append(treatment) } diff --git a/LoopFollow/Treatments/TrioMealTreatment.swift b/LoopFollow/Treatments/TrioMealTreatment.swift new file mode 100644 index 000000000..19690d21f --- /dev/null +++ b/LoopFollow/Treatments/TrioMealTreatment.swift @@ -0,0 +1,93 @@ +// LoopFollow +// TrioMealTreatment.swift + +import Foundation + +/// Trio meal metadata from a Nightscout "Carb Correction" treatment. +/// +/// Nightscout's `_id` identifies the document; `id` is Trio's meal UUID. FPU children are +/// separate documents whose `id` equals the root's `fpuID`, so either value works as a handle +/// for Trio's remote edit/delete commands. +struct TrioMealTreatment: Equatable { + static let pastEditWindow: TimeInterval = 24 * 3600 + static let futureEditWindow: TimeInterval = 12 * 3600 + static let requiredRemoteCommands: Set = [TRCCommandType.editMeal.rawValue, TRCCommandType.deleteMeal.rawValue] + + let nightscoutID: String + let mealID: UUID + let fpuID: UUID? + let date: TimeInterval + let carbs: Double + let fat: Int + let protein: Int + let note: String? + let isFPUChild: Bool + + /// `siblingIDCount` is how many Trio carb documents in the same page share this `id`. + /// Builds without `fpuID` in the upload are classified by that count: children share an id, roots are unique. + init?(nightscoutEntry entry: [String: AnyObject], date: TimeInterval, siblingIDCount: Int) { + guard entry["enteredBy"] as? String == "Trio", + entry["eventType"] as? String == "Carb Correction", + let rawID = entry["id"] as? String, + let mealID = UUID(uuidString: rawID) + else { + return nil + } + + let carbs = Self.number(entry["carbs"]) ?? 0 + let fat = Int((Self.number(entry["fat"]) ?? 0).rounded()) + let protein = Int((Self.number(entry["protein"]) ?? 0).rounded()) + guard carbs > 0 || fat > 0 || protein > 0 else { return nil } + + let fpuID = (entry["fpuID"] as? String).flatMap(UUID.init(uuidString:)) + let isFPUChild: Bool + if let fpuID { + isFPUChild = fpuID == mealID + } else { + isFPUChild = siblingIDCount > 1 + } + + let rawNote = (entry["notes"] as? String) ?? (entry["foodType"] as? String) + let trimmedNote = rawNote?.trimmingCharacters(in: .whitespacesAndNewlines) + + nightscoutID = entry["_id"] as? String ?? "" + self.mealID = mealID + self.fpuID = fpuID + self.date = date + self.carbs = carbs + self.fat = fat + self.protein = protein + note = trimmedNote?.isEmpty == false ? trimmedNote : nil + self.isFPUChild = isFPUChild + } + + var carbsForEdit: Int { Int(carbs.rounded()) } + + func isWithinEditWindow(now: Date = Date()) -> Bool { + let age = now.timeIntervalSince1970 - date + return age <= Self.pastEditWindow && age >= -Self.futureEditWindow + } + + static func siblingIDCounts(in entries: [[String: AnyObject]]) -> [String: Int] { + var counts: [String: Int] = [:] + for entry in entries { + guard entry["enteredBy"] as? String == "Trio", + entry["eventType"] as? String == "Carb Correction", + let id = entry["id"] as? String + else { continue } + counts[id, default: 0] += 1 + } + return counts + } + + /// Remote edit/delete is offered only when Trio Remote Control targets a Trio that lists both commands. + static func remoteActionsAvailable(remoteType: RemoteType, device: String, remoteCommands: [String]) -> Bool { + remoteType == .trc && device == "Trio" && requiredRemoteCommands.isSubset(of: remoteCommands) + } + + private static func number(_ value: AnyObject?) -> Double? { + if let number = value as? NSNumber { return number.doubleValue } + if let string = value as? String { return Double(string) } + return nil + } +} diff --git a/Tests/TrioMealTreatmentTests.swift b/Tests/TrioMealTreatmentTests.swift new file mode 100644 index 000000000..fac5bfc11 --- /dev/null +++ b/Tests/TrioMealTreatmentTests.swift @@ -0,0 +1,96 @@ +// LoopFollow +// TrioMealTreatmentTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct TrioMealTreatmentTests { + private typealias Entry = [String: AnyObject] + + private let rootID = "11111111-1111-4111-8111-111111111111" + private let familyID = "22222222-2222-4222-8222-222222222222" + + private func entry(id: String, fpuID: String? = nil, carbs: Double = 45, fat: Double = 0, protein: Double = 0, enteredBy: String = "Trio", eventType: String = "Carb Correction", notes: String? = nil) -> Entry { + var result: Entry = [ + "_id": "mongo-\(id)" as AnyObject, + "id": id as AnyObject, + "enteredBy": enteredBy as AnyObject, + "eventType": eventType as AnyObject, + "carbs": carbs as AnyObject, + "fat": fat as AnyObject, + "protein": protein as AnyObject, + ] + if let fpuID { result["fpuID"] = fpuID as AnyObject } + if let notes { result["notes"] = notes as AnyObject } + return result + } + + @Test("root with fpuID is not a child") + func root() { + let meal = TrioMealTreatment(nightscoutEntry: entry(id: rootID, fpuID: familyID, fat: 20, protein: 15, notes: " πŸ“‘ "), date: 0, siblingIDCount: 1) + #expect(meal?.isFPUChild == false) + #expect(meal?.mealID.uuidString == rootID) + #expect(meal?.fpuID?.uuidString == familyID) + #expect(meal?.fat == 20) + #expect(meal?.protein == 15) + #expect(meal?.note == "πŸ“‘") + #expect(meal?.nightscoutID == "mongo-\(rootID)") + } + + @Test("child carries the family id as both id and fpuID") + func child() { + let meal = TrioMealTreatment(nightscoutEntry: entry(id: familyID, fpuID: familyID, carbs: 12), date: 0, siblingIDCount: 3) + #expect(meal?.isFPUChild == true) + #expect(meal?.mealID.uuidString == familyID) + } + + @Test("without fpuID, shared ids mean children and unique ids mean roots") + func legacy() { + #expect(TrioMealTreatment(nightscoutEntry: entry(id: familyID, carbs: 12), date: 0, siblingIDCount: 3)?.isFPUChild == true) + #expect(TrioMealTreatment(nightscoutEntry: entry(id: rootID), date: 0, siblingIDCount: 1)?.isFPUChild == false) + } + + @Test("rejects non-Trio, non-carb, non-UUID and empty entries") + func rejects() { + #expect(TrioMealTreatment(nightscoutEntry: entry(id: rootID, enteredBy: "loop://phone"), date: 0, siblingIDCount: 1) == nil) + #expect(TrioMealTreatment(nightscoutEntry: entry(id: rootID, eventType: "Meal Bolus"), date: 0, siblingIDCount: 1) == nil) + #expect(TrioMealTreatment(nightscoutEntry: entry(id: "not-a-uuid"), date: 0, siblingIDCount: 1) == nil) + #expect(TrioMealTreatment(nightscoutEntry: entry(id: rootID, carbs: 0), date: 0, siblingIDCount: 1) == nil) + } + + @Test("sibling counts only count Trio carb documents") + func siblingCounts() { + let counts = TrioMealTreatment.siblingIDCounts(in: [ + entry(id: familyID, carbs: 10), + entry(id: familyID, carbs: 10), + entry(id: rootID), + entry(id: rootID, enteredBy: "mock"), + entry(id: rootID, eventType: "Note"), + ]) + #expect(counts[familyID] == 2) + #expect(counts[rootID] == 1) + } + + @Test("edit window is 24 h back and 12 h ahead") + func window() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + func meal(offsetHours: Double) -> TrioMealTreatment? { + TrioMealTreatment(nightscoutEntry: entry(id: rootID), date: now.timeIntervalSince1970 + offsetHours * 3600, siblingIDCount: 1) + } + #expect(meal(offsetHours: -23)?.isWithinEditWindow(now: now) == true) + #expect(meal(offsetHours: -25)?.isWithinEditWindow(now: now) == false) + #expect(meal(offsetHours: 11)?.isWithinEditWindow(now: now) == true) + #expect(meal(offsetHours: 13)?.isWithinEditWindow(now: now) == false) + } + + @Test("actions need TRC, a Trio device and both commands advertised") + func gating() { + let both = ["meal", "edit_meal", "delete_meal"] + #expect(TrioMealTreatment.remoteActionsAvailable(remoteType: .trc, device: "Trio", remoteCommands: both) == true) + #expect(TrioMealTreatment.remoteActionsAvailable(remoteType: .trc, device: "Trio", remoteCommands: ["meal"]) == false) + #expect(TrioMealTreatment.remoteActionsAvailable(remoteType: .trc, device: "Loop", remoteCommands: both) == false) + #expect(TrioMealTreatment.remoteActionsAvailable(remoteType: .loopAPNS, device: "Trio", remoteCommands: both) == false) + #expect(TrioMealTreatment.remoteActionsAvailable(remoteType: .none, device: "Trio", remoteCommands: both) == false) + } +} From a65536e3d497c445261ce53aee872cf10b5ad622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 21 Sep 2026 10:25:38 +0200 Subject: [PATCH 04/84] Add remote Edit and Delete for Trio meals in treatment details --- LoopFollow/Remote/TRC/MealMacroInputs.swift | 58 ++++++ LoopFollow/Remote/TRC/MealView.swift | 48 +---- LoopFollow/Remote/TRC/TRCMealEditView.swift | 201 ++++++++++++++++++++ LoopFollow/Treatments/TreatmentsView.swift | 93 +++++++++ 4 files changed, 361 insertions(+), 39 deletions(-) create mode 100644 LoopFollow/Remote/TRC/MealMacroInputs.swift create mode 100644 LoopFollow/Remote/TRC/TRCMealEditView.swift diff --git a/LoopFollow/Remote/TRC/MealMacroInputs.swift b/LoopFollow/Remote/TRC/MealMacroInputs.swift new file mode 100644 index 000000000..dc8eca657 --- /dev/null +++ b/LoopFollow/Remote/TRC/MealMacroInputs.swift @@ -0,0 +1,58 @@ +// LoopFollow +// MealMacroInputs.swift + +import HealthKit +import SwiftUI + +/// Carbs, fat and protein rows bound to the remote guardrail maxima. +struct MealMacroInputs: View { + @Binding var carbs: HKQuantity + @Binding var fat: HKQuantity + @Binding var protein: HKQuantity + var showFatProtein: Bool + @FocusState.Binding var carbsFocused: Bool + @FocusState.Binding var fatFocused: Bool + @FocusState.Binding var proteinFocused: Bool + var onValidationError: (String) -> Void + + @ObservedObject private var maxCarbs = Storage.shared.maxCarbs + @ObservedObject private var maxProtein = Storage.shared.maxProtein + @ObservedObject private var maxFat = Storage.shared.maxFat + + var body: some View { + HKQuantityInputView( + label: "Carbs", + quantity: $carbs, + unit: .gram(), + maxLength: 4, + minValue: HKQuantity(unit: .gram(), doubleValue: 0), + maxValue: maxCarbs.value, + isFocused: $carbsFocused, + onValidationError: onValidationError + ) + + if showFatProtein { + HKQuantityInputView( + label: "Fat", + quantity: $fat, + unit: .gram(), + maxLength: 4, + minValue: HKQuantity(unit: .gram(), doubleValue: 0), + maxValue: maxFat.value, + isFocused: $fatFocused, + onValidationError: onValidationError + ) + + HKQuantityInputView( + label: "Protein", + quantity: $protein, + unit: .gram(), + maxLength: 4, + minValue: HKQuantity(unit: .gram(), doubleValue: 0), + maxValue: maxProtein.value, + isFocused: $proteinFocused, + onValidationError: onValidationError + ) + } + } +} diff --git a/LoopFollow/Remote/TRC/MealView.swift b/LoopFollow/Remote/TRC/MealView.swift index 5db2ef14b..9d9ed0e7e 100644 --- a/LoopFollow/Remote/TRC/MealView.swift +++ b/LoopFollow/Remote/TRC/MealView.swift @@ -100,47 +100,17 @@ struct MealView: View { .transition(.opacity) } - HKQuantityInputView( - label: "Carbs", - quantity: $carbs, - unit: .gram(), - maxLength: 4, - minValue: HKQuantity(unit: .gram(), doubleValue: 0), - maxValue: maxCarbs.value, - isFocused: $carbsFieldIsFocused, - onValidationError: { message in - handleValidationError(message) - } + MealMacroInputs( + carbs: $carbs, + fat: $fat, + protein: $protein, + showFatProtein: mealWithFatProtein.value, + carbsFocused: $carbsFieldIsFocused, + fatFocused: $fatFieldIsFocused, + proteinFocused: $proteinFieldIsFocused, + onValidationError: handleValidationError ) - if mealWithFatProtein.value { - HKQuantityInputView( - label: "Fat", - quantity: $fat, - unit: .gram(), - maxLength: 4, - minValue: HKQuantity(unit: .gram(), doubleValue: 0), - maxValue: maxFat.value, - isFocused: $fatFieldIsFocused, - onValidationError: { message in - handleValidationError(message) - } - ) - - HKQuantityInputView( - label: "Protein", - quantity: $protein, - unit: .gram(), - maxLength: 4, - minValue: HKQuantity(unit: .gram(), doubleValue: 0), - maxValue: maxProtein.value, - isFocused: $proteinFieldIsFocused, - onValidationError: { message in - handleValidationError(message) - } - ) - } - if mealWithBolus.value { HKQuantityInputView( label: "Bolus Amount", diff --git a/LoopFollow/Remote/TRC/TRCMealEditView.swift b/LoopFollow/Remote/TRC/TRCMealEditView.swift new file mode 100644 index 000000000..121dab77d --- /dev/null +++ b/LoopFollow/Remote/TRC/TRCMealEditView.swift @@ -0,0 +1,201 @@ +// LoopFollow +// TRCMealEditView.swift + +import HealthKit +import SwiftUI + +/// Edit form for a meal already logged in Trio. Trio replaces the meal with these values. +struct TRCMealEditView: View { + let meal: TrioMealTreatment + + @Environment(\.presentationMode) private var presentationMode + @ObservedObject private var mealWithFatProtein = Storage.shared.mealWithFatProtein + + @State private var carbs: HKQuantity + @State private var fat: HKQuantity + @State private var protein: HKQuantity + @State private var mealDate: Date + @State private var isSending = false + @State private var alertType: AlertType? + + @FocusState private var carbsFocused: Bool + @FocusState private var fatFocused: Bool + @FocusState private var proteinFocused: Bool + + private enum AlertType: Identifiable { + case confirm + case validation(String) + case sendFailed(String) + + var id: String { + switch self { + case .confirm: return "confirm" + case let .validation(message): return "validation-\(message)" + case let .sendFailed(message): return "failed-\(message)" + } + } + } + + init(meal: TrioMealTreatment) { + self.meal = meal + _carbs = State(initialValue: HKQuantity(unit: .gram(), doubleValue: Double(meal.carbsForEdit))) + _fat = State(initialValue: HKQuantity(unit: .gram(), doubleValue: Double(meal.fat))) + _protein = State(initialValue: HKQuantity(unit: .gram(), doubleValue: Double(meal.protein))) + _mealDate = State(initialValue: Date(timeIntervalSince1970: meal.date)) + } + + private var showFatProtein: Bool { + mealWithFatProtein.value || meal.fat > 0 || meal.protein > 0 + } + + private var dateRange: ClosedRange { + let now = Date() + return now.addingTimeInterval(-TrioMealTreatment.pastEditWindow) ... now.addingTimeInterval(TrioMealTreatment.futureEditWindow) + } + + private var carbsValue: Int { Int(carbs.doubleValue(for: .gram()).rounded()) } + private var fatValue: Int { showFatProtein ? Int(fat.doubleValue(for: .gram()).rounded()) : 0 } + private var proteinValue: Int { showFatProtein ? Int(protein.doubleValue(for: .gram()).rounded()) : 0 } + + var body: some View { + NavigationView { + Form { + Section(header: Text("Current")) { + TRCMealMacroRows(carbs: meal.carbs, fat: meal.fat, protein: meal.protein, date: meal.date) + } + + Section(header: Text("New values"), footer: Text("Trio replaces the meal, including any fat and protein entries, with these values.")) { + MealMacroInputs( + carbs: $carbs, + fat: $fat, + protein: $protein, + showFatProtein: showFatProtein, + carbsFocused: $carbsFocused, + fatFocused: $fatFocused, + proteinFocused: $proteinFocused, + onValidationError: { alertType = .validation($0) } + ) + DatePicker("Meal time", selection: $mealDate, in: dateRange, displayedComponents: [.date, .hourAndMinute]) + .environment(\.timeZone, dateTimeUtils.displayTimeZone()) + } + } + .safeAreaInset(edge: .bottom) { + Button { + carbsFocused = false + fatFocused = false + proteinFocused = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + guard carbsValue > 0 || fatValue > 0 || proteinValue > 0 else { + alertType = .validation("Enter at least one of carbs, fat, or protein. Use Delete meal to remove the meal.") + return + } + alertType = .confirm + } + } label: { + if isSending { + HStack { + ProgressView().scaleEffect(0.8) + Text("Sending...") + } + .frame(maxWidth: .infinity) + } else { + Text("Update Meal").frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isSending) + .padding(.horizontal) + .padding(.vertical, 8) + .background(.bar) + } + .navigationTitle("Edit Meal") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { presentationMode.wrappedValue.dismiss() } + } + } + .alert(item: $alertType) { type in + switch type { + case .confirm: + return Alert( + title: Text("Update meal in Trio?"), + message: Text(confirmationMessage), + primaryButton: .default(Text("Update"), action: send), + secondaryButton: .cancel() + ) + case let .validation(message): + return Alert(title: Text("Validation Error"), message: Text(message), dismissButton: .default(Text("OK"))) + case let .sendFailed(message): + return Alert(title: Text("Not Sent"), message: Text(message), dismissButton: .default(Text("OK"))) + } + } + } + } + + private var confirmationMessage: String { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + dateTimeUtils.applyDisplayTimeZone(to: formatter) + var lines = ["Carbs: \(carbsValue) g"] + if showFatProtein { + lines.append("Fat: \(fatValue) g") + lines.append("Protein: \(proteinValue) g") + } + lines.append("Time: \(formatter.string(from: mealDate))") + return lines.joined(separator: "\n") + } + + private func send() { + isSending = true + TRCCommandTracker.shared.sendEdit( + mealID: meal.mealID.uuidString, + carbs: carbsValue, + fat: fatValue, + protein: proteinValue, + date: mealDate + ) { success, error in + DispatchQueue.main.async { + isSending = false + if success { + presentationMode.wrappedValue.dismiss() + } else { + alertType = .sendFailed(error ?? "Failed to send the meal update.") + } + } + } + } +} + +/// Read-only macro rows shared by the edit form and the treatment detail view. +struct TRCMealMacroRows: View { + let carbs: Double + let fat: Int + let protein: Int + let date: TimeInterval + + var body: some View { + row("Carbs", carbs == carbs.rounded() ? String(format: "%.0f g", carbs) : String(format: "%.1f g", carbs)) + if fat > 0 { row("Fat", "\(fat) g") } + if protein > 0 { row("Protein", "\(protein) g") } + row("Time", formattedTime) + } + + private var formattedTime: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + dateTimeUtils.applyDisplayTimeZone(to: formatter) + return formatter.string(from: Date(timeIntervalSince1970: date)) + } + + private func row(_ label: String, _ value: String) -> some View { + HStack { + Text(label) + Spacer() + Text(value).foregroundColor(.secondary) + } + } +} diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift index d811270eb..a692cb21f 100644 --- a/LoopFollow/Treatments/TreatmentsView.swift +++ b/LoopFollow/Treatments/TreatmentsView.swift @@ -398,6 +398,14 @@ struct TreatmentDetailView: View { let treatment: Treatment var rootMeal: Treatment? = nil @StateObject private var viewModel = TreatmentDetailViewModel() + @Environment(\.presentationMode) private var presentationMode + @ObservedObject private var commandTracker = TRCCommandTracker.shared + @ObservedObject private var remoteType = Storage.shared.remoteType + @ObservedObject private var device = Storage.shared.device + @ObservedObject private var remoteCommands = Storage.shared.remoteCommands + @State private var showEditSheet = false + @State private var showDeleteConfirmation = false + @State private var sendError: String? var body: some View { List { @@ -419,6 +427,13 @@ struct TreatmentDetailView: View { } } + if let meal = treatment.trioMeal { + trioMealSection(meal) + if TrioMealTreatment.remoteActionsAvailable(remoteType: remoteType.value, device: device.value, remoteCommands: remoteCommands.value) { + trioRemoteActionsSection(meal) + } + } + // Glucose at time if viewModel.isLoading { Section { @@ -562,6 +577,84 @@ struct TreatmentDetailView: View { .onAppear { viewModel.loadDetails(for: treatment) } + .sheet(isPresented: $showEditSheet) { + if let meal = treatment.trioMeal { + TRCMealEditView(meal: meal) + } + } + .confirmationDialog("Delete this meal in Trio?", isPresented: $showDeleteConfirmation, titleVisibility: .visible) { + Button("Delete meal", role: .destructive) { + guard let meal = treatment.trioMeal else { return } + TRCCommandTracker.shared.sendDelete(mealID: meal.mealID.uuidString) { success, error in + DispatchQueue.main.async { + sendError = success ? nil : (error ?? "Failed to send the delete command.") + } + } + } + } message: { + Text("This removes the carb entry and any fat/protein entries Trio created from it.") + } + .onReceive(commandTracker.$lastCompleted) { completed in + guard let meal = treatment.trioMeal, + let result = completed[meal.mealID.uuidString], + result.isSuccess + else { return } + presentationMode.wrappedValue.dismiss() + } + } + + @ViewBuilder + private func trioMealSection(_ meal: TrioMealTreatment) -> some View { + Section(header: Text("Meal"), footer: meal.isFPUChild ? Text("One of the small carb entries Trio created from this meal's fat and protein. Editing or deleting affects the whole meal.") : nil) { + TRCMealMacroRows(carbs: meal.carbs, fat: meal.fat, protein: meal.protein, date: meal.date) + if let note = meal.note { + HStack(alignment: .top) { + Text("Note") + Spacer() + Text(note) + .foregroundColor(.secondary) + .multilineTextAlignment(.trailing) + } + } + if meal.isFPUChild, let rootMeal { + NavigationLink("Show original meal", destination: TreatmentDetailView(treatment: rootMeal)) + } + } + } + + @ViewBuilder + private func trioRemoteActionsSection(_ meal: TrioMealTreatment) -> some View { + let mealID = meal.mealID.uuidString + let pending = commandTracker.pendingCommand(forMealID: mealID) + let lastResult = commandTracker.lastResult(forMealID: mealID) + let withinWindow = meal.isWithinEditWindow() + + Section(header: Text("Remote actions"), footer: remoteActionsFooter(withinWindow: withinWindow)) { + if pending != nil { + HStack { + ProgressView().scaleEffect(0.8) + Text("Sent, awaiting confirmation from Trio…") + .foregroundColor(.secondary) + } + } else if let lastResult, !lastResult.isSuccess { + Text(lastResult.displayMessage) + .font(.footnote) + .foregroundColor(.red) + } else if let sendError { + Text(sendError) + .font(.footnote) + .foregroundColor(.red) + } + + Button("Edit meal") { showEditSheet = true } + .disabled(!withinWindow || pending != nil) + Button("Delete meal", role: .destructive) { showDeleteConfirmation = true } + .disabled(!withinWindow || pending != nil) + } + } + + private func remoteActionsFooter(withinWindow: Bool) -> Text? { + withinWindow ? nil : Text("Meals can be changed remotely up to 24 hours after and 12 hours before their time.") } private func formatNavigationTitle(_ timeInterval: TimeInterval) -> String { From a8ddf2d9ba011476b3d8b337427d8455f8c848f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 21 Sep 2026 10:29:06 +0200 Subject: [PATCH 05/84] Add remote Edit and Delete for Loop carb entries in treatment details --- .../Remote/LoopAPNS/LoopAPNSService.swift | 74 ++++++ .../LoopAPNS/LoopCarbActionTracker.swift | 222 ++++++++++++++++++ .../Remote/LoopAPNS/LoopCarbEditSheet.swift | 203 ++++++++++++++++ LoopFollow/Treatments/LoopCarbTreatment.swift | 56 +++++ LoopFollow/Treatments/TreatmentsView.swift | 116 ++++++++- Tests/LoopCarbTreatmentTests.swift | 60 +++++ 6 files changed, 719 insertions(+), 12 deletions(-) create mode 100644 LoopFollow/Remote/LoopAPNS/LoopCarbActionTracker.swift create mode 100644 LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift create mode 100644 LoopFollow/Treatments/LoopCarbTreatment.swift create mode 100644 Tests/LoopCarbTreatmentTests.swift diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index 38dc4fb23..d7f66377c 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -122,6 +122,80 @@ class LoopAPNSService { return hasFullSetup } + /// Fields every Loop remote command carries, plus the return-notification block encrypted with the OTP. + private func remoteCommandPayload(otp: String, alert: String) -> [String: Any] { + let now = Date() + var payload: [String: Any] = [ + "otp": otp, + "remote-address": "LoopFollow", + "notes": "Sent via LoopFollow APNS", + "entered-by": "LoopFollow", + "sent-at": formatDateForAPNS(now), + "expiration": formatDateForAPNS(now.addingTimeInterval(5 * 60)), + "alert": alert, + ] + if let returnInfo = createReturnNotificationInfo(), + let encryptedReturnInfo = encryptReturnNotificationInfo(returnInfo: returnInfo, otpCode: otp) + { + payload["encrypted_return_notification"] = encryptedReturnInfo + } + return payload + } + + private func sendRemoteCommand(_ payload: [String: Any], completion: @escaping (Bool, String?) -> Void) { + guard validateSetup() else { + let errorMessage = "Loop APNS Configuration not valid" + LogManager.shared.log(category: .apns, message: errorMessage) + completion(false, errorMessage) + return + } + let creds = effectiveCredentials() + sendAPNSNotification( + deviceToken: Storage.shared.deviceToken.value, + bundleIdentifier: Storage.shared.bundleId.value, + keyId: creds.keyId, + apnsKey: creds.apnsKey, + teamId: creds.teamId, + payload: payload, + completion: completion + ) + } + + /// Deletes the Loop carb entry with this `syncIdentifier`. Requires the Loop remote carb edit patch. + func sendCarbsDelete(syncIdentifier: String, otp: String, completion: @escaping (Bool, String?) -> Void) { + var payload = remoteCommandPayload(otp: otp, alert: "Remote Carbs Delete") + payload["carbs-delete"] = syncIdentifier + LogManager.shared.log(category: .apns, message: "Sending carbs delete for syncIdentifier=\(LogRedactor.tail(syncIdentifier))") + sendRemoteCommand(payload, completion: completion) + } + + /// Replaces the Loop carb entry with this `syncIdentifier`. Keys are prefixed so an unpatched Loop rejects the command. + func sendCarbsEdit( + syncIdentifier: String, + carbsAmount: Double, + absorptionTimeHours: Double, + foodType: String?, + consumedDate: Date?, + otp: String, + completion: @escaping (Bool, String?) -> Void + ) { + var payload = remoteCommandPayload( + otp: otp, + alert: "Remote Carbs Edit: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTimeHours)) hours" + ) + payload["carbs-edit"] = syncIdentifier + payload["carbs-edit-entry"] = carbsAmount + payload["carbs-edit-absorption-time"] = absorptionTimeHours + if let foodType, !foodType.isEmpty { + payload["carbs-edit-food-type"] = foodType + } + if let consumedDate { + payload["carbs-edit-start-time"] = formatDateForAPNS(consumedDate) + } + LogManager.shared.log(category: .apns, message: "Sending carbs edit for syncIdentifier=\(LogRedactor.tail(syncIdentifier)): \(String(format: "%.1f", carbsAmount))g, absorption \(String(format: "%.1f", absorptionTimeHours))h") + sendRemoteCommand(payload, completion: completion) + } + /// Sends carbs via APNS push notification /// - Parameters: /// - payload: The carbs payload to send diff --git a/LoopFollow/Remote/LoopAPNS/LoopCarbActionTracker.swift b/LoopFollow/Remote/LoopAPNS/LoopCarbActionTracker.swift new file mode 100644 index 000000000..66c681bbe --- /dev/null +++ b/LoopFollow/Remote/LoopAPNS/LoopCarbActionTracker.swift @@ -0,0 +1,222 @@ +// LoopFollow +// LoopCarbActionTracker.swift + +import Foundation + +/// Sends carb delete/edit commands to Loop and confirms them by watching Nightscout. +/// +/// Loop sends no acknowledgement push, so the treatment is polled by `syncIdentifier`: +/// a delete is confirmed when it disappears, an edit when the new values are published. +/// Loop reports failures as Nightscout Notes whose `enteredBy` names the action. +final class LoopCarbActionTracker: ObservableObject { + static let shared = LoopCarbActionTracker() + static let pollInterval: TimeInterval = 10 + static let timeout: TimeInterval = 120 + + enum State: Equatable { + case idle + case sending + case awaitingConfirmation(since: Date) + case confirmed + case failed(String) + case timedOut + + var isTerminal: Bool { + switch self { + case .confirmed, .failed, .timedOut: return true + default: return false + } + } + + var message: String? { + switch self { + case .idle: return nil + case .sending: return "Sending…" + case .awaitingConfirmation: return "Sent, waiting for Nightscout to reflect the change…" + case .confirmed: return "Confirmed." + case let .failed(message): return message + case .timedOut: return "Loop may have applied the change but Nightscout was not updated. Refresh the list to check." + } + } + } + + private enum Expectation { + case deleted + case edited(carbs: Double, absorptionMinutes: Double) + } + + private struct Operation { + let syncIdentifier: String + let actionName: String + let sentAt: Date + let expectation: Expectation + } + + @Published private(set) var states: [String: State] = [:] + + private var operations: [String: Operation] = [:] + private var timers: [String: Timer] = [:] + + func state(forSyncIdentifier syncIdentifier: String) -> State { + states[syncIdentifier] ?? .idle + } + + func isBusy(syncIdentifier: String) -> Bool { + switch state(forSyncIdentifier: syncIdentifier) { + case .sending, .awaitingConfirmation: return true + default: return false + } + } + + func sendDelete(carb: LoopCarbTreatment, completion: @escaping (Bool, String?) -> Void) { + guard let otp = currentOTP(completion: completion) else { return } + setState(.sending, for: carb.syncIdentifier) + LoopAPNSService().sendCarbsDelete(syncIdentifier: carb.syncIdentifier, otp: otp) { [weak self] success, error in + self?.handleSendResult( + Operation(syncIdentifier: carb.syncIdentifier, actionName: "Delete Carbs", sentAt: Date(), expectation: .deleted), + success: success, + error: error, + completion: completion + ) + } + } + + func sendEdit(carb: LoopCarbTreatment, carbsAmount: Double, absorptionHours: Double, foodType: String?, consumedDate: Date?, completion: @escaping (Bool, String?) -> Void) { + guard let otp = currentOTP(completion: completion) else { return } + setState(.sending, for: carb.syncIdentifier) + LoopAPNSService().sendCarbsEdit( + syncIdentifier: carb.syncIdentifier, + carbsAmount: carbsAmount, + absorptionTimeHours: absorptionHours, + foodType: foodType, + consumedDate: consumedDate, + otp: otp + ) { [weak self] success, error in + self?.handleSendResult( + Operation( + syncIdentifier: carb.syncIdentifier, + actionName: "Edit Carbs", + sentAt: Date(), + expectation: .edited(carbs: carbsAmount, absorptionMinutes: absorptionHours * 60) + ), + success: success, + error: error, + completion: completion + ) + } + } + + // MARK: - Internal + + private func currentOTP(completion: (Bool, String?) -> Void) -> String? { + let qrCodeURL = Storage.shared.loopAPNSQrCodeURL.value + guard let otp = TOTPGenerator.extractOTPFromURL(qrCodeURL) else { + completion(false, "Invalid QR code URL. Please re-scan the QR code in settings.") + return nil + } + guard !TOTPService.shared.isTOTPBlocked(qrCodeURL: qrCodeURL) else { + completion(false, "The current one-time code was already used. Wait for the next code and try again.") + return nil + } + return otp + } + + private func handleSendResult(_ operation: Operation, success: Bool, error: String?, completion: @escaping (Bool, String?) -> Void) { + DispatchQueue.main.async { + guard success else { + self.finish(operation.syncIdentifier, state: .failed(error ?? "The command could not be sent.")) + completion(false, error) + return + } + TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) + LogManager.shared.log(category: .apns, message: "\(operation.actionName) sent for syncIdentifier=\(LogRedactor.tail(operation.syncIdentifier))") + self.operations[operation.syncIdentifier] = operation + self.setState(.awaitingConfirmation(since: operation.sentAt), for: operation.syncIdentifier) + let timer = Timer.scheduledTimer(withTimeInterval: Self.pollInterval, repeats: true) { [weak self] _ in + self?.poll(operation.syncIdentifier) + } + self.timers[operation.syncIdentifier] = timer + completion(true, nil) + } + } + + private func poll(_ syncIdentifier: String) { + guard let operation = operations[syncIdentifier] else { return } + if Date().timeIntervalSince(operation.sentAt) > Self.timeout { + finish(syncIdentifier, state: .timedOut) + return + } + + fetchTreatments(parameters: ["find[syncIdentifier]": syncIdentifier, "count": "1"]) { [weak self] entries in + guard let self, let entries else { return } + let confirmed: Bool + switch operation.expectation { + case .deleted: + confirmed = entries.isEmpty + case let .edited(carbs, absorptionMinutes): + confirmed = entries.contains { entry in + let entryCarbs = (entry["carbs"] as? NSNumber)?.doubleValue ?? -1 + let entryAbsorption = (entry["absorptionTime"] as? NSNumber)?.doubleValue ?? -1 + return abs(entryCarbs - carbs.rounded()) < 0.5 && abs(entryAbsorption - absorptionMinutes) < 1 + } + } + if confirmed { + DispatchQueue.main.async { self.finish(syncIdentifier, state: .confirmed) } + return + } + self.checkFailureNote(for: operation) + } + } + + private func checkFailureNote(for operation: Operation) { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let since = formatter.string(from: operation.sentAt.addingTimeInterval(-60)) + fetchTreatments(parameters: ["find[eventType]": "Note", "find[created_at][$gte]": since, "count": "10"]) { [weak self] entries in + guard let self, let entries else { return } + let failure = entries.first { entry in + (entry["enteredBy"] as? String)?.hasPrefix(operation.actionName) == true + } + guard let failure else { return } + let notes = (failure["notes"] as? String) ?? "" + let firstLine = notes.split(separator: "\n").first.map(String.init) ?? "Loop rejected the command." + DispatchQueue.main.async { self.finish(operation.syncIdentifier, state: .failed(firstLine)) } + } + } + + private func fetchTreatments(parameters: [String: String], completion: @escaping ([[String: AnyObject]]?) -> Void) { + guard let url = NightscoutUtils.constructURL( + baseURL: Storage.shared.url.value, + token: Storage.shared.token.value, + endpoint: "/api/v1/treatments.json", + parameters: parameters + ) else { + completion(nil) + return + } + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalCacheData + URLSession.shared.dataTask(with: request) { data, _, error in + guard error == nil, let data, + let entries = try? JSONSerialization.jsonObject(with: data) as? [[String: AnyObject]] + else { + completion(nil) + return + } + completion(entries) + }.resume() + } + + private func setState(_ state: State, for syncIdentifier: String) { + DispatchQueue.main.async { self.states[syncIdentifier] = state } + } + + private func finish(_ syncIdentifier: String, state: State) { + timers[syncIdentifier]?.invalidate() + timers[syncIdentifier] = nil + operations[syncIdentifier] = nil + states[syncIdentifier] = state + LogManager.shared.log(category: .apns, message: "Loop carb command for syncIdentifier=\(LogRedactor.tail(syncIdentifier)) finished: \(state)") + NotificationCenter.default.post(name: .remoteMealCommandDidComplete, object: nil, userInfo: ["syncIdentifier": syncIdentifier]) + } +} diff --git a/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift b/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift new file mode 100644 index 000000000..cad24bf57 --- /dev/null +++ b/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift @@ -0,0 +1,203 @@ +// LoopFollow +// LoopCarbEditSheet.swift + +import HealthKit +import SwiftUI + +/// Edit form for a carb entry already logged in Loop. Loop replaces the entry, keeping its sync identifier. +struct LoopCarbEditSheet: View { + let carb: LoopCarbTreatment + + @Environment(\.presentationMode) private var presentationMode + @State private var carbsAmount: HKQuantity + @State private var absorptionHours: Int + @State private var absorptionMinutes: Int + @State private var foodType: String + @State private var consumedDate: Date + @State private var isSending = false + @State private var alertType: AlertType? + @FocusState private var carbsFocused: Bool + + private let maxPastHours = 12 + private let maxFutureHours = 1 + private let minAbsorptionHours = 0.5 + private let maxAbsorptionHours = 8 + + private enum AlertType: Identifiable { + case confirm + case validation(String) + case sendFailed(String) + + var id: String { + switch self { + case .confirm: return "confirm" + case let .validation(message): return "validation-\(message)" + case let .sendFailed(message): return "failed-\(message)" + } + } + } + + init(carb: LoopCarbTreatment) { + self.carb = carb + _carbsAmount = State(initialValue: HKQuantity(unit: .gram(), doubleValue: carb.carbs)) + let totalMinutes = Int((carb.absorptionMinutes ?? 180).rounded()) + _absorptionHours = State(initialValue: totalMinutes / 60) + _absorptionMinutes = State(initialValue: totalMinutes % 60 >= 30 ? 30 : 0) + _foodType = State(initialValue: carb.foodType ?? "") + _consumedDate = State(initialValue: Date(timeIntervalSince1970: carb.date)) + } + + private var absorptionTimeValue: Double { + Double(absorptionHours) + Double(absorptionMinutes) / 60 + } + + private var dateRange: ClosedRange { + let now = Date() + let lower = min(now.addingTimeInterval(-TimeInterval(maxPastHours) * 3600), consumedDate) + return lower ... now.addingTimeInterval(TimeInterval(maxFutureHours) * 3600) + } + + var body: some View { + NavigationView { + Form { + Section(header: Text("Current")) { + row("Carbs", String(format: "%.0f g", carb.carbs)) + if let hours = carb.absorptionHours { + row("Absorption", String(format: "%.1f h", hours)) + } + if let foodType = carb.foodType { + row("Food type", foodType) + } + } + + Section(header: Text("New values"), footer: Text("Loop replaces the entry with these values and keeps its history.")) { + HKQuantityInputView( + label: "Carbs", + quantity: $carbsAmount, + unit: .gram(), + maxLength: 4, + minValue: HKQuantity(unit: .gram(), doubleValue: 1), + maxValue: Storage.shared.maxCarbs.value, + isFocused: $carbsFocused, + onValidationError: { alertType = .validation($0) } + ) + HStack { + Text("Absorption") + Spacer() + Picker("Hours", selection: $absorptionHours) { + ForEach(0 ... maxAbsorptionHours, id: \.self) { Text("\($0) hr").tag($0) } + } + .labelsHidden() + Picker("Minutes", selection: $absorptionMinutes) { + ForEach([0, 30], id: \.self) { Text("\($0) min").tag($0) } + } + .labelsHidden() + } + TextField("Food type (optional)", text: $foodType) + DatePicker("Time", selection: $consumedDate, in: dateRange, displayedComponents: [.date, .hourAndMinute]) + .environment(\.timeZone, dateTimeUtils.displayTimeZone()) + } + } + .safeAreaInset(edge: .bottom) { + Button { + carbsFocused = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { validateAndConfirm() } + } label: { + if isSending { + HStack { + ProgressView().scaleEffect(0.8) + Text("Sending...") + } + .frame(maxWidth: .infinity) + } else { + Text("Update Carbs").frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isSending) + .padding(.horizontal) + .padding(.vertical, 8) + .background(.bar) + } + .navigationTitle("Edit Carbs") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { presentationMode.wrappedValue.dismiss() } + } + } + .alert(item: $alertType) { type in + switch type { + case .confirm: + return Alert( + title: Text("Update carbs in Loop?"), + message: Text(confirmationMessage), + primaryButton: .default(Text("Update"), action: send), + secondaryButton: .cancel() + ) + case let .validation(message): + return Alert(title: Text("Validation Error"), message: Text(message), dismissButton: .default(Text("OK"))) + case let .sendFailed(message): + return Alert(title: Text("Not Sent"), message: Text(message), dismissButton: .default(Text("OK"))) + } + } + } + } + + private var confirmationMessage: String { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + dateTimeUtils.applyDisplayTimeZone(to: formatter) + var lines = [ + String(format: "Carbs: %.0f g", carbsAmount.doubleValue(for: .gram())), + String(format: "Absorption: %.1f h", absorptionTimeValue), + ] + if !foodType.isEmpty { lines.append("Food type: \(foodType)") } + lines.append("Time: \(formatter.string(from: consumedDate))") + return lines.joined(separator: "\n") + } + + private func validateAndConfirm() { + guard carbsAmount.doubleValue(for: .gram()) > 0 else { + alertType = .validation("Enter a carb amount, or use Delete carbs to remove the entry.") + return + } + guard absorptionTimeValue >= minAbsorptionHours, absorptionTimeValue <= Double(maxAbsorptionHours) else { + alertType = .validation(String(format: "Please enter a valid absorption time between %.1f and %d hours", minAbsorptionHours, maxAbsorptionHours)) + return + } + alertType = .confirm + } + + private func send() { + isSending = true + let originalDate = Date(timeIntervalSince1970: carb.date) + let dateChanged = abs(consumedDate.timeIntervalSince(originalDate)) >= 60 + LoopCarbActionTracker.shared.sendEdit( + carb: carb, + carbsAmount: carbsAmount.doubleValue(for: .gram()), + absorptionHours: absorptionTimeValue, + foodType: foodType.isEmpty ? nil : foodType, + consumedDate: dateChanged ? consumedDate : nil + ) { success, error in + DispatchQueue.main.async { + isSending = false + if success { + presentationMode.wrappedValue.dismiss() + } else { + alertType = .sendFailed(error ?? "Failed to send the carb update.") + } + } + } + } + + private func row(_ label: String, _ value: String) -> some View { + HStack { + Text(label) + Spacer() + Text(value).foregroundColor(.secondary) + } + } +} diff --git a/LoopFollow/Treatments/LoopCarbTreatment.swift b/LoopFollow/Treatments/LoopCarbTreatment.swift new file mode 100644 index 000000000..deed2239c --- /dev/null +++ b/LoopFollow/Treatments/LoopCarbTreatment.swift @@ -0,0 +1,56 @@ +// LoopFollow +// LoopCarbTreatment.swift + +import Foundation + +/// Loop carb entry metadata from a Nightscout "Carb Correction" treatment. +/// +/// `syncIdentifier` is Loop's stable handle for the entry; it survives edits on the phone, +/// so it addresses the entry in remote delete/edit commands. +struct LoopCarbTreatment: Equatable { + /// Loop's Nightscout object-id cache expires after 24 h, so stop one hour short of it. + static let editWindow: TimeInterval = 23 * 3600 + + let nightscoutID: String + let syncIdentifier: String + let enteredBy: String + let date: TimeInterval + let carbs: Double + let absorptionMinutes: Double? + let foodType: String? + + init?(nightscoutEntry entry: [String: AnyObject], date: TimeInterval) { + guard let enteredBy = entry["enteredBy"] as? String, + enteredBy.hasPrefix("loop://"), + entry["eventType"] as? String == "Carb Correction", + let syncIdentifier = entry["syncIdentifier"] as? String, + !syncIdentifier.isEmpty, + let carbs = (entry["carbs"] as? NSNumber)?.doubleValue, + carbs > 0 + else { + return nil + } + + nightscoutID = entry["_id"] as? String ?? "" + self.syncIdentifier = syncIdentifier + self.enteredBy = enteredBy + self.date = date + self.carbs = carbs + absorptionMinutes = (entry["absorptionTime"] as? NSNumber)?.doubleValue + let rawFoodType = (entry["foodType"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + foodType = rawFoodType?.isEmpty == false ? rawFoodType : nil + } + + var absorptionHours: Double? { + absorptionMinutes.map { $0 / 60 } + } + + func isWithinEditWindow(now: Date = Date()) -> Bool { + let age = now.timeIntervalSince1970 - date + return age <= Self.editWindow && age >= -3600 + } + + static func remoteActionsAvailable(remoteType: RemoteType, device: String) -> Bool { + remoteType == .loopAPNS && device == "Loop" + } +} diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift index a692cb21f..624a3ef17 100644 --- a/LoopFollow/Treatments/TreatmentsView.swift +++ b/LoopFollow/Treatments/TreatmentsView.swift @@ -400,6 +400,7 @@ struct TreatmentDetailView: View { @StateObject private var viewModel = TreatmentDetailViewModel() @Environment(\.presentationMode) private var presentationMode @ObservedObject private var commandTracker = TRCCommandTracker.shared + @ObservedObject private var loopCarbTracker = LoopCarbActionTracker.shared @ObservedObject private var remoteType = Storage.shared.remoteType @ObservedObject private var device = Storage.shared.device @ObservedObject private var remoteCommands = Storage.shared.remoteCommands @@ -434,6 +435,13 @@ struct TreatmentDetailView: View { } } + if let carb = treatment.loopCarb { + loopCarbSection(carb) + if LoopCarbTreatment.remoteActionsAvailable(remoteType: remoteType.value, device: device.value) { + loopRemoteActionsSection(carb) + } + } + // Glucose at time if viewModel.isLoading { Section { @@ -580,19 +588,14 @@ struct TreatmentDetailView: View { .sheet(isPresented: $showEditSheet) { if let meal = treatment.trioMeal { TRCMealEditView(meal: meal) + } else if let carb = treatment.loopCarb { + LoopCarbEditSheet(carb: carb) } } - .confirmationDialog("Delete this meal in Trio?", isPresented: $showDeleteConfirmation, titleVisibility: .visible) { - Button("Delete meal", role: .destructive) { - guard let meal = treatment.trioMeal else { return } - TRCCommandTracker.shared.sendDelete(mealID: meal.mealID.uuidString) { success, error in - DispatchQueue.main.async { - sendError = success ? nil : (error ?? "Failed to send the delete command.") - } - } - } + .confirmationDialog(deleteDialogTitle, isPresented: $showDeleteConfirmation, titleVisibility: .visible) { + Button(treatment.trioMeal != nil ? "Delete meal" : "Delete carbs", role: .destructive, action: sendDelete) } message: { - Text("This removes the carb entry and any fat/protein entries Trio created from it.") + Text(deleteDialogMessage) } .onReceive(commandTracker.$lastCompleted) { completed in guard let meal = treatment.trioMeal, @@ -601,6 +604,91 @@ struct TreatmentDetailView: View { else { return } presentationMode.wrappedValue.dismiss() } + .onReceive(loopCarbTracker.$states) { states in + guard let carb = treatment.loopCarb, states[carb.syncIdentifier] == .confirmed else { return } + presentationMode.wrappedValue.dismiss() + } + } + + private var deleteDialogTitle: String { + treatment.trioMeal != nil ? "Delete this meal in Trio?" : "Delete this carb entry in Loop?" + } + + private var deleteDialogMessage: String { + treatment.trioMeal != nil + ? "This removes the carb entry and any fat/protein entries Trio created from it." + : "This removes the carb entry from Loop. Loop recalculates carbs on board right away." + } + + private func sendDelete() { + let completion: (Bool, String?) -> Void = { success, error in + DispatchQueue.main.async { + sendError = success ? nil : (error ?? "Failed to send the delete command.") + } + } + if let meal = treatment.trioMeal { + TRCCommandTracker.shared.sendDelete(mealID: meal.mealID.uuidString, completion: completion) + } else if let carb = treatment.loopCarb { + LoopCarbActionTracker.shared.sendDelete(carb: carb, completion: completion) + } + } + + @ViewBuilder + private func loopCarbSection(_ carb: LoopCarbTreatment) -> some View { + Section(header: Text("Carb entry")) { + HStack { + Text("Carbs") + Spacer() + Text(String(format: "%.0f g", carb.carbs)).foregroundColor(.secondary) + } + if let hours = carb.absorptionHours { + HStack { + Text("Absorption") + Spacer() + Text(String(format: "%.1f h", hours)).foregroundColor(.secondary) + } + } + if let foodType = carb.foodType { + HStack { + Text("Food type") + Spacer() + Text(foodType).foregroundColor(.secondary) + } + } + } + } + + @ViewBuilder + private func loopRemoteActionsSection(_ carb: LoopCarbTreatment) -> some View { + let state = loopCarbTracker.state(forSyncIdentifier: carb.syncIdentifier) + let busy = loopCarbTracker.isBusy(syncIdentifier: carb.syncIdentifier) + let withinWindow = carb.isWithinEditWindow() + + Section(header: Text("Remote actions"), footer: loopRemoteActionsFooter(withinWindow: withinWindow)) { + if busy, let message = state.message { + HStack { + ProgressView().scaleEffect(0.8) + Text(message).foregroundColor(.secondary) + } + } else if state.isTerminal, state != .confirmed, let message = state.message { + Text(message) + .font(.footnote) + .foregroundColor(.red) + } else if let sendError { + Text(sendError) + .font(.footnote) + .foregroundColor(.red) + } + + Button("Edit carbs") { showEditSheet = true } + .disabled(!withinWindow || busy) + Button("Delete carbs", role: .destructive) { showDeleteConfirmation = true } + .disabled(!withinWindow || busy) + } + } + + private func loopRemoteActionsFooter(withinWindow: Bool) -> Text? { + withinWindow ? Text("Requires a Loop build with remote carb editing.") : Text("Carb entries can be changed remotely for 23 hours.") } @ViewBuilder @@ -1046,8 +1134,9 @@ struct Treatment: Identifiable { let color: Color let bgValue: Int let trioMeal: TrioMealTreatment? + let loopCarb: LoopCarbTreatment? - init(id: String? = nil, type: TreatmentType, date: TimeInterval, title: String, subtitle: String?, icon: String, color: Color, bgValue: Int, trioMeal: TrioMealTreatment? = nil) { + init(id: String? = nil, type: TreatmentType, date: TimeInterval, title: String, subtitle: String?, icon: String, color: Color, bgValue: Int, trioMeal: TrioMealTreatment? = nil, loopCarb: LoopCarbTreatment? = nil) { self.id = id ?? "\(type)-\(date)-\(title)" self.type = type self.date = date @@ -1057,6 +1146,7 @@ struct Treatment: Identifiable { self.color = color self.bgValue = bgValue self.trioMeal = trioMeal + self.loopCarb = loopCarb } var hourKey: String { @@ -1285,6 +1375,7 @@ class TreatmentsViewModel: ObservableObject { let trioMeal = eventType == "Carb Correction" ? TrioMealTreatment(nightscoutEntry: entry, date: timestamp, siblingIDCount: siblingIDCounts[entry["id"] as? String ?? ""] ?? 0) : nil + let loopCarb = trioMeal == nil ? LoopCarbTreatment(nightscoutEntry: entry, date: timestamp) : nil let carbs = entry["carbs"] as? Double ?? 0 if carbs > 0 || trioMeal != nil { let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) @@ -1298,7 +1389,8 @@ class TreatmentsViewModel: ObservableObject { icon: "circle.fill", color: .orange, bgValue: actualBG, - trioMeal: trioMeal + trioMeal: trioMeal, + loopCarb: loopCarb ) treatments.append(treatment) } diff --git a/Tests/LoopCarbTreatmentTests.swift b/Tests/LoopCarbTreatmentTests.swift new file mode 100644 index 000000000..e6c2bcb47 --- /dev/null +++ b/Tests/LoopCarbTreatmentTests.swift @@ -0,0 +1,60 @@ +// LoopFollow +// LoopCarbTreatmentTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct LoopCarbTreatmentTests { + private typealias Entry = [String: AnyObject] + + private func entry(enteredBy: String = "loop://phone", eventType: String = "Carb Correction", syncIdentifier: String? = "SYNC-1", carbs: Double = 30, absorption: Double? = 180, foodType: String? = "πŸ•") -> Entry { + var result: Entry = [ + "_id": "mongo" as AnyObject, + "enteredBy": enteredBy as AnyObject, + "eventType": eventType as AnyObject, + "carbs": carbs as AnyObject, + ] + if let syncIdentifier { result["syncIdentifier"] = syncIdentifier as AnyObject } + if let absorption { result["absorptionTime"] = absorption as AnyObject } + if let foodType { result["foodType"] = foodType as AnyObject } + return result + } + + @Test("parses a Loop carb entry") + func parses() { + let carb = LoopCarbTreatment(nightscoutEntry: entry(), date: 100) + #expect(carb?.syncIdentifier == "SYNC-1") + #expect(carb?.carbs == 30) + #expect(carb?.absorptionHours == 3) + #expect(carb?.foodType == "πŸ•") + #expect(carb?.nightscoutID == "mongo") + } + + @Test("rejects entries Loop cannot address") + func rejects() { + #expect(LoopCarbTreatment(nightscoutEntry: entry(enteredBy: "Trio"), date: 0) == nil) + #expect(LoopCarbTreatment(nightscoutEntry: entry(syncIdentifier: nil), date: 0) == nil) + #expect(LoopCarbTreatment(nightscoutEntry: entry(eventType: "Meal Bolus"), date: 0) == nil) + #expect(LoopCarbTreatment(nightscoutEntry: entry(carbs: 0), date: 0) == nil) + } + + @Test("edit window is 23 h back and 1 h ahead") + func window() { + let now = Date(timeIntervalSince1970: 1_700_000_000) + func carb(offsetHours: Double) -> LoopCarbTreatment? { + LoopCarbTreatment(nightscoutEntry: entry(), date: now.timeIntervalSince1970 + offsetHours * 3600) + } + #expect(carb(offsetHours: -22)?.isWithinEditWindow(now: now) == true) + #expect(carb(offsetHours: -24)?.isWithinEditWindow(now: now) == false) + #expect(carb(offsetHours: 0.5)?.isWithinEditWindow(now: now) == true) + #expect(carb(offsetHours: 2)?.isWithinEditWindow(now: now) == false) + } + + @Test("actions need Loop APNS and a Loop device") + func gating() { + #expect(LoopCarbTreatment.remoteActionsAvailable(remoteType: .loopAPNS, device: "Loop") == true) + #expect(LoopCarbTreatment.remoteActionsAvailable(remoteType: .loopAPNS, device: "Trio") == false) + #expect(LoopCarbTreatment.remoteActionsAvailable(remoteType: .trc, device: "Loop") == false) + } +} From d0ec220959c54d776f363959c8d66a3e468212bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 21 Sep 2026 10:40:15 +0200 Subject: [PATCH 06/84] Confirm Loop carb commands from Loop's return push --- LoopFollow/Application/AppDelegate.swift | 3 ++ .../LoopAPNS/LoopCarbActionTracker.swift | 30 ++++++++++++++++--- Tests/LoopCarbTreatmentTests.swift | 11 +++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 64099f6f1..d9e1b21b8 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -135,6 +135,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let userInfoKeys = userInfo.keys.compactMap { $0 as? String }.sorted() LogManager.shared.log(category: .apns, message: "Received remote notification: keys=\(userInfoKeys)") TRCCommandTracker.shared.handleNotification(userInfo: userInfo) + LoopCarbActionTracker.shared.handleNotification(userInfo: userInfo) // Check if this is a response notification from Loop or Trio if let aps = userInfo["aps"] as? [String: Any] { @@ -185,6 +186,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { TRCCommandTracker.shared.handleNotification(userInfo: response.notification.request.content.userInfo) + LoopCarbActionTracker.shared.handleNotification(userInfo: response.notification.request.content.userInfo) if response.actionIdentifier == "OPEN_APP_ACTION" { // Dismiss any presented modal/sheet so the user actually sees Home @@ -271,6 +273,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { { let content = notification.request.content TRCCommandTracker.shared.handleNotification(userInfo: content.userInfo) + LoopCarbActionTracker.shared.handleNotification(userInfo: content.userInfo) let userInfoKeys = content.userInfo.keys.compactMap { $0 as? String }.sorted() LogManager.shared.log( category: .general, diff --git a/LoopFollow/Remote/LoopAPNS/LoopCarbActionTracker.swift b/LoopFollow/Remote/LoopAPNS/LoopCarbActionTracker.swift index 66c681bbe..79117d645 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopCarbActionTracker.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopCarbActionTracker.swift @@ -3,12 +3,14 @@ import Foundation -/// Sends carb delete/edit commands to Loop and confirms them by watching Nightscout. +/// Sends carb delete/edit commands to Loop and tracks their outcome. /// -/// Loop sends no acknowledgement push, so the treatment is polled by `syncIdentifier`: -/// a delete is confirmed when it disappears, an edit when the new values are published. -/// Loop reports failures as Nightscout Notes whose `enteredBy` names the action. +/// Loop's return push (`command_type` `carbs_delete`/`carbs_edit`, `sync_identifier`, +/// `command_status`) is the primary confirmation. Nightscout is polled by `syncIdentifier` +/// as a fallback: a delete is confirmed when the entry disappears, an edit when the new +/// values are published, and a failure Note from Loop reports the error. final class LoopCarbActionTracker: ObservableObject { + static let ackCommandTypes: Set = ["carbs_delete", "carbs_edit"] static let shared = LoopCarbActionTracker() static let pollInterval: TimeInterval = 10 static let timeout: TimeInterval = 120 @@ -106,6 +108,26 @@ final class LoopCarbActionTracker: ObservableObject { } } + /// Returns true when the notification is a Loop carb command ack (matched or not). + @discardableResult + func handleNotification(userInfo: [AnyHashable: Any]) -> Bool { + guard let commandType = userInfo["command_type"] as? String, + Self.ackCommandTypes.contains(commandType), + let status = userInfo["command_status"] as? String + else { return false } + let syncIdentifier = userInfo["sync_identifier"] as? String + let alert = (userInfo["aps"] as? [String: Any])?["alert"] as? [String: Any] + let message = alert?["body"] as? String + LogManager.shared.log( + category: .apns, + message: "Loop ack: type=\(commandType) status=\(status) sync_identifier=\(syncIdentifier.map { LogRedactor.tail($0) } ?? "-")" + ) + guard let syncIdentifier, operations[syncIdentifier] != nil else { return true } + let state: State = status == "success" ? .confirmed : .failed(message ?? "Loop reported a failure.") + DispatchQueue.main.async { self.finish(syncIdentifier, state: state) } + return true + } + // MARK: - Internal private func currentOTP(completion: (Bool, String?) -> Void) -> String? { diff --git a/Tests/LoopCarbTreatmentTests.swift b/Tests/LoopCarbTreatmentTests.swift index e6c2bcb47..25c9a2a20 100644 --- a/Tests/LoopCarbTreatmentTests.swift +++ b/Tests/LoopCarbTreatmentTests.swift @@ -58,3 +58,14 @@ struct LoopCarbTreatmentTests { #expect(LoopCarbTreatment.remoteActionsAvailable(remoteType: .trc, device: "Loop") == false) } } + +struct LoopCarbAckTests { + @Test("only Loop carb command acks are consumed") + func routing() { + let tracker = LoopCarbActionTracker.shared + #expect(tracker.handleNotification(userInfo: ["command_type": "carbs_delete", "command_status": "success", "sync_identifier": "unknown"]) == true) + #expect(tracker.handleNotification(userInfo: ["command_type": "delete_meal", "command_status": "success"]) == false) + #expect(tracker.handleNotification(userInfo: ["command_type": "carbs_edit"]) == false) + #expect(tracker.state(forSyncIdentifier: "unknown") == .idle) + } +} From 86acfbef170fc8e342132641cc3cecad6e119f61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 21 Sep 2026 10:46:54 +0200 Subject: [PATCH 07/84] Skip list refresh when a meal command was never sent --- LoopFollow/Remote/TRC/TRCCommandTracker.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/LoopFollow/Remote/TRC/TRCCommandTracker.swift b/LoopFollow/Remote/TRC/TRCCommandTracker.swift index eda64df13..3bea87415 100644 --- a/LoopFollow/Remote/TRC/TRCCommandTracker.swift +++ b/LoopFollow/Remote/TRC/TRCCommandTracker.swift @@ -178,7 +178,9 @@ final class TRCCommandTracker: ObservableObject { guard self.pending[command.id] != nil else { return } self.pending[command.id] = nil self.lastCompleted[command.mealID] = CompletedCommand(command: command, outcome: outcome, message: message, completedAt: Date()) - NotificationCenter.default.post(name: .remoteMealCommandDidComplete, object: nil, userInfo: ["mealID": command.mealID]) + if outcome != .sendFailed { + NotificationCenter.default.post(name: .remoteMealCommandDidComplete, object: nil, userInfo: ["mealID": command.mealID]) + } } } } From 640c3225b4387b1be472cfaf8f9505013426186f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 21 Sep 2026 14:22:55 +0200 Subject: [PATCH 08/84] Track remote meal commands with one generic tracker RemoteCommandTracker keeps one pending/done state per key (Trio meal id or Loop sync identifier) with a 60 s timeout, and resolves it from the AID app's return push through two thin adapters: TRC by command_id, Loop by sync_identifier. Loop carb delete/edit require LoopFollow's return credentials, like Trio, since the push is the only confirmation. The detail view reads busy / failure message from the tracker and dismisses on a success transition it observes itself, so a terminal state left from an earlier visit never dismisses a freshly opened view. --- LoopFollow/Application/AppDelegate.swift | 9 +- .../Remote/LoopAPNS/LoopAPNSService.swift | 24 +- .../LoopAPNS/LoopCarbActionTracker.swift | 244 ----------------- .../Remote/LoopAPNS/LoopCarbEditSheet.swift | 2 +- LoopFollow/Remote/RemoteCommandTracker.swift | 254 ++++++++++++++++++ LoopFollow/Remote/TRC/TRCCommandTracker.swift | 186 ------------- LoopFollow/Remote/TRC/TRCMealEditView.swift | 2 +- LoopFollow/Treatments/TreatmentsView.swift | 94 +++---- Tests/LoopCarbTreatmentTests.swift | 11 - Tests/RemoteCommandTrackerTests.swift | 85 ++++++ Tests/TRCCommandAckTests.swift | 81 ------ 11 files changed, 404 insertions(+), 588 deletions(-) delete mode 100644 LoopFollow/Remote/LoopAPNS/LoopCarbActionTracker.swift create mode 100644 LoopFollow/Remote/RemoteCommandTracker.swift delete mode 100644 LoopFollow/Remote/TRC/TRCCommandTracker.swift create mode 100644 Tests/RemoteCommandTrackerTests.swift delete mode 100644 Tests/TRCCommandAckTests.swift diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index d9e1b21b8..3cc65b736 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -134,8 +134,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { let userInfoKeys = userInfo.keys.compactMap { $0 as? String }.sorted() LogManager.shared.log(category: .apns, message: "Received remote notification: keys=\(userInfoKeys)") - TRCCommandTracker.shared.handleNotification(userInfo: userInfo) - LoopCarbActionTracker.shared.handleNotification(userInfo: userInfo) + RemoteCommandTracker.shared.handleNotification(userInfo: userInfo) // Check if this is a response notification from Loop or Trio if let aps = userInfo["aps"] as? [String: Any] { @@ -185,8 +184,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } func userNotificationCenter(_: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { - TRCCommandTracker.shared.handleNotification(userInfo: response.notification.request.content.userInfo) - LoopCarbActionTracker.shared.handleNotification(userInfo: response.notification.request.content.userInfo) + RemoteCommandTracker.shared.handleNotification(userInfo: response.notification.request.content.userInfo) if response.actionIdentifier == "OPEN_APP_ACTION" { // Dismiss any presented modal/sheet so the user actually sees Home @@ -272,8 +270,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { let content = notification.request.content - TRCCommandTracker.shared.handleNotification(userInfo: content.userInfo) - LoopCarbActionTracker.shared.handleNotification(userInfo: content.userInfo) + RemoteCommandTracker.shared.handleNotification(userInfo: content.userInfo) let userInfoKeys = content.userInfo.keys.compactMap { $0 as? String }.sorted() LogManager.shared.log( category: .general, diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index d7f66377c..5203c4665 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -122,6 +122,8 @@ class LoopAPNSService { return hasFullSetup } + private static let returnNotificationRequiredMessage = "Editing or deleting a carb entry needs LoopFollow's own APNS credentials so Loop can confirm the result. Configure them in App Settings β†’ APN." + /// Fields every Loop remote command carries, plus the return-notification block encrypted with the OTP. private func remoteCommandPayload(otp: String, alert: String) -> [String: Any] { let now = Date() @@ -142,6 +144,16 @@ class LoopAPNSService { return payload } + /// Carb delete/edit are confirmed only through Loop's return push, so the encrypted return block is mandatory. + private func confirmedCommandPayload(otp: String, alert: String) -> [String: Any]? { + let payload = remoteCommandPayload(otp: otp, alert: alert) + guard payload["encrypted_return_notification"] != nil else { + LogManager.shared.log(category: .apns, message: "Carb command not sent: return notification info unavailable") + return nil + } + return payload + } + private func sendRemoteCommand(_ payload: [String: Any], completion: @escaping (Bool, String?) -> Void) { guard validateSetup() else { let errorMessage = "Loop APNS Configuration not valid" @@ -163,7 +175,10 @@ class LoopAPNSService { /// Deletes the Loop carb entry with this `syncIdentifier`. Requires the Loop remote carb edit patch. func sendCarbsDelete(syncIdentifier: String, otp: String, completion: @escaping (Bool, String?) -> Void) { - var payload = remoteCommandPayload(otp: otp, alert: "Remote Carbs Delete") + guard var payload = confirmedCommandPayload(otp: otp, alert: "Remote Carbs Delete") else { + completion(false, Self.returnNotificationRequiredMessage) + return + } payload["carbs-delete"] = syncIdentifier LogManager.shared.log(category: .apns, message: "Sending carbs delete for syncIdentifier=\(LogRedactor.tail(syncIdentifier))") sendRemoteCommand(payload, completion: completion) @@ -179,10 +194,13 @@ class LoopAPNSService { otp: String, completion: @escaping (Bool, String?) -> Void ) { - var payload = remoteCommandPayload( + guard var payload = confirmedCommandPayload( otp: otp, alert: "Remote Carbs Edit: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTimeHours)) hours" - ) + ) else { + completion(false, Self.returnNotificationRequiredMessage) + return + } payload["carbs-edit"] = syncIdentifier payload["carbs-edit-entry"] = carbsAmount payload["carbs-edit-absorption-time"] = absorptionTimeHours diff --git a/LoopFollow/Remote/LoopAPNS/LoopCarbActionTracker.swift b/LoopFollow/Remote/LoopAPNS/LoopCarbActionTracker.swift deleted file mode 100644 index 79117d645..000000000 --- a/LoopFollow/Remote/LoopAPNS/LoopCarbActionTracker.swift +++ /dev/null @@ -1,244 +0,0 @@ -// LoopFollow -// LoopCarbActionTracker.swift - -import Foundation - -/// Sends carb delete/edit commands to Loop and tracks their outcome. -/// -/// Loop's return push (`command_type` `carbs_delete`/`carbs_edit`, `sync_identifier`, -/// `command_status`) is the primary confirmation. Nightscout is polled by `syncIdentifier` -/// as a fallback: a delete is confirmed when the entry disappears, an edit when the new -/// values are published, and a failure Note from Loop reports the error. -final class LoopCarbActionTracker: ObservableObject { - static let ackCommandTypes: Set = ["carbs_delete", "carbs_edit"] - static let shared = LoopCarbActionTracker() - static let pollInterval: TimeInterval = 10 - static let timeout: TimeInterval = 120 - - enum State: Equatable { - case idle - case sending - case awaitingConfirmation(since: Date) - case confirmed - case failed(String) - case timedOut - - var isTerminal: Bool { - switch self { - case .confirmed, .failed, .timedOut: return true - default: return false - } - } - - var message: String? { - switch self { - case .idle: return nil - case .sending: return "Sending…" - case .awaitingConfirmation: return "Sent, waiting for Nightscout to reflect the change…" - case .confirmed: return "Confirmed." - case let .failed(message): return message - case .timedOut: return "Loop may have applied the change but Nightscout was not updated. Refresh the list to check." - } - } - } - - private enum Expectation { - case deleted - case edited(carbs: Double, absorptionMinutes: Double) - } - - private struct Operation { - let syncIdentifier: String - let actionName: String - let sentAt: Date - let expectation: Expectation - } - - @Published private(set) var states: [String: State] = [:] - - private var operations: [String: Operation] = [:] - private var timers: [String: Timer] = [:] - - func state(forSyncIdentifier syncIdentifier: String) -> State { - states[syncIdentifier] ?? .idle - } - - func isBusy(syncIdentifier: String) -> Bool { - switch state(forSyncIdentifier: syncIdentifier) { - case .sending, .awaitingConfirmation: return true - default: return false - } - } - - func sendDelete(carb: LoopCarbTreatment, completion: @escaping (Bool, String?) -> Void) { - guard let otp = currentOTP(completion: completion) else { return } - setState(.sending, for: carb.syncIdentifier) - LoopAPNSService().sendCarbsDelete(syncIdentifier: carb.syncIdentifier, otp: otp) { [weak self] success, error in - self?.handleSendResult( - Operation(syncIdentifier: carb.syncIdentifier, actionName: "Delete Carbs", sentAt: Date(), expectation: .deleted), - success: success, - error: error, - completion: completion - ) - } - } - - func sendEdit(carb: LoopCarbTreatment, carbsAmount: Double, absorptionHours: Double, foodType: String?, consumedDate: Date?, completion: @escaping (Bool, String?) -> Void) { - guard let otp = currentOTP(completion: completion) else { return } - setState(.sending, for: carb.syncIdentifier) - LoopAPNSService().sendCarbsEdit( - syncIdentifier: carb.syncIdentifier, - carbsAmount: carbsAmount, - absorptionTimeHours: absorptionHours, - foodType: foodType, - consumedDate: consumedDate, - otp: otp - ) { [weak self] success, error in - self?.handleSendResult( - Operation( - syncIdentifier: carb.syncIdentifier, - actionName: "Edit Carbs", - sentAt: Date(), - expectation: .edited(carbs: carbsAmount, absorptionMinutes: absorptionHours * 60) - ), - success: success, - error: error, - completion: completion - ) - } - } - - /// Returns true when the notification is a Loop carb command ack (matched or not). - @discardableResult - func handleNotification(userInfo: [AnyHashable: Any]) -> Bool { - guard let commandType = userInfo["command_type"] as? String, - Self.ackCommandTypes.contains(commandType), - let status = userInfo["command_status"] as? String - else { return false } - let syncIdentifier = userInfo["sync_identifier"] as? String - let alert = (userInfo["aps"] as? [String: Any])?["alert"] as? [String: Any] - let message = alert?["body"] as? String - LogManager.shared.log( - category: .apns, - message: "Loop ack: type=\(commandType) status=\(status) sync_identifier=\(syncIdentifier.map { LogRedactor.tail($0) } ?? "-")" - ) - guard let syncIdentifier, operations[syncIdentifier] != nil else { return true } - let state: State = status == "success" ? .confirmed : .failed(message ?? "Loop reported a failure.") - DispatchQueue.main.async { self.finish(syncIdentifier, state: state) } - return true - } - - // MARK: - Internal - - private func currentOTP(completion: (Bool, String?) -> Void) -> String? { - let qrCodeURL = Storage.shared.loopAPNSQrCodeURL.value - guard let otp = TOTPGenerator.extractOTPFromURL(qrCodeURL) else { - completion(false, "Invalid QR code URL. Please re-scan the QR code in settings.") - return nil - } - guard !TOTPService.shared.isTOTPBlocked(qrCodeURL: qrCodeURL) else { - completion(false, "The current one-time code was already used. Wait for the next code and try again.") - return nil - } - return otp - } - - private func handleSendResult(_ operation: Operation, success: Bool, error: String?, completion: @escaping (Bool, String?) -> Void) { - DispatchQueue.main.async { - guard success else { - self.finish(operation.syncIdentifier, state: .failed(error ?? "The command could not be sent.")) - completion(false, error) - return - } - TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) - LogManager.shared.log(category: .apns, message: "\(operation.actionName) sent for syncIdentifier=\(LogRedactor.tail(operation.syncIdentifier))") - self.operations[operation.syncIdentifier] = operation - self.setState(.awaitingConfirmation(since: operation.sentAt), for: operation.syncIdentifier) - let timer = Timer.scheduledTimer(withTimeInterval: Self.pollInterval, repeats: true) { [weak self] _ in - self?.poll(operation.syncIdentifier) - } - self.timers[operation.syncIdentifier] = timer - completion(true, nil) - } - } - - private func poll(_ syncIdentifier: String) { - guard let operation = operations[syncIdentifier] else { return } - if Date().timeIntervalSince(operation.sentAt) > Self.timeout { - finish(syncIdentifier, state: .timedOut) - return - } - - fetchTreatments(parameters: ["find[syncIdentifier]": syncIdentifier, "count": "1"]) { [weak self] entries in - guard let self, let entries else { return } - let confirmed: Bool - switch operation.expectation { - case .deleted: - confirmed = entries.isEmpty - case let .edited(carbs, absorptionMinutes): - confirmed = entries.contains { entry in - let entryCarbs = (entry["carbs"] as? NSNumber)?.doubleValue ?? -1 - let entryAbsorption = (entry["absorptionTime"] as? NSNumber)?.doubleValue ?? -1 - return abs(entryCarbs - carbs.rounded()) < 0.5 && abs(entryAbsorption - absorptionMinutes) < 1 - } - } - if confirmed { - DispatchQueue.main.async { self.finish(syncIdentifier, state: .confirmed) } - return - } - self.checkFailureNote(for: operation) - } - } - - private func checkFailureNote(for operation: Operation) { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let since = formatter.string(from: operation.sentAt.addingTimeInterval(-60)) - fetchTreatments(parameters: ["find[eventType]": "Note", "find[created_at][$gte]": since, "count": "10"]) { [weak self] entries in - guard let self, let entries else { return } - let failure = entries.first { entry in - (entry["enteredBy"] as? String)?.hasPrefix(operation.actionName) == true - } - guard let failure else { return } - let notes = (failure["notes"] as? String) ?? "" - let firstLine = notes.split(separator: "\n").first.map(String.init) ?? "Loop rejected the command." - DispatchQueue.main.async { self.finish(operation.syncIdentifier, state: .failed(firstLine)) } - } - } - - private func fetchTreatments(parameters: [String: String], completion: @escaping ([[String: AnyObject]]?) -> Void) { - guard let url = NightscoutUtils.constructURL( - baseURL: Storage.shared.url.value, - token: Storage.shared.token.value, - endpoint: "/api/v1/treatments.json", - parameters: parameters - ) else { - completion(nil) - return - } - var request = URLRequest(url: url) - request.cachePolicy = .reloadIgnoringLocalCacheData - URLSession.shared.dataTask(with: request) { data, _, error in - guard error == nil, let data, - let entries = try? JSONSerialization.jsonObject(with: data) as? [[String: AnyObject]] - else { - completion(nil) - return - } - completion(entries) - }.resume() - } - - private func setState(_ state: State, for syncIdentifier: String) { - DispatchQueue.main.async { self.states[syncIdentifier] = state } - } - - private func finish(_ syncIdentifier: String, state: State) { - timers[syncIdentifier]?.invalidate() - timers[syncIdentifier] = nil - operations[syncIdentifier] = nil - states[syncIdentifier] = state - LogManager.shared.log(category: .apns, message: "Loop carb command for syncIdentifier=\(LogRedactor.tail(syncIdentifier)) finished: \(state)") - NotificationCenter.default.post(name: .remoteMealCommandDidComplete, object: nil, userInfo: ["syncIdentifier": syncIdentifier]) - } -} diff --git a/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift b/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift index cad24bf57..cceeff59e 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift @@ -175,7 +175,7 @@ struct LoopCarbEditSheet: View { isSending = true let originalDate = Date(timeIntervalSince1970: carb.date) let dateChanged = abs(consumedDate.timeIntervalSince(originalDate)) >= 60 - LoopCarbActionTracker.shared.sendEdit( + RemoteCommandTracker.shared.sendLoopCarbEdit( carb: carb, carbsAmount: carbsAmount.doubleValue(for: .gram()), absorptionHours: absorptionTimeValue, diff --git a/LoopFollow/Remote/RemoteCommandTracker.swift b/LoopFollow/Remote/RemoteCommandTracker.swift new file mode 100644 index 000000000..1f7b4a573 --- /dev/null +++ b/LoopFollow/Remote/RemoteCommandTracker.swift @@ -0,0 +1,254 @@ +// LoopFollow +// RemoteCommandTracker.swift + +import Foundation + +extension Notification.Name { + /// Posted when a remote meal edit/delete reaches a terminal state, so treatment lists can refresh. + static let remoteMealCommandDidComplete = Notification.Name("LoopFollow.remoteMealCommandDidComplete") +} + +/// Tracks one in-flight remote command per key (Trio meal id or Loop sync identifier) until the +/// AID app's return push resolves it or the timeout fires. All state changes happen on the main queue. +final class RemoteCommandTracker: ObservableObject { + static let shared = RemoteCommandTracker() + static let timeoutMessage = "No confirmation received. Refresh the list to check whether the change was applied." + + enum State: Equatable { + case pending(since: Date) + case done(success: Bool, message: String) + } + + struct Resolution: Equatable { + let key: String + let success: Bool + let message: String + } + + @Published private(set) var states: [String: State] = [:] + + let trioAcks = TRCMealAckAdapter() + private let timeout: TimeInterval + private var timeouts: [String: DispatchWorkItem] = [:] + + init(timeout: TimeInterval = 60) { + self.timeout = timeout + } + + func isBusy(key: String) -> Bool { + if case .pending = states[key] { return true } + return false + } + + func begin(key: String) { + onMain { + self.timeouts[key]?.cancel() + self.states[key] = .pending(since: Date()) + let work = DispatchWorkItem { [weak self] in + self?.resolve(key: key, success: false, message: Self.timeoutMessage) + } + self.timeouts[key] = work + DispatchQueue.main.asyncAfter(deadline: .now() + self.timeout, execute: work) + } + } + + func resolve(key: String, success: Bool, message: String) { + onMain { + guard case .pending = self.states[key] else { return } + self.timeouts[key]?.cancel() + self.timeouts[key] = nil + self.states[key] = .done(success: success, message: message) + LogManager.shared.log(category: .apns, message: "Remote command for \(LogRedactor.tail(key)) finished: success=\(success) \(message)") + NotificationCenter.default.post(name: .remoteMealCommandDidComplete, object: nil, userInfo: ["key": key]) + } + } + + /// Clears a terminal state once the UI has acted on it. + func consume(key: String) { + onMain { + if case .done = self.states[key] { self.states[key] = nil } + } + } + + /// Returns true when the push resolved a pending command. + @discardableResult + func handleNotification(userInfo: [AnyHashable: Any]) -> Bool { + guard let resolution = trioAcks.resolution(for: userInfo) ?? LoopCarbAckAdapter.resolution(for: userInfo) else { return false } + guard isBusy(key: resolution.key) else { + LogManager.shared.log(category: .apns, message: "Ack for \(LogRedactor.tail(resolution.key)) matched no pending command") + return false + } + resolve(key: resolution.key, success: resolution.success, message: resolution.message) + return true + } + + private func onMain(_ work: @escaping () -> Void) { + if Thread.isMainThread { work() } else { DispatchQueue.main.async(execute: work) } + } +} + +/// Correlates Trio's return push with the meal a command was sent for via the per-send command id. +final class TRCMealAckAdapter { + private var keys: [String: String] = [:] + + func register(commandID: String, key: String) { + keys[commandID] = key + } + + func unregister(commandID: String) { + keys[commandID] = nil + } + + func resolution(for userInfo: [AnyHashable: Any]) -> RemoteCommandTracker.Resolution? { + guard let status = userInfo["command_status"] as? String, + let commandID = userInfo["command_id"] as? String, + let key = keys[commandID] + else { return nil } + keys[commandID] = nil + let success = status == "success" + let result = userInfo["result"] as? String + LogManager.shared.log(category: .apns, message: "TRC ack: status=\(status) result=\(result ?? "-") command_id=\(commandID)") + return RemoteCommandTracker.Resolution( + key: key, + success: success, + message: alertBody(userInfo) ?? Self.defaultMessage(result: result, success: success) + ) + } + + private static func defaultMessage(result: String?, success: Bool) -> String { + switch result { + case "deleted": return "Meal deleted." + case "updated": return "Meal updated." + case "not_found": return "Trio could not find this meal. It may already have been deleted or changed on the phone." + case "rejected": return "Trio rejected the command." + default: return success ? "Command confirmed." : "Trio reported a failure." + } + } +} + +/// Matches Loop's return push for carb delete/edit commands by sync identifier. +enum LoopCarbAckAdapter { + static let commandTypes: Set = ["carbs_delete", "carbs_edit"] + + static func resolution(for userInfo: [AnyHashable: Any]) -> RemoteCommandTracker.Resolution? { + guard let commandType = userInfo["command_type"] as? String, + commandTypes.contains(commandType), + let status = userInfo["command_status"] as? String, + let key = userInfo["sync_identifier"] as? String + else { return nil } + let success = status == "success" + LogManager.shared.log(category: .apns, message: "Loop ack: type=\(commandType) status=\(status) sync_identifier=\(LogRedactor.tail(key))") + let defaultMessage: String + if !success { + defaultMessage = "Loop reported a failure." + } else { + defaultMessage = commandType == "carbs_delete" ? "Carb entry deleted." : "Carb entry updated." + } + return RemoteCommandTracker.Resolution(key: key, success: success, message: alertBody(userInfo) ?? defaultMessage) + } +} + +private func alertBody(_ userInfo: [AnyHashable: Any]) -> String? { + let alert = (userInfo["aps"] as? [String: Any])?["alert"] as? [String: Any] + guard let body = alert?["body"] as? String, !body.isEmpty else { return nil } + return body +} + +// MARK: - Trio meals + +extension RemoteCommandTracker { + func sendTrioMealDelete(mealID: String, completion: @escaping (Bool, String?) -> Void) { + let commandID = UUID().uuidString + trioAcks.register(commandID: commandID, key: mealID) + begin(key: mealID) + PushNotificationManager().sendDeleteMealPushNotification(mealID: mealID, commandID: commandID) { [weak self] success, error in + self?.finishTrioSend(key: mealID, commandID: commandID, success: success, error: error, completion: completion) + } + } + + func sendTrioMealEdit(mealID: String, carbs: Int, fat: Int, protein: Int, date: Date, completion: @escaping (Bool, String?) -> Void) { + let commandID = UUID().uuidString + trioAcks.register(commandID: commandID, key: mealID) + begin(key: mealID) + PushNotificationManager().sendEditMealPushNotification( + mealID: mealID, + commandID: commandID, + carbs: carbs, + fat: fat, + protein: protein, + scheduledTime: date + ) { [weak self] success, error in + self?.finishTrioSend(key: mealID, commandID: commandID, success: success, error: error, completion: completion) + } + } + + private func finishTrioSend(key: String, commandID: String, success: Bool, error: String?, completion: @escaping (Bool, String?) -> Void) { + DispatchQueue.main.async { + if success { + LogManager.shared.log(category: .apns, message: "Meal command sent command_id=\(commandID) meal_id=\(key)") + } else { + self.trioAcks.unregister(commandID: commandID) + self.resolve(key: key, success: false, message: error ?? "The command could not be sent.") + } + completion(success, error) + } + } +} + +// MARK: - Loop carb entries + +extension RemoteCommandTracker { + func sendLoopCarbDelete(carb: LoopCarbTreatment, completion: @escaping (Bool, String?) -> Void) { + let key = carb.syncIdentifier + begin(key: key) + guard let otp = loopOTP(key: key, completion: completion) else { return } + LoopAPNSService().sendCarbsDelete(syncIdentifier: key, otp: otp) { [weak self] success, error in + self?.finishLoopSend(key: key, success: success, error: error, completion: completion) + } + } + + func sendLoopCarbEdit(carb: LoopCarbTreatment, carbsAmount: Double, absorptionHours: Double, foodType: String?, consumedDate: Date?, completion: @escaping (Bool, String?) -> Void) { + let key = carb.syncIdentifier + begin(key: key) + guard let otp = loopOTP(key: key, completion: completion) else { return } + LoopAPNSService().sendCarbsEdit( + syncIdentifier: key, + carbsAmount: carbsAmount, + absorptionTimeHours: absorptionHours, + foodType: foodType, + consumedDate: consumedDate, + otp: otp + ) { [weak self] success, error in + self?.finishLoopSend(key: key, success: success, error: error, completion: completion) + } + } + + private func loopOTP(key: String, completion: (Bool, String?) -> Void) -> String? { + let qrCodeURL = Storage.shared.loopAPNSQrCodeURL.value + let otp = TOTPGenerator.extractOTPFromURL(qrCodeURL) + let message: String? + if otp == nil { + message = "Invalid QR code URL. Please re-scan the QR code in settings." + } else if TOTPService.shared.isTOTPBlocked(qrCodeURL: qrCodeURL) { + message = "The current one-time code was already used. Wait for the next code and try again." + } else { + message = nil + } + guard let message else { return otp } + resolve(key: key, success: false, message: message) + completion(false, message) + return nil + } + + private func finishLoopSend(key: String, success: Bool, error: String?, completion: @escaping (Bool, String?) -> Void) { + DispatchQueue.main.async { + if success { + TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) + LogManager.shared.log(category: .apns, message: "Carb command sent for syncIdentifier=\(LogRedactor.tail(key))") + } else { + self.resolve(key: key, success: false, message: error ?? "The command could not be sent.") + } + completion(success, error) + } + } +} diff --git a/LoopFollow/Remote/TRC/TRCCommandTracker.swift b/LoopFollow/Remote/TRC/TRCCommandTracker.swift deleted file mode 100644 index 3bea87415..000000000 --- a/LoopFollow/Remote/TRC/TRCCommandTracker.swift +++ /dev/null @@ -1,186 +0,0 @@ -// LoopFollow -// TRCCommandTracker.swift - -import Foundation - -/// Return notification Trio sends after processing a command. -struct TRCCommandAck: Equatable { - let commandID: String? - let commandType: String? - let status: String - let result: String? - let mealID: String? - let message: String? - - init?(userInfo: [AnyHashable: Any]) { - guard let status = userInfo["command_status"] as? String else { return nil } - self.status = status - commandID = userInfo["command_id"] as? String - commandType = userInfo["command_type"] as? String - result = userInfo["result"] as? String - mealID = userInfo["meal_id"] as? String - let alert = (userInfo["aps"] as? [String: Any])?["alert"] as? [String: Any] - message = alert?["body"] as? String - } - - var isSuccess: Bool { status == "success" } -} - -extension Notification.Name { - /// Posted when a remote meal edit/delete reaches a terminal state, so treatment lists can refresh. - static let remoteMealCommandDidComplete = Notification.Name("LoopFollow.remoteMealCommandDidComplete") -} - -/// Tracks in-flight meal edit/delete commands to Trio and correlates Trio's acks by command id. -final class TRCCommandTracker: ObservableObject { - static let shared = TRCCommandTracker() - static let timeout: TimeInterval = 60 - - struct PendingCommand: Identifiable, Equatable { - let id: String - let type: TRCCommandType - let mealID: String - let sentAt: Date - } - - enum Outcome: Equatable { - case deleted - case updated(newMealID: String?) - case notFound - case rejected - case failed - case timedOut - case sendFailed - - var isSuccess: Bool { - switch self { - case .deleted, .updated: return true - default: return false - } - } - } - - struct CompletedCommand: Equatable { - let command: PendingCommand - let outcome: Outcome - let message: String? - let completedAt: Date - - var isSuccess: Bool { outcome.isSuccess } - - var displayMessage: String { - if let message, !message.isEmpty { return message } - switch outcome { - case .deleted: return "Meal deleted." - case .updated: return "Meal updated." - case .notFound: return "Trio could not find this meal. It may already have been deleted or changed on the phone." - case .rejected: return "Trio rejected the command." - case .failed: return "Trio reported a failure." - case .timedOut: return "No confirmation from Trio yet. Refresh the list to check whether the change was applied." - case .sendFailed: return "The command could not be sent." - } - } - } - - @Published private(set) var pending: [String: PendingCommand] = [:] - @Published private(set) var lastCompleted: [String: CompletedCommand] = [:] - - private var timeouts: [String: DispatchWorkItem] = [:] - - func pendingCommand(forMealID mealID: String) -> PendingCommand? { - pending.values.first { $0.mealID == mealID } - } - - func lastResult(forMealID mealID: String) -> CompletedCommand? { - lastCompleted[mealID] - } - - func sendDelete(mealID: String, completion: @escaping (Bool, String?) -> Void) { - let command = register(type: .deleteMeal, mealID: mealID) - PushNotificationManager().sendDeleteMealPushNotification(mealID: command.mealID, commandID: command.id) { [weak self] success, error in - self?.handleSendResult(command, success: success, error: error, completion: completion) - } - } - - func sendEdit(mealID: String, carbs: Int, fat: Int, protein: Int, date: Date, completion: @escaping (Bool, String?) -> Void) { - let command = register(type: .editMeal, mealID: mealID) - PushNotificationManager().sendEditMealPushNotification( - mealID: command.mealID, - commandID: command.id, - carbs: carbs, - fat: fat, - protein: protein, - scheduledTime: date - ) { [weak self] success, error in - self?.handleSendResult(command, success: success, error: error, completion: completion) - } - } - - /// Returns true when the notification matched a pending command. - @discardableResult - func handleNotification(userInfo: [AnyHashable: Any]) -> Bool { - guard let ack = TRCCommandAck(userInfo: userInfo) else { return false } - LogManager.shared.log( - category: .apns, - message: "TRC ack: type=\(ack.commandType ?? "-") status=\(ack.status) result=\(ack.result ?? "-") command_id=\(ack.commandID ?? "-")" - ) - guard let commandID = ack.commandID, let command = pending[commandID] else { return false } - - let outcome: Outcome - switch ack.result { - case "deleted": outcome = .deleted - case "updated": outcome = .updated(newMealID: ack.mealID) - case "not_found": outcome = .notFound - case "rejected": outcome = .rejected - default: - if ack.isSuccess { - outcome = command.type == .deleteMeal ? .deleted : .updated(newMealID: ack.mealID) - } else { - outcome = .failed - } - } - complete(command, outcome: outcome, message: ack.message) - return true - } - - // MARK: - Internal - - func register(type: TRCCommandType, mealID: String) -> PendingCommand { - let command = PendingCommand(id: UUID().uuidString, type: type, mealID: mealID, sentAt: Date()) - DispatchQueue.main.async { - self.pending[command.id] = command - self.lastCompleted[mealID] = nil - } - return command - } - - private func handleSendResult(_ command: PendingCommand, success: Bool, error: String?, completion: @escaping (Bool, String?) -> Void) { - DispatchQueue.main.async { - guard success else { - self.complete(command, outcome: .sendFailed, message: error) - completion(false, error) - return - } - let work = DispatchWorkItem { [weak self] in - self?.complete(command, outcome: .timedOut, message: nil) - } - self.timeouts[command.id] = work - DispatchQueue.main.asyncAfter(deadline: .now() + Self.timeout, execute: work) - LogManager.shared.log(category: .apns, message: "\(command.type.rawValue) sent command_id=\(command.id) meal_id=\(command.mealID)") - completion(true, nil) - } - } - - func complete(_ command: PendingCommand, outcome: Outcome, message: String?) { - DispatchQueue.main.async { - self.timeouts[command.id]?.cancel() - self.timeouts[command.id] = nil - guard self.pending[command.id] != nil else { return } - self.pending[command.id] = nil - self.lastCompleted[command.mealID] = CompletedCommand(command: command, outcome: outcome, message: message, completedAt: Date()) - if outcome != .sendFailed { - NotificationCenter.default.post(name: .remoteMealCommandDidComplete, object: nil, userInfo: ["mealID": command.mealID]) - } - } - } -} diff --git a/LoopFollow/Remote/TRC/TRCMealEditView.swift b/LoopFollow/Remote/TRC/TRCMealEditView.swift index 121dab77d..6cdcb47d9 100644 --- a/LoopFollow/Remote/TRC/TRCMealEditView.swift +++ b/LoopFollow/Remote/TRC/TRCMealEditView.swift @@ -150,7 +150,7 @@ struct TRCMealEditView: View { private func send() { isSending = true - TRCCommandTracker.shared.sendEdit( + RemoteCommandTracker.shared.sendTrioMealEdit( mealID: meal.mealID.uuidString, carbs: carbsValue, fat: fatValue, diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift index 624a3ef17..31db334b2 100644 --- a/LoopFollow/Treatments/TreatmentsView.swift +++ b/LoopFollow/Treatments/TreatmentsView.swift @@ -399,14 +399,21 @@ struct TreatmentDetailView: View { var rootMeal: Treatment? = nil @StateObject private var viewModel = TreatmentDetailViewModel() @Environment(\.presentationMode) private var presentationMode - @ObservedObject private var commandTracker = TRCCommandTracker.shared - @ObservedObject private var loopCarbTracker = LoopCarbActionTracker.shared + @ObservedObject private var commandTracker = RemoteCommandTracker.shared @ObservedObject private var remoteType = Storage.shared.remoteType @ObservedObject private var device = Storage.shared.device @ObservedObject private var remoteCommands = Storage.shared.remoteCommands @State private var showEditSheet = false @State private var showDeleteConfirmation = false - @State private var sendError: String? + + /// Key under which the tracker follows this treatment's remote commands. + private var commandKey: String? { + treatment.trioMeal?.mealID.uuidString ?? treatment.loopCarb?.syncIdentifier + } + + private var commandState: RemoteCommandTracker.State? { + commandKey.flatMap { commandTracker.states[$0] } + } var body: some View { List { @@ -597,15 +604,9 @@ struct TreatmentDetailView: View { } message: { Text(deleteDialogMessage) } - .onReceive(commandTracker.$lastCompleted) { completed in - guard let meal = treatment.trioMeal, - let result = completed[meal.mealID.uuidString], - result.isSuccess - else { return } - presentationMode.wrappedValue.dismiss() - } - .onReceive(loopCarbTracker.$states) { states in - guard let carb = treatment.loopCarb, states[carb.syncIdentifier] == .confirmed else { return } + .onChange(of: commandState) { _, state in + guard let commandKey, case .done(success: true, message: _) = state else { return } + commandTracker.consume(key: commandKey) presentationMode.wrappedValue.dismiss() } } @@ -621,15 +622,28 @@ struct TreatmentDetailView: View { } private func sendDelete() { - let completion: (Bool, String?) -> Void = { success, error in - DispatchQueue.main.async { - sendError = success ? nil : (error ?? "Failed to send the delete command.") - } - } if let meal = treatment.trioMeal { - TRCCommandTracker.shared.sendDelete(mealID: meal.mealID.uuidString, completion: completion) + commandTracker.sendTrioMealDelete(mealID: meal.mealID.uuidString) { _, _ in } } else if let carb = treatment.loopCarb { - LoopCarbActionTracker.shared.sendDelete(carb: carb, completion: completion) + commandTracker.sendLoopCarbDelete(carb: carb) { _, _ in } + } + } + + /// Pending indicator or last failure for this treatment's remote command. + @ViewBuilder + private func commandStatusRow(pendingText: String) -> some View { + switch commandState { + case .pending: + HStack { + ProgressView().scaleEffect(0.8) + Text(pendingText).foregroundColor(.secondary) + } + case let .done(success, message) where !success: + Text(message) + .font(.footnote) + .foregroundColor(.red) + default: + EmptyView() } } @@ -660,25 +674,11 @@ struct TreatmentDetailView: View { @ViewBuilder private func loopRemoteActionsSection(_ carb: LoopCarbTreatment) -> some View { - let state = loopCarbTracker.state(forSyncIdentifier: carb.syncIdentifier) - let busy = loopCarbTracker.isBusy(syncIdentifier: carb.syncIdentifier) + let busy = commandTracker.isBusy(key: carb.syncIdentifier) let withinWindow = carb.isWithinEditWindow() Section(header: Text("Remote actions"), footer: loopRemoteActionsFooter(withinWindow: withinWindow)) { - if busy, let message = state.message { - HStack { - ProgressView().scaleEffect(0.8) - Text(message).foregroundColor(.secondary) - } - } else if state.isTerminal, state != .confirmed, let message = state.message { - Text(message) - .font(.footnote) - .foregroundColor(.red) - } else if let sendError { - Text(sendError) - .font(.footnote) - .foregroundColor(.red) - } + commandStatusRow(pendingText: "Sent, awaiting confirmation from Loop…") Button("Edit carbs") { showEditSheet = true } .disabled(!withinWindow || busy) @@ -712,32 +712,16 @@ struct TreatmentDetailView: View { @ViewBuilder private func trioRemoteActionsSection(_ meal: TrioMealTreatment) -> some View { - let mealID = meal.mealID.uuidString - let pending = commandTracker.pendingCommand(forMealID: mealID) - let lastResult = commandTracker.lastResult(forMealID: mealID) + let busy = commandTracker.isBusy(key: meal.mealID.uuidString) let withinWindow = meal.isWithinEditWindow() Section(header: Text("Remote actions"), footer: remoteActionsFooter(withinWindow: withinWindow)) { - if pending != nil { - HStack { - ProgressView().scaleEffect(0.8) - Text("Sent, awaiting confirmation from Trio…") - .foregroundColor(.secondary) - } - } else if let lastResult, !lastResult.isSuccess { - Text(lastResult.displayMessage) - .font(.footnote) - .foregroundColor(.red) - } else if let sendError { - Text(sendError) - .font(.footnote) - .foregroundColor(.red) - } + commandStatusRow(pendingText: "Sent, awaiting confirmation from Trio…") Button("Edit meal") { showEditSheet = true } - .disabled(!withinWindow || pending != nil) + .disabled(!withinWindow || busy) Button("Delete meal", role: .destructive) { showDeleteConfirmation = true } - .disabled(!withinWindow || pending != nil) + .disabled(!withinWindow || busy) } } diff --git a/Tests/LoopCarbTreatmentTests.swift b/Tests/LoopCarbTreatmentTests.swift index 25c9a2a20..e6c2bcb47 100644 --- a/Tests/LoopCarbTreatmentTests.swift +++ b/Tests/LoopCarbTreatmentTests.swift @@ -58,14 +58,3 @@ struct LoopCarbTreatmentTests { #expect(LoopCarbTreatment.remoteActionsAvailable(remoteType: .trc, device: "Loop") == false) } } - -struct LoopCarbAckTests { - @Test("only Loop carb command acks are consumed") - func routing() { - let tracker = LoopCarbActionTracker.shared - #expect(tracker.handleNotification(userInfo: ["command_type": "carbs_delete", "command_status": "success", "sync_identifier": "unknown"]) == true) - #expect(tracker.handleNotification(userInfo: ["command_type": "delete_meal", "command_status": "success"]) == false) - #expect(tracker.handleNotification(userInfo: ["command_type": "carbs_edit"]) == false) - #expect(tracker.state(forSyncIdentifier: "unknown") == .idle) - } -} diff --git a/Tests/RemoteCommandTrackerTests.swift b/Tests/RemoteCommandTrackerTests.swift new file mode 100644 index 000000000..a7ec75dd2 --- /dev/null +++ b/Tests/RemoteCommandTrackerTests.swift @@ -0,0 +1,85 @@ +// LoopFollow +// RemoteCommandTrackerTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@MainActor +struct RemoteCommandTrackerTests { + @Test("begin, resolve and consume walk one key through its states") + func beginResolveConsume() { + let tracker = RemoteCommandTracker(timeout: 60) + tracker.begin(key: "K") + #expect(tracker.isBusy(key: "K")) + + tracker.resolve(key: "K", success: true, message: "Done") + #expect(tracker.states["K"] == .done(success: true, message: "Done")) + #expect(!tracker.isBusy(key: "K")) + + tracker.consume(key: "K") + #expect(tracker.states["K"] == nil) + } + + @Test("resolve without a pending command is ignored") + func resolveWhenIdle() { + let tracker = RemoteCommandTracker(timeout: 60) + tracker.resolve(key: "K", success: true, message: "Done") + #expect(tracker.states["K"] == nil) + } + + @Test("a pending command times out") + func timeout() async throws { + let tracker = RemoteCommandTracker(timeout: 0.05) + tracker.begin(key: "K") + try await Task.sleep(for: .milliseconds(400)) + #expect(tracker.states["K"] == .done(success: false, message: RemoteCommandTracker.timeoutMessage)) + } + + @Test("acks for unknown keys are ignored") + func unknownKey() { + let tracker = RemoteCommandTracker(timeout: 60) + #expect(tracker.handleNotification(userInfo: ["command_type": "carbs_delete", "command_status": "success", "sync_identifier": "nope"]) == false) + #expect(tracker.handleNotification(userInfo: ["command_type": "delete_meal", "command_status": "success", "command_id": "nope"]) == false) + #expect(tracker.states.isEmpty) + } + + @Test("TRC ack resolves the meal registered under its command id, once") + func trioAdapter() { + let tracker = RemoteCommandTracker(timeout: 60) + tracker.trioAcks.register(commandID: "CMD", key: "MEAL") + tracker.begin(key: "MEAL") + let ack: [AnyHashable: Any] = [ + "command_status": "failed", + "command_type": "delete_meal", + "command_id": "CMD", + "meal_id": "MEAL", + "result": "not_found", + "aps": ["alert": ["title": "Command Failed", "body": "Meal not found"]], + ] + #expect(tracker.handleNotification(userInfo: ack) == true) + #expect(tracker.states["MEAL"] == .done(success: false, message: "Meal not found")) + + tracker.begin(key: "MEAL") + #expect(tracker.handleNotification(userInfo: ack) == false) + #expect(tracker.isBusy(key: "MEAL")) + } + + @Test("TRC ack without an alert body falls back to the result message") + func trioDefaultMessage() { + let adapter = TRCMealAckAdapter() + adapter.register(commandID: "CMD", key: "MEAL") + let resolution = adapter.resolution(for: ["command_status": "success", "command_id": "CMD", "result": "updated"]) + #expect(resolution == RemoteCommandTracker.Resolution(key: "MEAL", success: true, message: "Meal updated.")) + } + + @Test("Loop ack resolves by sync identifier") + func loopAdapter() { + let tracker = RemoteCommandTracker(timeout: 60) + tracker.begin(key: "SYNC-1") + #expect(tracker.handleNotification(userInfo: ["command_type": "carbs_edit", "command_status": "success", "sync_identifier": "SYNC-1"]) == true) + #expect(tracker.states["SYNC-1"] == .done(success: true, message: "Carb entry updated.")) + #expect(LoopCarbAckAdapter.resolution(for: ["command_type": "meal", "command_status": "success", "sync_identifier": "SYNC-1"]) == nil) + #expect(LoopCarbAckAdapter.resolution(for: ["command_type": "carbs_delete", "command_status": "failed"]) == nil) + } +} diff --git a/Tests/TRCCommandAckTests.swift b/Tests/TRCCommandAckTests.swift deleted file mode 100644 index 0a994fb3a..000000000 --- a/Tests/TRCCommandAckTests.swift +++ /dev/null @@ -1,81 +0,0 @@ -// LoopFollow -// TRCCommandAckTests.swift - -import Foundation -@testable import LoopFollow -import Testing - -struct TRCCommandAckTests { - private func ack(status: String = "success", result: String? = "deleted", commandID: String? = "CMD", mealID: String? = "MEAL", body: String? = "Meal deleted") -> [AnyHashable: Any] { - var info: [AnyHashable: Any] = [ - "command_status": status, - "command_type": "delete_meal", - "timestamp": 1_700_000_000.0, - ] - if let result { info["result"] = result } - if let commandID { info["command_id"] = commandID } - if let mealID { info["meal_id"] = mealID } - if let body { info["aps"] = ["alert": ["title": "Command Successful", "body": body], "sound": "default"] } - return info - } - - @Test("parses Trio's return notification") - func parsesAck() { - let parsed = TRCCommandAck(userInfo: ack()) - #expect(parsed?.status == "success") - #expect(parsed?.isSuccess == true) - #expect(parsed?.commandType == "delete_meal") - #expect(parsed?.result == "deleted") - #expect(parsed?.commandID == "CMD") - #expect(parsed?.mealID == "MEAL") - #expect(parsed?.message == "Meal deleted") - } - - @Test("requires command_status, tolerates legacy acks without ids") - func fallbacks() { - #expect(TRCCommandAck(userInfo: ["command_type": "meal"]) == nil) - let legacy = TRCCommandAck(userInfo: ack(result: nil, commandID: nil, mealID: nil, body: nil)) - #expect(legacy != nil) - #expect(legacy?.commandID == nil) - #expect(legacy?.message == nil) - } - - @Test("unknown command ids are ignored") - func ignoresUnknown() { - #expect(TRCCommandTracker.shared.handleNotification(userInfo: ack(commandID: "nope")) == false) - } - - @Test("a matching ack completes the pending command") - func completesPending() async throws { - let tracker = TRCCommandTracker.shared - let command = tracker.register(type: .deleteMeal, mealID: "MEAL-1") - try await Task.sleep(for: .milliseconds(50)) - #expect(tracker.pendingCommand(forMealID: "MEAL-1")?.id == command.id) - - #expect(tracker.handleNotification(userInfo: ack(commandID: command.id, mealID: "MEAL-1")) == true) - try await Task.sleep(for: .milliseconds(50)) - #expect(tracker.pendingCommand(forMealID: "MEAL-1") == nil) - let result = tracker.lastResult(forMealID: "MEAL-1") - #expect(result?.outcome == .deleted) - #expect(result?.isSuccess == true) - } - - @Test("result values map to outcomes") - func outcomes() async throws { - let tracker = TRCCommandTracker.shared - let cases: [(String?, String, TRCCommandTracker.Outcome)] = [ - ("updated", "success", .updated(newMealID: "NEW")), - ("not_found", "failed", .notFound), - ("rejected", "failed", .rejected), - (nil, "failed", .failed), - ] - for (index, testCase) in cases.enumerated() { - let mealID = "MEAL-\(index)" - let command = tracker.register(type: .editMeal, mealID: mealID) - try await Task.sleep(for: .milliseconds(50)) - tracker.handleNotification(userInfo: ack(status: testCase.1, result: testCase.0, commandID: command.id, mealID: "NEW")) - try await Task.sleep(for: .milliseconds(50)) - #expect(tracker.lastResult(forMealID: mealID)?.outcome == testCase.2) - } - } -} From a9e424bd274517e43ea12b4b589d07cebabb58be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 21 Sep 2026 14:24:28 +0200 Subject: [PATCH 09/84] Edit FPU child rows through their root meal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An FPU child row offers Edit only when its root meal is loaded; the edit sheet is then prefilled with the root's macros and time and addresses the root's id. Delete stays available for every child and targets the whole meal. The view model builds the fpuID β†’ root lookup once per regroup. --- LoopFollow/Treatments/TreatmentsView.swift | 34 +++++++++++++++------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift index 31db334b2..1eb2c5428 100644 --- a/LoopFollow/Treatments/TreatmentsView.swift +++ b/LoopFollow/Treatments/TreatmentsView.swift @@ -406,9 +406,15 @@ struct TreatmentDetailView: View { @State private var showEditSheet = false @State private var showDeleteConfirmation = false + /// The Trio meal remote commands act on: the root meal for an FPU child whose root is loaded. + private var commandMeal: TrioMealTreatment? { + guard let meal = treatment.trioMeal else { return nil } + return meal.isFPUChild ? (rootMeal?.trioMeal ?? meal) : meal + } + /// Key under which the tracker follows this treatment's remote commands. private var commandKey: String? { - treatment.trioMeal?.mealID.uuidString ?? treatment.loopCarb?.syncIdentifier + commandMeal?.mealID.uuidString ?? treatment.loopCarb?.syncIdentifier } private var commandState: RemoteCommandTracker.State? { @@ -593,7 +599,7 @@ struct TreatmentDetailView: View { viewModel.loadDetails(for: treatment) } .sheet(isPresented: $showEditSheet) { - if let meal = treatment.trioMeal { + if let meal = commandMeal { TRCMealEditView(meal: meal) } else if let carb = treatment.loopCarb { LoopCarbEditSheet(carb: carb) @@ -622,7 +628,7 @@ struct TreatmentDetailView: View { } private func sendDelete() { - if let meal = treatment.trioMeal { + if let meal = commandMeal { commandTracker.sendTrioMealDelete(mealID: meal.mealID.uuidString) { _, _ in } } else if let carb = treatment.loopCarb { commandTracker.sendLoopCarbDelete(carb: carb) { _, _ in } @@ -712,14 +718,17 @@ struct TreatmentDetailView: View { @ViewBuilder private func trioRemoteActionsSection(_ meal: TrioMealTreatment) -> some View { - let busy = commandTracker.isBusy(key: meal.mealID.uuidString) - let withinWindow = meal.isWithinEditWindow() + let target = commandMeal ?? meal + let busy = commandTracker.isBusy(key: target.mealID.uuidString) + let withinWindow = target.isWithinEditWindow() Section(header: Text("Remote actions"), footer: remoteActionsFooter(withinWindow: withinWindow)) { commandStatusRow(pendingText: "Sent, awaiting confirmation from Trio…") - Button("Edit meal") { showEditSheet = true } - .disabled(!withinWindow || busy) + if !target.isFPUChild { + Button("Edit meal") { showEditSheet = true } + .disabled(!withinWindow || busy) + } Button("Delete meal", role: .destructive) { showDeleteConfirmation = true } .disabled(!withinWindow || busy) } @@ -1150,6 +1159,7 @@ class TreatmentsViewModel: ObservableObject { @Published var hasAutomaticEntries = false private var allTreatments: [Treatment] = [] + private var rootMealsByFPUID: [UUID: Treatment] = [:] private var processedNightscoutIds = Set() // Track which NS entries we've already processed private var oldestFetchedDate: Date? // Track the oldest treatment date we've fetched private let pageSize = 100 @@ -1193,10 +1203,7 @@ class TreatmentsViewModel: ObservableObject { /// The root meal for an FPU child, when the Trio build publishes `fpuID` and the root is loaded. func rootMeal(forFPUChild treatment: Treatment) -> Treatment? { guard let child = treatment.trioMeal, child.isFPUChild, let fpuID = child.fpuID else { return nil } - return allTreatments.first { candidate in - guard let meal = candidate.trioMeal, !meal.isFPUChild else { return false } - return meal.fpuID == fpuID - } + return rootMealsByFPUID[fpuID] } func refreshTreatments() { @@ -1545,6 +1552,7 @@ class TreatmentsViewModel: ObservableObject { private func regroupTreatments() { var grouped: [String: [Treatment]] = [:] + var roots: [UUID: Treatment] = [:] for treatment in allTreatments { let key = treatment.hourKey @@ -1552,7 +1560,11 @@ class TreatmentsViewModel: ObservableObject { grouped[key] = [] } grouped[key]?.append(treatment) + if let meal = treatment.trioMeal, !meal.isFPUChild, let fpuID = meal.fpuID { + roots[fpuID] = treatment + } } + rootMealsByFPUID = roots // Sort treatments within each hour for key in grouped.keys { From e64cb89c21be80788be01cc67a3dd8a200c65230 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 21 Sep 2026 14:25:21 +0200 Subject: [PATCH 10/84] Classify Trio FPU children by fpuID only A Nightscout carb document from Trio is an FPU child exactly when its fpuID equals its id; a document without fpuID is a root meal. --- LoopFollow/Treatments/TreatmentsView.swift | 5 +-- LoopFollow/Treatments/TrioMealTreatment.swift | 26 ++------------ Tests/TrioMealTreatmentTests.swift | 35 ++++++------------- 3 files changed, 15 insertions(+), 51 deletions(-) diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift index 1eb2c5428..ff7566d65 100644 --- a/LoopFollow/Treatments/TreatmentsView.swift +++ b/LoopFollow/Treatments/TreatmentsView.swift @@ -1340,7 +1340,6 @@ class TreatmentsViewModel: ObservableObject { var detectedSMB = false var detectedAutomatic = false guard let mainVC = getMainViewController() else { return ([], false, false) } - let siblingIDCounts = TrioMealTreatment.siblingIDCounts(in: entries) for entry in entries { guard let eventType = entry["eventType"] as? String, @@ -1363,9 +1362,7 @@ class TreatmentsViewModel: ObservableObject { switch eventType { case "Carb Correction", "Meal Bolus": - let trioMeal = eventType == "Carb Correction" - ? TrioMealTreatment(nightscoutEntry: entry, date: timestamp, siblingIDCount: siblingIDCounts[entry["id"] as? String ?? ""] ?? 0) - : nil + let trioMeal = eventType == "Carb Correction" ? TrioMealTreatment(nightscoutEntry: entry, date: timestamp) : nil let loopCarb = trioMeal == nil ? LoopCarbTreatment(nightscoutEntry: entry, date: timestamp) : nil let carbs = entry["carbs"] as? Double ?? 0 if carbs > 0 || trioMeal != nil { diff --git a/LoopFollow/Treatments/TrioMealTreatment.swift b/LoopFollow/Treatments/TrioMealTreatment.swift index 19690d21f..606649a61 100644 --- a/LoopFollow/Treatments/TrioMealTreatment.swift +++ b/LoopFollow/Treatments/TrioMealTreatment.swift @@ -7,7 +7,7 @@ import Foundation /// /// Nightscout's `_id` identifies the document; `id` is Trio's meal UUID. FPU children are /// separate documents whose `id` equals the root's `fpuID`, so either value works as a handle -/// for Trio's remote edit/delete commands. +/// for Trio's remote edit/delete commands. A document without `fpuID` is a root. struct TrioMealTreatment: Equatable { static let pastEditWindow: TimeInterval = 24 * 3600 static let futureEditWindow: TimeInterval = 12 * 3600 @@ -23,9 +23,7 @@ struct TrioMealTreatment: Equatable { let note: String? let isFPUChild: Bool - /// `siblingIDCount` is how many Trio carb documents in the same page share this `id`. - /// Builds without `fpuID` in the upload are classified by that count: children share an id, roots are unique. - init?(nightscoutEntry entry: [String: AnyObject], date: TimeInterval, siblingIDCount: Int) { + init?(nightscoutEntry entry: [String: AnyObject], date: TimeInterval) { guard entry["enteredBy"] as? String == "Trio", entry["eventType"] as? String == "Carb Correction", let rawID = entry["id"] as? String, @@ -40,12 +38,6 @@ struct TrioMealTreatment: Equatable { guard carbs > 0 || fat > 0 || protein > 0 else { return nil } let fpuID = (entry["fpuID"] as? String).flatMap(UUID.init(uuidString:)) - let isFPUChild: Bool - if let fpuID { - isFPUChild = fpuID == mealID - } else { - isFPUChild = siblingIDCount > 1 - } let rawNote = (entry["notes"] as? String) ?? (entry["foodType"] as? String) let trimmedNote = rawNote?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -58,7 +50,7 @@ struct TrioMealTreatment: Equatable { self.fat = fat self.protein = protein note = trimmedNote?.isEmpty == false ? trimmedNote : nil - self.isFPUChild = isFPUChild + isFPUChild = fpuID == mealID } var carbsForEdit: Int { Int(carbs.rounded()) } @@ -68,18 +60,6 @@ struct TrioMealTreatment: Equatable { return age <= Self.pastEditWindow && age >= -Self.futureEditWindow } - static func siblingIDCounts(in entries: [[String: AnyObject]]) -> [String: Int] { - var counts: [String: Int] = [:] - for entry in entries { - guard entry["enteredBy"] as? String == "Trio", - entry["eventType"] as? String == "Carb Correction", - let id = entry["id"] as? String - else { continue } - counts[id, default: 0] += 1 - } - return counts - } - /// Remote edit/delete is offered only when Trio Remote Control targets a Trio that lists both commands. static func remoteActionsAvailable(remoteType: RemoteType, device: String, remoteCommands: [String]) -> Bool { remoteType == .trc && device == "Trio" && requiredRemoteCommands.isSubset(of: remoteCommands) diff --git a/Tests/TrioMealTreatmentTests.swift b/Tests/TrioMealTreatmentTests.swift index fac5bfc11..3145b4efb 100644 --- a/Tests/TrioMealTreatmentTests.swift +++ b/Tests/TrioMealTreatmentTests.swift @@ -28,7 +28,7 @@ struct TrioMealTreatmentTests { @Test("root with fpuID is not a child") func root() { - let meal = TrioMealTreatment(nightscoutEntry: entry(id: rootID, fpuID: familyID, fat: 20, protein: 15, notes: " πŸ“‘ "), date: 0, siblingIDCount: 1) + let meal = TrioMealTreatment(nightscoutEntry: entry(id: rootID, fpuID: familyID, fat: 20, protein: 15, notes: " πŸ“‘ "), date: 0) #expect(meal?.isFPUChild == false) #expect(meal?.mealID.uuidString == rootID) #expect(meal?.fpuID?.uuidString == familyID) @@ -40,43 +40,30 @@ struct TrioMealTreatmentTests { @Test("child carries the family id as both id and fpuID") func child() { - let meal = TrioMealTreatment(nightscoutEntry: entry(id: familyID, fpuID: familyID, carbs: 12), date: 0, siblingIDCount: 3) + let meal = TrioMealTreatment(nightscoutEntry: entry(id: familyID, fpuID: familyID, carbs: 12), date: 0) #expect(meal?.isFPUChild == true) #expect(meal?.mealID.uuidString == familyID) } - @Test("without fpuID, shared ids mean children and unique ids mean roots") - func legacy() { - #expect(TrioMealTreatment(nightscoutEntry: entry(id: familyID, carbs: 12), date: 0, siblingIDCount: 3)?.isFPUChild == true) - #expect(TrioMealTreatment(nightscoutEntry: entry(id: rootID), date: 0, siblingIDCount: 1)?.isFPUChild == false) + @Test("without fpuID the document is a root") + func missingFPUID() { + #expect(TrioMealTreatment(nightscoutEntry: entry(id: rootID), date: 0)?.isFPUChild == false) + #expect(TrioMealTreatment(nightscoutEntry: entry(id: rootID), date: 0)?.fpuID == nil) } @Test("rejects non-Trio, non-carb, non-UUID and empty entries") func rejects() { - #expect(TrioMealTreatment(nightscoutEntry: entry(id: rootID, enteredBy: "loop://phone"), date: 0, siblingIDCount: 1) == nil) - #expect(TrioMealTreatment(nightscoutEntry: entry(id: rootID, eventType: "Meal Bolus"), date: 0, siblingIDCount: 1) == nil) - #expect(TrioMealTreatment(nightscoutEntry: entry(id: "not-a-uuid"), date: 0, siblingIDCount: 1) == nil) - #expect(TrioMealTreatment(nightscoutEntry: entry(id: rootID, carbs: 0), date: 0, siblingIDCount: 1) == nil) - } - - @Test("sibling counts only count Trio carb documents") - func siblingCounts() { - let counts = TrioMealTreatment.siblingIDCounts(in: [ - entry(id: familyID, carbs: 10), - entry(id: familyID, carbs: 10), - entry(id: rootID), - entry(id: rootID, enteredBy: "mock"), - entry(id: rootID, eventType: "Note"), - ]) - #expect(counts[familyID] == 2) - #expect(counts[rootID] == 1) + #expect(TrioMealTreatment(nightscoutEntry: entry(id: rootID, enteredBy: "loop://phone"), date: 0) == nil) + #expect(TrioMealTreatment(nightscoutEntry: entry(id: rootID, eventType: "Meal Bolus"), date: 0) == nil) + #expect(TrioMealTreatment(nightscoutEntry: entry(id: "not-a-uuid"), date: 0) == nil) + #expect(TrioMealTreatment(nightscoutEntry: entry(id: rootID, carbs: 0), date: 0) == nil) } @Test("edit window is 24 h back and 12 h ahead") func window() { let now = Date(timeIntervalSince1970: 1_700_000_000) func meal(offsetHours: Double) -> TrioMealTreatment? { - TrioMealTreatment(nightscoutEntry: entry(id: rootID), date: now.timeIntervalSince1970 + offsetHours * 3600, siblingIDCount: 1) + TrioMealTreatment(nightscoutEntry: entry(id: rootID), date: now.timeIntervalSince1970 + offsetHours * 3600) } #expect(meal(offsetHours: -23)?.isWithinEditWindow(now: now) == true) #expect(meal(offsetHours: -25)?.isWithinEditWindow(now: now) == false) From dbff62179357c8e35f7063ea1d2f758e677a2869 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 21 Sep 2026 14:27:51 +0200 Subject: [PATCH 11/84] Build every Loop command payload from one header helper Carbs, bolus, carb delete and carb edit share commandHeader for the otp, remote-address, notes, entered-by, sent-at, expiration and alert fields. The builders are static and take the send time, so tests pin the exact key set and values of each payload. A carb edit always carries carbs-edit-entry, carbs-edit-absorption-time and carbs-edit-start-time; carbs-edit-food-type is sent only when set. --- .../Remote/LoopAPNS/LoopAPNSService.swift | 259 +++++++----------- .../Remote/LoopAPNS/LoopCarbEditSheet.swift | 4 +- LoopFollow/Remote/RemoteCommandTracker.swift | 2 +- Tests/LoopAPNSCommandPayloadTests.swift | 69 +++++ 4 files changed, 169 insertions(+), 165 deletions(-) create mode 100644 Tests/LoopAPNSCommandPayloadTests.swift diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index 5203c4665..142b31bac 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -124,10 +124,11 @@ class LoopAPNSService { private static let returnNotificationRequiredMessage = "Editing or deleting a carb entry needs LoopFollow's own APNS credentials so Loop can confirm the result. Configure them in App Settings β†’ APN." - /// Fields every Loop remote command carries, plus the return-notification block encrypted with the OTP. - private func remoteCommandPayload(otp: String, alert: String) -> [String: Any] { - let now = Date() - var payload: [String: Any] = [ + // MARK: - Command payloads (matching Nightscout's loop.js format) + + /// Fields every Loop remote command carries. + static func commandHeader(otp: String, alert: String, now: Date) -> [String: Any] { + [ "otp": otp, "remote-address": "LoopFollow", "notes": "Sent via LoopFollow APNS", @@ -136,22 +137,67 @@ class LoopAPNSService { "expiration": formatDateForAPNS(now.addingTimeInterval(5 * 60)), "alert": alert, ] - if let returnInfo = createReturnNotificationInfo(), - let encryptedReturnInfo = encryptReturnNotificationInfo(returnInfo: returnInfo, otpCode: otp) - { - payload["encrypted_return_notification"] = encryptedReturnInfo + } + + static func carbsCommandPayload(_ payload: LoopAPNSPayload, now: Date = Date()) -> [String: Any] { + let carbsAmount = payload.carbsAmount ?? 0.0 + let absorptionTime = payload.absorptionTime ?? 3.0 + var fields = commandHeader( + otp: payload.otp, + alert: "Remote Carbs Entry: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTime)) hours", + now: now + ) + fields["carbs-entry"] = carbsAmount + fields["absorption-time"] = absorptionTime + fields["start-time"] = formatDateForAPNS(payload.consumedDate ?? now) + return fields + } + + static func bolusCommandPayload(_ payload: LoopAPNSPayload, now: Date = Date()) -> [String: Any] { + let bolusAmount = payload.bolusAmount ?? 0.0 + var fields = commandHeader(otp: payload.otp, alert: "Remote Bolus Entry: \(String(format: "%.2f", bolusAmount)) U", now: now) + fields["bolus-entry"] = bolusAmount + return fields + } + + static func carbsDeleteCommandPayload(syncIdentifier: String, otp: String, now: Date = Date()) -> [String: Any] { + var fields = commandHeader(otp: otp, alert: "Remote Carbs Delete", now: now) + fields["carbs-delete"] = syncIdentifier + return fields + } + + /// Keys are prefixed so an unpatched Loop rejects the command. Amount, absorption time and start time are always sent; food type only when set. + static func carbsEditCommandPayload( + syncIdentifier: String, + carbsAmount: Double, + absorptionTimeHours: Double, + foodType: String?, + consumedDate: Date, + otp: String, + now: Date = Date() + ) -> [String: Any] { + var fields = commandHeader( + otp: otp, + alert: "Remote Carbs Edit: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTimeHours)) hours", + now: now + ) + fields["carbs-edit"] = syncIdentifier + fields["carbs-edit-entry"] = carbsAmount + fields["carbs-edit-absorption-time"] = absorptionTimeHours + fields["carbs-edit-start-time"] = formatDateForAPNS(consumedDate) + if let foodType, !foodType.isEmpty { + fields["carbs-edit-food-type"] = foodType } - return payload + return fields } - /// Carb delete/edit are confirmed only through Loop's return push, so the encrypted return block is mandatory. - private func confirmedCommandPayload(otp: String, alert: String) -> [String: Any]? { - let payload = remoteCommandPayload(otp: otp, alert: alert) - guard payload["encrypted_return_notification"] != nil else { - LogManager.shared.log(category: .apns, message: "Carb command not sent: return notification info unavailable") + /// The return-notification block encrypted with the OTP, when LoopFollow's own credentials are configured. + private func encryptedReturnNotification(otp: String) -> String? { + guard let returnInfo = createReturnNotificationInfo() else { + LogManager.shared.log(category: .apns, message: "Return notification info unavailable; Loop cannot confirm this command") return nil } - return payload + return encryptReturnNotificationInfo(returnInfo: returnInfo, otpCode: otp) } private func sendRemoteCommand(_ payload: [String: Any], completion: @escaping (Bool, String?) -> Void) { @@ -173,45 +219,43 @@ class LoopAPNSService { ) } - /// Deletes the Loop carb entry with this `syncIdentifier`. Requires the Loop remote carb edit patch. - func sendCarbsDelete(syncIdentifier: String, otp: String, completion: @escaping (Bool, String?) -> Void) { - guard var payload = confirmedCommandPayload(otp: otp, alert: "Remote Carbs Delete") else { + /// Sends a command whose only confirmation is Loop's return push, so the encrypted return block is mandatory. + private func sendConfirmedCommand(_ payload: [String: Any], otp: String, completion: @escaping (Bool, String?) -> Void) { + guard let encryptedReturnInfo = encryptedReturnNotification(otp: otp) else { completion(false, Self.returnNotificationRequiredMessage) return } - payload["carbs-delete"] = syncIdentifier - LogManager.shared.log(category: .apns, message: "Sending carbs delete for syncIdentifier=\(LogRedactor.tail(syncIdentifier))") + var payload = payload + payload["encrypted_return_notification"] = encryptedReturnInfo sendRemoteCommand(payload, completion: completion) } - /// Replaces the Loop carb entry with this `syncIdentifier`. Keys are prefixed so an unpatched Loop rejects the command. + /// Deletes the Loop carb entry with this `syncIdentifier`. Requires the Loop remote carb edit patch. + func sendCarbsDelete(syncIdentifier: String, otp: String, completion: @escaping (Bool, String?) -> Void) { + LogManager.shared.log(category: .apns, message: "Sending carbs delete for syncIdentifier=\(LogRedactor.tail(syncIdentifier))") + sendConfirmedCommand(Self.carbsDeleteCommandPayload(syncIdentifier: syncIdentifier, otp: otp), otp: otp, completion: completion) + } + + /// Replaces the Loop carb entry with this `syncIdentifier`. Requires the Loop remote carb edit patch. func sendCarbsEdit( syncIdentifier: String, carbsAmount: Double, absorptionTimeHours: Double, foodType: String?, - consumedDate: Date?, + consumedDate: Date, otp: String, completion: @escaping (Bool, String?) -> Void ) { - guard var payload = confirmedCommandPayload( - otp: otp, - alert: "Remote Carbs Edit: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTimeHours)) hours" - ) else { - completion(false, Self.returnNotificationRequiredMessage) - return - } - payload["carbs-edit"] = syncIdentifier - payload["carbs-edit-entry"] = carbsAmount - payload["carbs-edit-absorption-time"] = absorptionTimeHours - if let foodType, !foodType.isEmpty { - payload["carbs-edit-food-type"] = foodType - } - if let consumedDate { - payload["carbs-edit-start-time"] = formatDateForAPNS(consumedDate) - } LogManager.shared.log(category: .apns, message: "Sending carbs edit for syncIdentifier=\(LogRedactor.tail(syncIdentifier)): \(String(format: "%.1f", carbsAmount))g, absorption \(String(format: "%.1f", absorptionTimeHours))h") - sendRemoteCommand(payload, completion: completion) + let payload = Self.carbsEditCommandPayload( + syncIdentifier: syncIdentifier, + carbsAmount: carbsAmount, + absorptionTimeHours: absorptionTimeHours, + foodType: foodType, + consumedDate: consumedDate, + otp: otp + ) + sendConfirmedCommand(payload, otp: otp, completion: completion) } /// Sends carbs via APNS push notification @@ -219,68 +263,12 @@ class LoopAPNSService { /// - payload: The carbs payload to send /// - completion: Completion handler with success status and error message func sendCarbsViaAPNS(payload: LoopAPNSPayload, completion: @escaping (Bool, String?) -> Void) { - guard validateSetup() else { - let errorMessage = "Loop APNS Configuration not valid" - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - return + var finalPayload = Self.carbsCommandPayload(payload) + if let encryptedReturnInfo = encryptedReturnNotification(otp: payload.otp) { + finalPayload["encrypted_return_notification"] = encryptedReturnInfo } - - let deviceToken = Storage.shared.deviceToken.value - let bundleIdentifier = Storage.shared.bundleId.value - let creds = effectiveCredentials() - - // Create APNS notification payload (matching Loop's expected format) - let now = Date() - let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now - - // Create the complete notification payload (matching Nightscout's exact format) - // Based on Nightscout's loop.js implementation - let carbsAmount = payload.carbsAmount ?? 0.0 - let absorptionTime = payload.absorptionTime ?? 3.0 - let startTime = payload.consumedDate ?? now - var finalPayload = [ - "carbs-entry": carbsAmount, - "absorption-time": absorptionTime, - "otp": String(payload.otp), - "remote-address": "LoopFollow", - "notes": "Sent via LoopFollow APNS", - "entered-by": "LoopFollow", - "sent-at": formatDateForAPNS(now), - "expiration": formatDateForAPNS(expiration), - "start-time": formatDateForAPNS(startTime), - "alert": "Remote Carbs Entry: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTime)) hours", - ] as [String: Any] - - // Encrypt and include return notification info using OTP - if let returnInfo = createReturnNotificationInfo() { - LogManager.shared.log(category: .apns, message: "Created return notification info for carbs - deviceToken: \(LogRedactor.head(returnInfo.deviceToken)), bundleId: \(LogRedactor.bundleId(returnInfo.bundleId))") - if let encryptedReturnInfo = encryptReturnNotificationInfo(returnInfo: returnInfo, otpCode: String(payload.otp)) { - finalPayload["encrypted_return_notification"] = encryptedReturnInfo - LogManager.shared.log(category: .apns, message: "Added encrypted_return_notification to carbs payload, length: \(encryptedReturnInfo.count)") - } else { - LogManager.shared.log(category: .apns, message: "Failed to encrypt return notification info for carbs command") - } - } else { - LogManager.shared.log(category: .apns, message: "Failed to create return notification info for carbs command") - } - - // Log the exact carbs amount for debugging precision issues - LogManager.shared.log(category: .apns, message: "Carbs amount - Raw: \(payload.carbsAmount ?? 0.0), Formatted: \(String(format: "%.1f", carbsAmount)), JSON: \(carbsAmount)") - LogManager.shared.log(category: .apns, message: "Absorption time - Raw: \(payload.absorptionTime ?? 3.0), Formatted: \(String(format: "%.1f", absorptionTime)), JSON: \(absorptionTime)") - - // Log carbs entry attempt - LogManager.shared.log(category: .apns, message: "Sending carbs: \(String(format: "%.1f", carbsAmount))g, absorption: \(String(format: "%.1f", absorptionTime))h") - - sendAPNSNotification( - deviceToken: deviceToken, - bundleIdentifier: bundleIdentifier, - keyId: creds.keyId, - apnsKey: creds.apnsKey, - teamId: creds.teamId, - payload: finalPayload, - completion: completion - ) + LogManager.shared.log(category: .apns, message: "Sending carbs: \(String(format: "%.1f", payload.carbsAmount ?? 0.0))g, absorption: \(String(format: "%.1f", payload.absorptionTime ?? 3.0))h") + sendRemoteCommand(finalPayload, completion: completion) } /// Sends bolus via APNS push notification @@ -288,63 +276,12 @@ class LoopAPNSService { /// - payload: The bolus payload to send /// - completion: Completion handler with success status and error message func sendBolusViaAPNS(payload: LoopAPNSPayload, completion: @escaping (Bool, String?) -> Void) { - guard validateSetup() else { - let errorMessage = "Loop APNS Configuration not valid" - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - return + var finalPayload = Self.bolusCommandPayload(payload) + if let encryptedReturnInfo = encryptedReturnNotification(otp: payload.otp) { + finalPayload["encrypted_return_notification"] = encryptedReturnInfo } - - let deviceToken = Storage.shared.deviceToken.value - let bundleIdentifier = Storage.shared.bundleId.value - let creds = effectiveCredentials() - - // Create APNS notification payload (matching Loop's expected format) - let now = Date() - let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now - - // Create the complete notification payload (matching Nightscout's exact format) - // Based on Nightscout's loop.js implementation - let bolusAmount = payload.bolusAmount ?? 0.0 - var finalPayload = [ - "bolus-entry": bolusAmount, - "otp": String(payload.otp), - "remote-address": "LoopFollow", - "notes": "Sent via LoopFollow APNS", - "entered-by": "LoopFollow", - "sent-at": formatDateForAPNS(now), - "expiration": formatDateForAPNS(expiration), - "alert": "Remote Bolus Entry: \(String(format: "%.2f", bolusAmount)) U", - ] as [String: Any] - - // Encrypt and include return notification info using OTP - if let returnInfo = createReturnNotificationInfo() { - LogManager.shared.log(category: .apns, message: "Created return notification info for carbs - deviceToken: \(LogRedactor.head(returnInfo.deviceToken)), bundleId: \(LogRedactor.bundleId(returnInfo.bundleId))") - if let encryptedReturnInfo = encryptReturnNotificationInfo(returnInfo: returnInfo, otpCode: String(payload.otp)) { - finalPayload["encrypted_return_notification"] = encryptedReturnInfo - LogManager.shared.log(category: .apns, message: "Added encrypted_return_notification to carbs payload, length: \(encryptedReturnInfo.count)") - } else { - LogManager.shared.log(category: .apns, message: "Failed to encrypt return notification info for carbs command") - } - } else { - LogManager.shared.log(category: .apns, message: "Failed to create return notification info for carbs command") - } - - // Log the exact bolus amount for debugging precision issues - LogManager.shared.log(category: .apns, message: "Bolus amount - Raw: \(payload.bolusAmount ?? 0.0), Formatted: \(String(format: "%.2f", bolusAmount)), JSON: \(bolusAmount)") - - // Log bolus entry attempt - LogManager.shared.log(category: .apns, message: "Sending bolus: \(String(format: "%.2f", bolusAmount))U") - - sendAPNSNotification( - deviceToken: deviceToken, - bundleIdentifier: bundleIdentifier, - keyId: creds.keyId, - apnsKey: creds.apnsKey, - teamId: creds.teamId, - payload: finalPayload, - completion: completion - ) + LogManager.shared.log(category: .apns, message: "Sending bolus: \(String(format: "%.2f", payload.bolusAmount ?? 0.0))U") + sendRemoteCommand(finalPayload, completion: completion) } /// Validates APNS credentials similar to PushNotificationManager @@ -724,7 +661,7 @@ class LoopAPNSService { /// Creates a properly formatted ISO8601 date string with milliseconds (matching Nightscout's format) /// - Parameter date: The date to format /// - Returns: Formatted date string like "2022-12-24T21:34:02.090Z" - private func formatDateForAPNS(_ date: Date) -> String { + private static func formatDateForAPNS(_ date: Date) -> String { let dateFormatter = ISO8601DateFormatter() dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return dateFormatter.string(from: date) @@ -769,8 +706,8 @@ class LoopAPNSService { "override-name": presetName, "remote-address": "LoopFollow", "entered-by": "LoopFollow", - "sent-at": formatDateForAPNS(now), - "expiration": formatDateForAPNS(expiration), + "sent-at": Self.formatDateForAPNS(now), + "expiration": Self.formatDateForAPNS(expiration), "alert": alertText, ] @@ -829,8 +766,8 @@ class LoopAPNSService { "cancel-temporary-override": "true", "remote-address": "LoopFollow", "entered-by": "LoopFollow", - "sent-at": formatDateForAPNS(now), - "expiration": formatDateForAPNS(expiration), + "sent-at": Self.formatDateForAPNS(now), + "expiration": Self.formatDateForAPNS(expiration), "alert": "Cancel Temporary Override", ] diff --git a/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift b/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift index cceeff59e..6b4f84e3a 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift @@ -173,14 +173,12 @@ struct LoopCarbEditSheet: View { private func send() { isSending = true - let originalDate = Date(timeIntervalSince1970: carb.date) - let dateChanged = abs(consumedDate.timeIntervalSince(originalDate)) >= 60 RemoteCommandTracker.shared.sendLoopCarbEdit( carb: carb, carbsAmount: carbsAmount.doubleValue(for: .gram()), absorptionHours: absorptionTimeValue, foodType: foodType.isEmpty ? nil : foodType, - consumedDate: dateChanged ? consumedDate : nil + consumedDate: consumedDate ) { success, error in DispatchQueue.main.async { isSending = false diff --git a/LoopFollow/Remote/RemoteCommandTracker.swift b/LoopFollow/Remote/RemoteCommandTracker.swift index 1f7b4a573..1870812ec 100644 --- a/LoopFollow/Remote/RemoteCommandTracker.swift +++ b/LoopFollow/Remote/RemoteCommandTracker.swift @@ -207,7 +207,7 @@ extension RemoteCommandTracker { } } - func sendLoopCarbEdit(carb: LoopCarbTreatment, carbsAmount: Double, absorptionHours: Double, foodType: String?, consumedDate: Date?, completion: @escaping (Bool, String?) -> Void) { + func sendLoopCarbEdit(carb: LoopCarbTreatment, carbsAmount: Double, absorptionHours: Double, foodType: String?, consumedDate: Date, completion: @escaping (Bool, String?) -> Void) { let key = carb.syncIdentifier begin(key: key) guard let otp = loopOTP(key: key, completion: completion) else { return } diff --git a/Tests/LoopAPNSCommandPayloadTests.swift b/Tests/LoopAPNSCommandPayloadTests.swift new file mode 100644 index 000000000..aabd53185 --- /dev/null +++ b/Tests/LoopAPNSCommandPayloadTests.swift @@ -0,0 +1,69 @@ +// LoopFollow +// LoopAPNSCommandPayloadTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct LoopAPNSCommandPayloadTests { + private let now = Date(timeIntervalSince1970: 1_700_000_000) + private let headerKeys: Set = ["otp", "remote-address", "notes", "entered-by", "sent-at", "expiration", "alert"] + + @Test("carbs payload carries the loop.js keys") + func carbsKeys() { + let payload = LoopAPNSPayload(type: .carbs, carbsAmount: 30, absorptionTime: 3, consumedDate: now.addingTimeInterval(-600), otp: "123456") + let json = LoopAPNSService.carbsCommandPayload(payload, now: now) + #expect(Set(json.keys) == headerKeys.union(["carbs-entry", "absorption-time", "start-time"])) + #expect(json["carbs-entry"] as? Double == 30) + #expect(json["absorption-time"] as? Double == 3) + #expect(json["otp"] as? String == "123456") + #expect(json["remote-address"] as? String == "LoopFollow") + #expect(json["entered-by"] as? String == "LoopFollow") + #expect(json["notes"] as? String == "Sent via LoopFollow APNS") + #expect(json["sent-at"] as? String == "2023-11-14T22:13:20.000Z") + #expect(json["expiration"] as? String == "2023-11-14T22:18:20.000Z") + #expect(json["start-time"] as? String == "2023-11-14T22:03:20.000Z") + #expect(json["alert"] as? String == "Remote Carbs Entry: 30.0 grams\nAbsorption Time: 3.0 hours") + } + + @Test("bolus payload carries the loop.js keys") + func bolusKeys() { + let json = LoopAPNSService.bolusCommandPayload(LoopAPNSPayload(type: .bolus, bolusAmount: 1.25, otp: "123456"), now: now) + #expect(Set(json.keys) == headerKeys.union(["bolus-entry"])) + #expect(json["bolus-entry"] as? Double == 1.25) + #expect(json["alert"] as? String == "Remote Bolus Entry: 1.25 U") + } + + @Test("carbs delete addresses the entry by sync identifier") + func deleteKeys() { + let json = LoopAPNSService.carbsDeleteCommandPayload(syncIdentifier: "SYNC-1", otp: "123456", now: now) + #expect(Set(json.keys) == headerKeys.union(["carbs-delete"])) + #expect(json["carbs-delete"] as? String == "SYNC-1") + #expect(json["carbs-entry"] == nil) + } + + @Test("carbs edit always sends amount, absorption and start time; food type only when set") + func editKeys() { + let json = LoopAPNSService.carbsEditCommandPayload( + syncIdentifier: "SYNC-1", + carbsAmount: 45, + absorptionTimeHours: 2.5, + foodType: nil, + consumedDate: now.addingTimeInterval(-3600), + otp: "123456", + now: now + ) + #expect(Set(json.keys) == headerKeys.union(["carbs-edit", "carbs-edit-entry", "carbs-edit-absorption-time", "carbs-edit-start-time"])) + #expect(json["carbs-edit"] as? String == "SYNC-1") + #expect(json["carbs-edit-entry"] as? Double == 45) + #expect(json["carbs-edit-absorption-time"] as? Double == 2.5) + #expect(json["carbs-edit-start-time"] as? String == "2023-11-14T21:13:20.000Z") + #expect(json["carbs-entry"] == nil) + #expect(json["start-time"] == nil) + + let withFood = LoopAPNSService.carbsEditCommandPayload( + syncIdentifier: "SYNC-1", carbsAmount: 45, absorptionTimeHours: 2.5, foodType: "πŸ•", consumedDate: now, otp: "123456", now: now + ) + #expect(withFood["carbs-edit-food-type"] as? String == "πŸ•") + } +} From 519df1b2fdef2d587917fc64e9e70c0f6f9085e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 21 Sep 2026 14:29:37 +0200 Subject: [PATCH 12/84] Share LabeledValueRow and tidy meal detail texts LabeledValueRow renders the label/value rows in the treatment detail sections and both edit sheets. A Trio meal without carbs is listed as "Meal" with its fat and protein grams as subtitle. When Trio Remote Control is active but the Trio build's profile does not advertise the meal commands, the Meal section footer says a newer Trio version is needed. --- .../Remote/LoopAPNS/LoopCarbEditSheet.swift | 14 +---- LoopFollow/Remote/TRC/TRCCommandType.swift | 2 +- LoopFollow/Remote/TRC/TRCMealEditView.swift | 16 ++---- LoopFollow/Treatments/LabeledValueRow.swift | 18 ++++++ LoopFollow/Treatments/LoopCarbTreatment.swift | 1 - LoopFollow/Treatments/TreatmentsView.swift | 55 +++++++++++-------- LoopFollow/Treatments/TrioMealTreatment.swift | 7 ++- Tests/TrioMealTreatmentTests.swift | 2 + 8 files changed, 67 insertions(+), 48 deletions(-) create mode 100644 LoopFollow/Treatments/LabeledValueRow.swift diff --git a/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift b/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift index 6b4f84e3a..2fd82cabc 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift @@ -61,12 +61,12 @@ struct LoopCarbEditSheet: View { NavigationView { Form { Section(header: Text("Current")) { - row("Carbs", String(format: "%.0f g", carb.carbs)) + LabeledValueRow(label: "Carbs", value: String(format: "%.0f g", carb.carbs)) if let hours = carb.absorptionHours { - row("Absorption", String(format: "%.1f h", hours)) + LabeledValueRow(label: "Absorption", value: String(format: "%.1f h", hours)) } if let foodType = carb.foodType { - row("Food type", foodType) + LabeledValueRow(label: "Food type", value: foodType) } } @@ -190,12 +190,4 @@ struct LoopCarbEditSheet: View { } } } - - private func row(_ label: String, _ value: String) -> some View { - HStack { - Text(label) - Spacer() - Text(value).foregroundColor(.secondary) - } - } } diff --git a/LoopFollow/Remote/TRC/TRCCommandType.swift b/LoopFollow/Remote/TRC/TRCCommandType.swift index e444f076b..b6a6bfe0d 100644 --- a/LoopFollow/Remote/TRC/TRCCommandType.swift +++ b/LoopFollow/Remote/TRC/TRCCommandType.swift @@ -13,7 +13,7 @@ enum TRCCommandType: String, Encodable { case editMeal = "edit_meal" case deleteMeal = "delete_meal" - /// Meal mutations get one APNs collapse id per command so two sends never collapse into one. + /// One collapse id per command. var usesCommandIDAsCollapseID: Bool { self == .editMeal || self == .deleteMeal } diff --git a/LoopFollow/Remote/TRC/TRCMealEditView.swift b/LoopFollow/Remote/TRC/TRCMealEditView.swift index 6cdcb47d9..fed843f12 100644 --- a/LoopFollow/Remote/TRC/TRCMealEditView.swift +++ b/LoopFollow/Remote/TRC/TRCMealEditView.swift @@ -177,10 +177,10 @@ struct TRCMealMacroRows: View { let date: TimeInterval var body: some View { - row("Carbs", carbs == carbs.rounded() ? String(format: "%.0f g", carbs) : String(format: "%.1f g", carbs)) - if fat > 0 { row("Fat", "\(fat) g") } - if protein > 0 { row("Protein", "\(protein) g") } - row("Time", formattedTime) + LabeledValueRow(label: "Carbs", value: carbs == carbs.rounded() ? String(format: "%.0f g", carbs) : String(format: "%.1f g", carbs)) + if fat > 0 { LabeledValueRow(label: "Fat", value: "\(fat) g") } + if protein > 0 { LabeledValueRow(label: "Protein", value: "\(protein) g") } + LabeledValueRow(label: "Time", value: formattedTime) } private var formattedTime: String { @@ -190,12 +190,4 @@ struct TRCMealMacroRows: View { dateTimeUtils.applyDisplayTimeZone(to: formatter) return formatter.string(from: Date(timeIntervalSince1970: date)) } - - private func row(_ label: String, _ value: String) -> some View { - HStack { - Text(label) - Spacer() - Text(value).foregroundColor(.secondary) - } - } } diff --git a/LoopFollow/Treatments/LabeledValueRow.swift b/LoopFollow/Treatments/LabeledValueRow.swift new file mode 100644 index 000000000..93e214469 --- /dev/null +++ b/LoopFollow/Treatments/LabeledValueRow.swift @@ -0,0 +1,18 @@ +// LoopFollow +// LabeledValueRow.swift + +import SwiftUI + +/// A label on the left and its value, in secondary color, on the right. +struct LabeledValueRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + Spacer() + Text(value).foregroundColor(.secondary) + } + } +} diff --git a/LoopFollow/Treatments/LoopCarbTreatment.swift b/LoopFollow/Treatments/LoopCarbTreatment.swift index deed2239c..28a3595b6 100644 --- a/LoopFollow/Treatments/LoopCarbTreatment.swift +++ b/LoopFollow/Treatments/LoopCarbTreatment.swift @@ -8,7 +8,6 @@ import Foundation /// `syncIdentifier` is Loop's stable handle for the entry; it survives edits on the phone, /// so it addresses the entry in remote delete/edit commands. struct LoopCarbTreatment: Equatable { - /// Loop's Nightscout object-id cache expires after 24 h, so stop one hour short of it. static let editWindow: TimeInterval = 23 * 3600 let nightscoutID: String diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift index ff7566d65..6439d4550 100644 --- a/LoopFollow/Treatments/TreatmentsView.swift +++ b/LoopFollow/Treatments/TreatmentsView.swift @@ -442,8 +442,10 @@ struct TreatmentDetailView: View { } if let meal = treatment.trioMeal { - trioMealSection(meal) - if TrioMealTreatment.remoteActionsAvailable(remoteType: remoteType.value, device: device.value, remoteCommands: remoteCommands.value) { + let controlActive = TrioMealTreatment.remoteControlActive(remoteType: remoteType.value, device: device.value) + let actionsAvailable = TrioMealTreatment.remoteActionsAvailable(remoteType: remoteType.value, device: device.value, remoteCommands: remoteCommands.value) + trioMealSection(meal, needsNewerTrio: controlActive && !actionsAvailable) + if actionsAvailable { trioRemoteActionsSection(meal) } } @@ -656,24 +658,12 @@ struct TreatmentDetailView: View { @ViewBuilder private func loopCarbSection(_ carb: LoopCarbTreatment) -> some View { Section(header: Text("Carb entry")) { - HStack { - Text("Carbs") - Spacer() - Text(String(format: "%.0f g", carb.carbs)).foregroundColor(.secondary) - } + LabeledValueRow(label: "Carbs", value: String(format: "%.0f g", carb.carbs)) if let hours = carb.absorptionHours { - HStack { - Text("Absorption") - Spacer() - Text(String(format: "%.1f h", hours)).foregroundColor(.secondary) - } + LabeledValueRow(label: "Absorption", value: String(format: "%.1f h", hours)) } if let foodType = carb.foodType { - HStack { - Text("Food type") - Spacer() - Text(foodType).foregroundColor(.secondary) - } + LabeledValueRow(label: "Food type", value: foodType) } } } @@ -698,8 +688,8 @@ struct TreatmentDetailView: View { } @ViewBuilder - private func trioMealSection(_ meal: TrioMealTreatment) -> some View { - Section(header: Text("Meal"), footer: meal.isFPUChild ? Text("One of the small carb entries Trio created from this meal's fat and protein. Editing or deleting affects the whole meal.") : nil) { + private func trioMealSection(_ meal: TrioMealTreatment, needsNewerTrio: Bool) -> some View { + Section(header: Text("Meal"), footer: trioMealFooter(meal, needsNewerTrio: needsNewerTrio)) { TRCMealMacroRows(carbs: meal.carbs, fat: meal.fat, protein: meal.protein, date: meal.date) if let note = meal.note { HStack(alignment: .top) { @@ -716,6 +706,17 @@ struct TreatmentDetailView: View { } } + private func trioMealFooter(_ meal: TrioMealTreatment, needsNewerTrio: Bool) -> Text? { + var lines: [String] = [] + if meal.isFPUChild { + lines.append("One of the small carb entries Trio created from this meal's fat and protein. Editing or deleting affects the whole meal.") + } + if needsNewerTrio { + lines.append("Editing meals needs a newer Trio version.") + } + return lines.isEmpty ? nil : Text(lines.joined(separator: "\n")) + } + @ViewBuilder private func trioRemoteActionsSection(_ meal: TrioMealTreatment) -> some View { let target = commandMeal ?? meal @@ -1367,13 +1368,12 @@ class TreatmentsViewModel: ObservableObject { let carbs = entry["carbs"] as? Double ?? 0 if carbs > 0 || trioMeal != nil { let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) - let title = carbs > 0 ? "\(Int(carbs))g" : "F\(trioMeal?.fat ?? 0) P\(trioMeal?.protein ?? 0)" let treatment = Treatment( id: "\(nsId)-carb", type: .carb, date: timestamp, - title: title, - subtitle: trioMeal?.isFPUChild == true ? "Carbs β€’ FPU" : "Carbs", + title: carbs > 0 ? "\(Int(carbs))g" : "Meal", + subtitle: carbSubtitle(carbs: carbs, trioMeal: trioMeal), icon: "circle.fill", color: .orange, bgValue: actualBG, @@ -1547,6 +1547,17 @@ class TreatmentsViewModel: ObservableObject { return (treatments.sorted { $0.date > $1.date }, detectedSMB, detectedAutomatic) } + /// "Carbs" for a carb entry, "Carbs β€’ FPU" for a Trio FPU child, and the fat/protein grams for a Trio meal without carbs. + private func carbSubtitle(carbs: Double, trioMeal: TrioMealTreatment?) -> String { + if trioMeal?.isFPUChild == true { return "Carbs β€’ FPU" } + guard carbs == 0, let trioMeal else { return "Carbs" } + let parts = [ + trioMeal.fat > 0 ? "\(trioMeal.fat) g fat" : nil, + trioMeal.protein > 0 ? "\(trioMeal.protein) g protein" : nil, + ] + return parts.compactMap { $0 }.joined(separator: " β€’ ") + } + private func regroupTreatments() { var grouped: [String: [Treatment]] = [:] var roots: [UUID: Treatment] = [:] diff --git a/LoopFollow/Treatments/TrioMealTreatment.swift b/LoopFollow/Treatments/TrioMealTreatment.swift index 606649a61..ed00e7398 100644 --- a/LoopFollow/Treatments/TrioMealTreatment.swift +++ b/LoopFollow/Treatments/TrioMealTreatment.swift @@ -60,9 +60,14 @@ struct TrioMealTreatment: Equatable { return age <= Self.pastEditWindow && age >= -Self.futureEditWindow } + /// Trio Remote Control targets a Trio device, whatever commands that build advertises. + static func remoteControlActive(remoteType: RemoteType, device: String) -> Bool { + remoteType == .trc && device == "Trio" + } + /// Remote edit/delete is offered only when Trio Remote Control targets a Trio that lists both commands. static func remoteActionsAvailable(remoteType: RemoteType, device: String, remoteCommands: [String]) -> Bool { - remoteType == .trc && device == "Trio" && requiredRemoteCommands.isSubset(of: remoteCommands) + remoteControlActive(remoteType: remoteType, device: device) && requiredRemoteCommands.isSubset(of: remoteCommands) } private static func number(_ value: AnyObject?) -> Double? { diff --git a/Tests/TrioMealTreatmentTests.swift b/Tests/TrioMealTreatmentTests.swift index 3145b4efb..71fe70771 100644 --- a/Tests/TrioMealTreatmentTests.swift +++ b/Tests/TrioMealTreatmentTests.swift @@ -79,5 +79,7 @@ struct TrioMealTreatmentTests { #expect(TrioMealTreatment.remoteActionsAvailable(remoteType: .trc, device: "Loop", remoteCommands: both) == false) #expect(TrioMealTreatment.remoteActionsAvailable(remoteType: .loopAPNS, device: "Trio", remoteCommands: both) == false) #expect(TrioMealTreatment.remoteActionsAvailable(remoteType: .none, device: "Trio", remoteCommands: both) == false) + #expect(TrioMealTreatment.remoteControlActive(remoteType: .trc, device: "Trio") == true) + #expect(TrioMealTreatment.remoteControlActive(remoteType: .trc, device: "Loop") == false) } } From ce22aa224a89e02cf8aa8470a6e9ad15ebe602b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 21 Sep 2026 15:43:05 +0200 Subject: [PATCH 13/84] Allow overriding the APNs host in debug builds --- LoopFollow/Remote/APNSEnvironment.swift | 16 ++++++++++++++++ LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift | 2 +- .../Remote/TRC/PushNotificationManager.swift | 4 +--- 3 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 LoopFollow/Remote/APNSEnvironment.swift diff --git a/LoopFollow/Remote/APNSEnvironment.swift b/LoopFollow/Remote/APNSEnvironment.swift new file mode 100644 index 000000000..5f1e03ebb --- /dev/null +++ b/LoopFollow/Remote/APNSEnvironment.swift @@ -0,0 +1,16 @@ +// LoopFollow +// APNSEnvironment.swift + +import Foundation + +enum APNSEnvironment { + /// Base URL of the APNs HTTP/2 endpoint. Debug builds honor `LOOPFOLLOW_APNS_BASE_URL` as a local stand-in. + static func baseURL(production: Bool) -> String { + #if DEBUG + if let override = ProcessInfo.processInfo.environment["LOOPFOLLOW_APNS_BASE_URL"], !override.isEmpty { + return override + } + #endif + return production ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com" + } +} diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index 142b31bac..e825ed88e 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -378,7 +378,7 @@ class LoopAPNSService { // Determine APNS environment let isProduction = storage.productionEnvironment.value - let apnsURL = isProduction ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com" + let apnsURL = APNSEnvironment.baseURL(production: isProduction) guard let requestURL = URL(string: "\(apnsURL)/3/device/\(deviceToken)") else { let errorMessage = "Failed to construct APNs URL" LogManager.shared.log(category: .apns, message: errorMessage) diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index 9c94616be..236161b06 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -378,8 +378,6 @@ class PushNotificationManager { } private func constructAPNsURL() -> URL? { - let host = productionEnvironment ? "api.push.apple.com" : "api.sandbox.push.apple.com" - let urlString = "https://\(host)/3/device/\(deviceToken)" - return URL(string: urlString) + URL(string: "\(APNSEnvironment.baseURL(production: productionEnvironment))/3/device/\(deviceToken)") } } From 3a8166ef597e050e334e78939f47cbd6c8e0f937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 21 Sep 2026 16:27:30 +0200 Subject: [PATCH 14/84] Keep a meal's current values editable above the guardrail maxima --- LoopFollow/Remote/TRC/MealMacroInputs.swift | 13 ++++++++++--- LoopFollow/Remote/TRC/TRCMealEditView.swift | 7 ++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/LoopFollow/Remote/TRC/MealMacroInputs.swift b/LoopFollow/Remote/TRC/MealMacroInputs.swift index dc8eca657..71bf9b29b 100644 --- a/LoopFollow/Remote/TRC/MealMacroInputs.swift +++ b/LoopFollow/Remote/TRC/MealMacroInputs.swift @@ -14,6 +14,8 @@ struct MealMacroInputs: View { @FocusState.Binding var fatFocused: Bool @FocusState.Binding var proteinFocused: Bool var onValidationError: (String) -> Void + /// Values already in the meal stay editable even when they exceed the guardrail maxima. + var currentValues: (carbs: HKQuantity, fat: HKQuantity, protein: HKQuantity)? = nil @ObservedObject private var maxCarbs = Storage.shared.maxCarbs @ObservedObject private var maxProtein = Storage.shared.maxProtein @@ -26,7 +28,7 @@ struct MealMacroInputs: View { unit: .gram(), maxLength: 4, minValue: HKQuantity(unit: .gram(), doubleValue: 0), - maxValue: maxCarbs.value, + maxValue: ceiling(maxCarbs.value, currentValues?.carbs), isFocused: $carbsFocused, onValidationError: onValidationError ) @@ -38,7 +40,7 @@ struct MealMacroInputs: View { unit: .gram(), maxLength: 4, minValue: HKQuantity(unit: .gram(), doubleValue: 0), - maxValue: maxFat.value, + maxValue: ceiling(maxFat.value, currentValues?.fat), isFocused: $fatFocused, onValidationError: onValidationError ) @@ -49,10 +51,15 @@ struct MealMacroInputs: View { unit: .gram(), maxLength: 4, minValue: HKQuantity(unit: .gram(), doubleValue: 0), - maxValue: maxProtein.value, + maxValue: ceiling(maxProtein.value, currentValues?.protein), isFocused: $proteinFocused, onValidationError: onValidationError ) } } + + private func ceiling(_ limit: HKQuantity, _ current: HKQuantity?) -> HKQuantity { + guard let current, current.compare(limit) == .orderedDescending else { return limit } + return current + } } diff --git a/LoopFollow/Remote/TRC/TRCMealEditView.swift b/LoopFollow/Remote/TRC/TRCMealEditView.swift index fed843f12..06cb8fa67 100644 --- a/LoopFollow/Remote/TRC/TRCMealEditView.swift +++ b/LoopFollow/Remote/TRC/TRCMealEditView.swift @@ -73,7 +73,12 @@ struct TRCMealEditView: View { carbsFocused: $carbsFocused, fatFocused: $fatFocused, proteinFocused: $proteinFocused, - onValidationError: { alertType = .validation($0) } + onValidationError: { alertType = .validation($0) }, + currentValues: ( + HKQuantity(unit: .gram(), doubleValue: meal.carbs), + HKQuantity(unit: .gram(), doubleValue: Double(meal.fat)), + HKQuantity(unit: .gram(), doubleValue: Double(meal.protein)) + ) ) DatePicker("Meal time", selection: $mealDate, in: dateRange, displayedComponents: [.date, .hourAndMinute]) .environment(\.timeZone, dateTimeUtils.displayTimeZone()) From 9e98b8b89dc95cec27d3735c40afc20325952317 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 26 Sep 2026 12:37:20 +0200 Subject: [PATCH 15/84] Gate Loop carb Edit and Delete on the advertised remote commands A patched Loop lists carbs-delete and carbs-edit under loop.testingDetails.remoteCommands in every devicestatus upload. DeviceStatusLoop stores that list in loopRemoteCommands, clearing it when the key is absent, and the treatment detail offers the Loop carb remote actions only when both commands are advertised. With Loop APNS active but the commands missing, the carb entry section explains that the remote carb edit customization is needed. --- .../Controllers/Nightscout/DeviceStatusLoop.swift | 1 + LoopFollow/Storage/Storage.swift | 2 ++ LoopFollow/Treatments/LoopCarbTreatment.swift | 9 ++++++++- LoopFollow/Treatments/TreatmentsView.swift | 13 ++++++++----- Tests/LoopCarbTreatmentTests.swift | 14 ++++++++++---- 5 files changed, 29 insertions(+), 10 deletions(-) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index bd5699726..0a3194b26 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -8,6 +8,7 @@ import SwiftUI extension MainViewController { func DeviceStatusLoop(formatter: ISO8601DateFormatter, lastLoopRecord: [String: AnyObject]) { Storage.shared.device.value = "Loop" + Storage.shared.loopRemoteCommands.value = (lastLoopRecord["testingDetails"] as? [String: AnyObject])?["remoteCommands"] as? [String] ?? [] if Storage.shared.remoteType.value == .trc { Storage.shared.remoteType.value = .none diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 64829a325..2645ff1e5 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -24,6 +24,8 @@ class Storage { var user = StorageValue(key: "user", defaultValue: "") /// Remote command types the AID app advertises in its Nightscout profile. var remoteCommands = StorageValue<[String]>(key: "remoteCommands", defaultValue: []) + /// Remote command types the Loop build advertises in its devicestatus. + var loopRemoteCommands = StorageValue<[String]>(key: "loopRemoteCommands", defaultValue: []) var maxBolus = SecureStorageValue(key: "maxBolus", defaultValue: HKQuantity(unit: .internationalUnit(), doubleValue: 1.0)) var maxCarbs = SecureStorageValue(key: "maxCarbs", defaultValue: HKQuantity(unit: .gram(), doubleValue: 30.0)) diff --git a/LoopFollow/Treatments/LoopCarbTreatment.swift b/LoopFollow/Treatments/LoopCarbTreatment.swift index 28a3595b6..8f6ff5575 100644 --- a/LoopFollow/Treatments/LoopCarbTreatment.swift +++ b/LoopFollow/Treatments/LoopCarbTreatment.swift @@ -9,6 +9,7 @@ import Foundation /// so it addresses the entry in remote delete/edit commands. struct LoopCarbTreatment: Equatable { static let editWindow: TimeInterval = 23 * 3600 + static let requiredRemoteCommands: Set = ["carbs-delete", "carbs-edit"] let nightscoutID: String let syncIdentifier: String @@ -49,7 +50,13 @@ struct LoopCarbTreatment: Equatable { return age <= Self.editWindow && age >= -3600 } - static func remoteActionsAvailable(remoteType: RemoteType, device: String) -> Bool { + /// Loop APNS targets a Loop device, whatever commands that build advertises. + static func remoteControlActive(remoteType: RemoteType, device: String) -> Bool { remoteType == .loopAPNS && device == "Loop" } + + /// Remote edit/delete is offered only when Loop APNS targets a Loop that lists both commands. + static func remoteActionsAvailable(remoteType: RemoteType, device: String, remoteCommands: [String]) -> Bool { + remoteControlActive(remoteType: remoteType, device: device) && requiredRemoteCommands.isSubset(of: remoteCommands) + } } diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift index 6439d4550..ff7bc4cad 100644 --- a/LoopFollow/Treatments/TreatmentsView.swift +++ b/LoopFollow/Treatments/TreatmentsView.swift @@ -403,6 +403,7 @@ struct TreatmentDetailView: View { @ObservedObject private var remoteType = Storage.shared.remoteType @ObservedObject private var device = Storage.shared.device @ObservedObject private var remoteCommands = Storage.shared.remoteCommands + @ObservedObject private var loopRemoteCommands = Storage.shared.loopRemoteCommands @State private var showEditSheet = false @State private var showDeleteConfirmation = false @@ -451,8 +452,10 @@ struct TreatmentDetailView: View { } if let carb = treatment.loopCarb { - loopCarbSection(carb) - if LoopCarbTreatment.remoteActionsAvailable(remoteType: remoteType.value, device: device.value) { + let controlActive = LoopCarbTreatment.remoteControlActive(remoteType: remoteType.value, device: device.value) + let actionsAvailable = LoopCarbTreatment.remoteActionsAvailable(remoteType: remoteType.value, device: device.value, remoteCommands: loopRemoteCommands.value) + loopCarbSection(carb, needsCustomization: controlActive && !actionsAvailable) + if actionsAvailable { loopRemoteActionsSection(carb) } } @@ -656,8 +659,8 @@ struct TreatmentDetailView: View { } @ViewBuilder - private func loopCarbSection(_ carb: LoopCarbTreatment) -> some View { - Section(header: Text("Carb entry")) { + private func loopCarbSection(_ carb: LoopCarbTreatment, needsCustomization: Bool) -> some View { + Section(header: Text("Carb entry"), footer: needsCustomization ? Text("Editing carbs needs the remote carb edit customization in Loop.") : nil) { LabeledValueRow(label: "Carbs", value: String(format: "%.0f g", carb.carbs)) if let hours = carb.absorptionHours { LabeledValueRow(label: "Absorption", value: String(format: "%.1f h", hours)) @@ -684,7 +687,7 @@ struct TreatmentDetailView: View { } private func loopRemoteActionsFooter(withinWindow: Bool) -> Text? { - withinWindow ? Text("Requires a Loop build with remote carb editing.") : Text("Carb entries can be changed remotely for 23 hours.") + withinWindow ? nil : Text("Carb entries can be changed remotely for 23 hours.") } @ViewBuilder diff --git a/Tests/LoopCarbTreatmentTests.swift b/Tests/LoopCarbTreatmentTests.swift index e6c2bcb47..9768dbcc2 100644 --- a/Tests/LoopCarbTreatmentTests.swift +++ b/Tests/LoopCarbTreatmentTests.swift @@ -51,10 +51,16 @@ struct LoopCarbTreatmentTests { #expect(carb(offsetHours: 2)?.isWithinEditWindow(now: now) == false) } - @Test("actions need Loop APNS and a Loop device") + @Test("actions need Loop APNS, a Loop device and both commands advertised") func gating() { - #expect(LoopCarbTreatment.remoteActionsAvailable(remoteType: .loopAPNS, device: "Loop") == true) - #expect(LoopCarbTreatment.remoteActionsAvailable(remoteType: .loopAPNS, device: "Trio") == false) - #expect(LoopCarbTreatment.remoteActionsAvailable(remoteType: .trc, device: "Loop") == false) + let both = ["carbs-delete", "carbs-edit"] + #expect(LoopCarbTreatment.remoteActionsAvailable(remoteType: .loopAPNS, device: "Loop", remoteCommands: both) == true) + #expect(LoopCarbTreatment.remoteActionsAvailable(remoteType: .loopAPNS, device: "Loop", remoteCommands: ["carbs-delete"]) == false) + #expect(LoopCarbTreatment.remoteActionsAvailable(remoteType: .loopAPNS, device: "Loop", remoteCommands: []) == false) + #expect(LoopCarbTreatment.remoteActionsAvailable(remoteType: .loopAPNS, device: "Trio", remoteCommands: both) == false) + #expect(LoopCarbTreatment.remoteActionsAvailable(remoteType: .trc, device: "Loop", remoteCommands: both) == false) + #expect(LoopCarbTreatment.remoteActionsAvailable(remoteType: .none, device: "Loop", remoteCommands: both) == false) + #expect(LoopCarbTreatment.remoteControlActive(remoteType: .loopAPNS, device: "Loop") == true) + #expect(LoopCarbTreatment.remoteControlActive(remoteType: .loopAPNS, device: "Trio") == false) } } From 67e2cc8ae275822543fb2602931e34910008547c Mon Sep 17 00:00:00 2001 From: Daniel Mini Johansson <42831533+codebymini@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:58:15 +0200 Subject: [PATCH 16/84] Add ability to double-click carbs to enter Treatments screen (#757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add ability to double-click carbs to enter Treatments screen * Keep the carb detail sheet across remounts and share carb treatment construction (#758) * Share carb Treatment construction and root-meal lookup Treatment.carb builds a carb entry for both the treatments list and the chart's detail sheet, with one id fallback for entries without a Nightscout _id. Treatment.rootMealsByFPUID and rootMeal(in:) back the FPU-child root lookup in both places. * Refresh treatments when a remote meal command completes The chart's carb marks follow a remote meal edit or delete from any screen by running the treatments task as soon as the command resolves. * Keep the carb detail sheet in the chart shell BGChartView owns the selected carb and its sheet, outside the subtree that is remounted on foregrounding, so an open sheet survives a trip to Notification Center or the background. Double-tap hit-tests carb marks only, so a nearby bolus cannot shadow the carb it sits next to. --------- Co-authored-by: Jonas BjΓΆrkert --- LoopFollow/Charts/BGChartModel.swift | 7 +- LoopFollow/Charts/BGChartView.swift | 73 ++++++++++++++--- LoopFollow/Controllers/NightScout.swift | 1 + .../Nightscout/Treatments/Carbs.swift | 8 +- LoopFollow/Treatments/CarbTreatment.swift | 30 +++++++ LoopFollow/Treatments/LoopCarbTreatment.swift | 6 +- LoopFollow/Treatments/TreatmentsView.swift | 78 ++++++++++++------- LoopFollow/Treatments/TrioMealTreatment.swift | 6 +- .../ViewControllers/MainViewController.swift | 6 ++ 9 files changed, 171 insertions(+), 44 deletions(-) create mode 100644 LoopFollow/Treatments/CarbTreatment.swift diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index 8acc2859c..197858d01 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -54,17 +54,19 @@ final class BGChartModel: ObservableObject { let sgv: Double let label: String let pillText: String + let treatment: CarbTreatment? /// Where the symbol is drawn. Equals `date` unless `spread` nudged it /// left to keep a crowded run of treatments from stacking up. var drawnDate: Date var id: Double { date.timeIntervalSince1970 } - init(date: Date, value: Double, sgv: Double, label: String, pillText: String) { + init(date: Date, value: Double, sgv: Double, label: String, pillText: String, treatment: CarbTreatment? = nil) { self.date = date self.value = value self.sgv = sgv self.label = label self.pillText = pillText + self.treatment = treatment drawnDate = date } } @@ -475,7 +477,8 @@ final class BGChartModel: ObservableObject { value: $0.value, sgv: Double($0.sgv), label: label, - pillText: "Carbs\n\(grams)g\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" + pillText: "Carbs\n\(grams)g\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))", + treatment: $0.treatment ) }, minGap: Spread.carbGap, maxShift: Spread.carbShift) let smbPoints = (showBolus ? vc.smbData : []).map { diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index 74f9ec03e..8c1c407f8 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -70,18 +70,36 @@ struct BGChartView: View { /// attachments while BGChartInteraction preserves the viewport. @State private var gestureMountEpoch = 0 + /// Carb entry opened by a double-tap on the main chart. Owned here, outside + /// the remounted subtree, so the sheet survives a foregrounding remount. + @State private var selectedTreatment: Treatment? + var body: some View { Group { if config == .small { SmallBGChart(model: model, interaction: model.interaction) } else { - MainBGChart(model: model, interaction: model.interaction) + MainBGChart(model: model, interaction: model.interaction, selectedTreatment: $selectedTreatment) } } .id(gestureMountEpoch) .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in gestureMountEpoch &+= 1 } + .sheet(item: $selectedTreatment) { treatment in + NavigationStack { + TreatmentDetailView(treatment: treatment, rootMeal: rootMeal(for: treatment)) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { selectedTreatment = nil } + } + } + } + } + } + + private func rootMeal(for treatment: Treatment) -> Treatment? { + treatment.rootMeal(in: Treatment.rootMealsByFPUID(model.carbs.compactMap { $0.treatment?.detailTreatment })) } } @@ -98,11 +116,14 @@ struct BGChartView: View { /// whose live preview is a `.scaleEffect(x:)` stretch anchored under the /// pinch centroid, committed on a geometric zoom grid. A one-finger press /// held stationary latches into inspect mode and scrubs a selection that is -/// rendered by a shell overlay (never re-laying the canvas). Double-tap -/// cycles zoom presets. No `.chartScrollableAxes`, no UIKit gesture hacks. +/// rendered by a shell overlay (never re-laying the canvas). Double-tap on a +/// carb mark opens its details; elsewhere it cycles zoom presets. +/// No `.chartScrollableAxes`, no UIKit gesture hacks. private struct MainBGChart: View { @ObservedObject var model: BGChartModel @ObservedObject var interaction: BGChartInteraction + /// Carb entry to show in the detail sheet; the shell owns the sheet. + @Binding var selectedTreatment: Treatment? /// Rendered slice of the domain. The canvas covers only this window /// (visible Β± `renderWindowPadFactor` viewports), bounding canvas width @@ -110,9 +131,10 @@ private struct MainBGChart: View { @State private var renderWindowStart: Date @State private var renderWindowEnd: Date - init(model: BGChartModel, interaction: BGChartInteraction) { + init(model: BGChartModel, interaction: BGChartInteraction, selectedTreatment: Binding) { _model = ObservedObject(wrappedValue: model) _interaction = ObservedObject(wrappedValue: interaction) + _selectedTreatment = selectedTreatment // Seed the render window around the current viewport so a remount's // first frame draws in place. let pad = BGChartConfig.renderWindowPadFactor * interaction.visibleSeconds @@ -241,15 +263,15 @@ private struct MainBGChart: View { .contentShape(Rectangle()) .simultaneousGesture(panAndInspectGesture(viewportWidth: viewportWidth)) .simultaneousGesture(magnifyGesture(viewportWidth: viewportWidth)) - // Double-tap zooms; a single tap (only recognized once the double-tap - // window lapses) selects the mark under the finger, or clears the pill. + // Double-tap opens carb treatment details, otherwise zooms. Single-tap + // waits for the double-tap window before selecting a mark. .simultaneousGesture( - TapGesture(count: 2) + SpatialTapGesture(count: 2) .exclusively(before: SpatialTapGesture()) .onEnded { value in switch value { - case .first: - cycleZoomPreset() + case let .first(tap): + handleDoubleTap(at: tap.location, viewportWidth: viewportWidth) case let .second(tap): handleTap(at: tap.location, viewportWidth: viewportWidth) } @@ -812,6 +834,39 @@ private struct MainBGChart: View { tapped = tappedAnchor(at: location, viewportWidth: viewportWidth) } + /// Double-tap hit test (screen-space, 2D) over carb marks only, at their + /// drawn (decluttered) positions. Returns nil when no carb is under the + /// finger β€” which cycles the zoom preset. + private func tappedCarb(at location: CGPoint, viewportWidth: CGFloat) -> BGChartModel.TreatmentPoint? { + let radius = BGChartConfig.tapHitRadius + var best: BGChartModel.TreatmentPoint? + var bestDistance2 = radius * radius + + for point in model.carbs { + let dx = xPosition(for: point.drawnDate, viewportWidth: viewportWidth) - location.x + let dy = yPosition(forValue: point.sgv) - location.y + let d2 = dx * dx + dy * dy + if d2 <= bestDistance2 { + bestDistance2 = d2 + best = point + } + } + return best + } + + private func handleDoubleTap(at location: CGPoint, viewportWidth: CGFloat) { + // A carb mark without AID metadata (no remote handle) has no details to show. + guard plotFrame.height > 0, let carb = tappedCarb(at: location, viewportWidth: viewportWidth)?.treatment else { + cycleZoomPreset() + return + } + momentumTask?.cancel() + momentumTask = nil + resetGestureState() + tapped = nil + selectedTreatment = carb.detailTreatment + } + /// The anchor the overlay should show: a live scrub wins over a sticky tap. private func activeAnchor() -> SelectionAnchor? { if isInspectLatched, let selected = selection { diff --git a/LoopFollow/Controllers/NightScout.swift b/LoopFollow/Controllers/NightScout.swift index 90e3c1e9c..34a368ab1 100644 --- a/LoopFollow/Controllers/NightScout.swift +++ b/LoopFollow/Controllers/NightScout.swift @@ -43,6 +43,7 @@ extension MainViewController { var date: TimeInterval var sgv: Int var absorptionTime: Int + var treatment: CarbTreatment? = nil } func clearOldTempBasal() { diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift index 23e2e0427..927230dae 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift @@ -44,7 +44,13 @@ extension MainViewController { if dateTimeStamp < (dateTimeUtils.getNowTimeIntervalUTC() + (3600 * Storage.shared.predictionToLoad.value)) { // Make the dot - let dot = carbGraphStruct(value: Double(carbs), date: Double(dateTimeStamp), sgv: Int(sgv.sgv + Double(offset)), absorptionTime: absorptionTime) + let dot = carbGraphStruct( + value: Double(carbs), + date: Double(dateTimeStamp), + sgv: Int(sgv.sgv + Double(offset)), + absorptionTime: absorptionTime, + treatment: CarbTreatment(nightscoutEntry: currentEntry, date: dateTimeStamp) + ) carbData.append(dot) } } diff --git a/LoopFollow/Treatments/CarbTreatment.swift b/LoopFollow/Treatments/CarbTreatment.swift new file mode 100644 index 000000000..8a373cf61 --- /dev/null +++ b/LoopFollow/Treatments/CarbTreatment.swift @@ -0,0 +1,30 @@ +// LoopFollow +// CarbTreatment.swift + +import Foundation + +/// Source metadata for a carb mark, preserving the identifier used by its AID app. +enum CarbTreatment: Codable, Equatable { + case loop(LoopCarbTreatment) + case trio(TrioMealTreatment) + + init?(nightscoutEntry entry: [String: AnyObject], date: TimeInterval) { + if let meal = TrioMealTreatment(nightscoutEntry: entry, date: date) { + self = .trio(meal) + } else if let carb = LoopCarbTreatment(nightscoutEntry: entry, date: date) { + self = .loop(carb) + } else { + return nil + } + } + + /// The existing detail screen owns all remote action availability and command handling. + var detailTreatment: Treatment { + switch self { + case let .loop(carb): + return .carb(nightscoutID: carb.nightscoutID, date: carb.date, carbs: carb.carbs, bgValue: 0, loopCarb: carb) + case let .trio(meal): + return .carb(nightscoutID: meal.nightscoutID, date: meal.date, carbs: meal.carbs, bgValue: 0, trioMeal: meal) + } + } +} diff --git a/LoopFollow/Treatments/LoopCarbTreatment.swift b/LoopFollow/Treatments/LoopCarbTreatment.swift index 8f6ff5575..b7a28f480 100644 --- a/LoopFollow/Treatments/LoopCarbTreatment.swift +++ b/LoopFollow/Treatments/LoopCarbTreatment.swift @@ -7,11 +7,11 @@ import Foundation /// /// `syncIdentifier` is Loop's stable handle for the entry; it survives edits on the phone, /// so it addresses the entry in remote delete/edit commands. -struct LoopCarbTreatment: Equatable { +struct LoopCarbTreatment: Codable, Equatable { static let editWindow: TimeInterval = 23 * 3600 static let requiredRemoteCommands: Set = ["carbs-delete", "carbs-edit"] - let nightscoutID: String + let nightscoutID: String? let syncIdentifier: String let enteredBy: String let date: TimeInterval @@ -31,7 +31,7 @@ struct LoopCarbTreatment: Equatable { return nil } - nightscoutID = entry["_id"] as? String ?? "" + nightscoutID = entry["_id"] as? String self.syncIdentifier = syncIdentifier self.enteredBy = enteredBy self.date = date diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift index ff7bc4cad..7aea0415a 100644 --- a/LoopFollow/Treatments/TreatmentsView.swift +++ b/LoopFollow/Treatments/TreatmentsView.swift @@ -1154,6 +1154,52 @@ struct Treatment: Identifiable { } } +extension Treatment { + /// A carb entry; `trioMeal` / `loopCarb` carry the AID app's handle for remote edit and delete. + static func carb(nightscoutID: String?, date: TimeInterval, carbs: Double, bgValue: Int, trioMeal: TrioMealTreatment? = nil, loopCarb: LoopCarbTreatment? = nil) -> Treatment { + Treatment( + id: "\(nightscoutID ?? "unknown-\(date)")-carb", + type: .carb, + date: date, + title: carbs > 0 ? "\(Int(carbs))g" : "Meal", + subtitle: carbSubtitle(carbs: carbs, trioMeal: trioMeal), + icon: "circle.fill", + color: .orange, + bgValue: bgValue, + trioMeal: trioMeal, + loopCarb: loopCarb + ) + } + + /// "Carbs" for a carb entry, "Carbs β€’ FPU" for a Trio FPU child, and the fat/protein grams for a Trio meal without carbs. + private static func carbSubtitle(carbs: Double, trioMeal: TrioMealTreatment?) -> String { + if trioMeal?.isFPUChild == true { return "Carbs β€’ FPU" } + guard carbs == 0, let trioMeal else { return "Carbs" } + let parts = [ + trioMeal.fat > 0 ? "\(trioMeal.fat) g fat" : nil, + trioMeal.protein > 0 ? "\(trioMeal.protein) g protein" : nil, + ] + return parts.compactMap { $0 }.joined(separator: " β€’ ") + } + + /// Trio root meals keyed by the `fpuID` their FPU children carry. + static func rootMealsByFPUID(_ treatments: some Sequence) -> [UUID: Treatment] { + var roots: [UUID: Treatment] = [:] + for treatment in treatments { + if let meal = treatment.trioMeal, !meal.isFPUChild, let fpuID = meal.fpuID { + roots[fpuID] = treatment + } + } + return roots + } + + /// The root meal for an FPU child, when the Trio build publishes `fpuID` and `roots` holds it. + func rootMeal(in roots: [UUID: Treatment]) -> Treatment? { + guard let child = trioMeal, child.isFPUChild, let fpuID = child.fpuID else { return nil } + return roots[fpuID] + } +} + class TreatmentsViewModel: ObservableObject { @Published var groupedTreatments: [String: [Treatment]] = [:] @Published var isInitialLoading = false @@ -1204,10 +1250,9 @@ class TreatmentsViewModel: ObservableObject { } } - /// The root meal for an FPU child, when the Trio build publishes `fpuID` and the root is loaded. + /// The root meal for an FPU child, when it is loaded. func rootMeal(forFPUChild treatment: Treatment) -> Treatment? { - guard let child = treatment.trioMeal, child.isFPUChild, let fpuID = child.fpuID else { return nil } - return rootMealsByFPUID[fpuID] + treatment.rootMeal(in: rootMealsByFPUID) } func refreshTreatments() { @@ -1371,14 +1416,10 @@ class TreatmentsViewModel: ObservableObject { let carbs = entry["carbs"] as? Double ?? 0 if carbs > 0 || trioMeal != nil { let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) - let treatment = Treatment( - id: "\(nsId)-carb", - type: .carb, + let treatment = Treatment.carb( + nightscoutID: nsId, date: timestamp, - title: carbs > 0 ? "\(Int(carbs))g" : "Meal", - subtitle: carbSubtitle(carbs: carbs, trioMeal: trioMeal), - icon: "circle.fill", - color: .orange, + carbs: carbs, bgValue: actualBG, trioMeal: trioMeal, loopCarb: loopCarb @@ -1550,20 +1591,8 @@ class TreatmentsViewModel: ObservableObject { return (treatments.sorted { $0.date > $1.date }, detectedSMB, detectedAutomatic) } - /// "Carbs" for a carb entry, "Carbs β€’ FPU" for a Trio FPU child, and the fat/protein grams for a Trio meal without carbs. - private func carbSubtitle(carbs: Double, trioMeal: TrioMealTreatment?) -> String { - if trioMeal?.isFPUChild == true { return "Carbs β€’ FPU" } - guard carbs == 0, let trioMeal else { return "Carbs" } - let parts = [ - trioMeal.fat > 0 ? "\(trioMeal.fat) g fat" : nil, - trioMeal.protein > 0 ? "\(trioMeal.protein) g protein" : nil, - ] - return parts.compactMap { $0 }.joined(separator: " β€’ ") - } - private func regroupTreatments() { var grouped: [String: [Treatment]] = [:] - var roots: [UUID: Treatment] = [:] for treatment in allTreatments { let key = treatment.hourKey @@ -1571,11 +1600,8 @@ class TreatmentsViewModel: ObservableObject { grouped[key] = [] } grouped[key]?.append(treatment) - if let meal = treatment.trioMeal, !meal.isFPUChild, let fpuID = meal.fpuID { - roots[fpuID] = treatment - } } - rootMealsByFPUID = roots + rootMealsByFPUID = Treatment.rootMealsByFPUID(allTreatments) // Sort treatments within each hour for key in grouped.keys { diff --git a/LoopFollow/Treatments/TrioMealTreatment.swift b/LoopFollow/Treatments/TrioMealTreatment.swift index ed00e7398..b87559a07 100644 --- a/LoopFollow/Treatments/TrioMealTreatment.swift +++ b/LoopFollow/Treatments/TrioMealTreatment.swift @@ -8,12 +8,12 @@ import Foundation /// Nightscout's `_id` identifies the document; `id` is Trio's meal UUID. FPU children are /// separate documents whose `id` equals the root's `fpuID`, so either value works as a handle /// for Trio's remote edit/delete commands. A document without `fpuID` is a root. -struct TrioMealTreatment: Equatable { +struct TrioMealTreatment: Codable, Equatable { static let pastEditWindow: TimeInterval = 24 * 3600 static let futureEditWindow: TimeInterval = 12 * 3600 static let requiredRemoteCommands: Set = [TRCCommandType.editMeal.rawValue, TRCCommandType.deleteMeal.rawValue] - let nightscoutID: String + let nightscoutID: String? let mealID: UUID let fpuID: UUID? let date: TimeInterval @@ -42,7 +42,7 @@ struct TrioMealTreatment: Equatable { let rawNote = (entry["notes"] as? String) ?? (entry["foodType"] as? String) let trimmedNote = rawNote?.trimmingCharacters(in: .whitespacesAndNewlines) - nightscoutID = entry["_id"] as? String ?? "" + nightscoutID = entry["_id"] as? String self.mealID = mealID self.fpuID = fpuID self.date = date diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 06c5e1f76..8f0f1187b 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -219,6 +219,8 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { // (this controller is built only after storage is ready, so it never fires // while we're alive); retained one release as a safety net. notificationCenter.addObserver(self, selector: #selector(handleBFUReloadCompleted), name: .bfuReloadCompleted, object: nil) + // A remote meal edit/delete changes the carb marks on the chart, whichever screen sent it. + notificationCenter.addObserver(self, selector: #selector(handleRemoteMealCommandDidComplete), name: .remoteMealCommandDidComplete, object: nil) #if !targetEnvironment(macCatalyst) notificationCenter.addObserver(self, selector: #selector(navigateOnLAForeground), name: .liveActivityDidForeground, object: nil) @@ -681,6 +683,10 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { runMigrationsIfNeeded() } + @objc func handleRemoteMealCommandDidComplete() { + TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date()) + } + @objc func handleBFUReloadCompleted() { // Show the loading overlay so the user sees feedback during the 2-5s // while tasks re-run with the now-correct credentials. Tasks scheduled From 22df04c7d69e8e10b168c8a456a3efc3f1fb884b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 3 Oct 2026 22:34:08 +0200 Subject: [PATCH 17/84] Update fastlane to 2.240.1 and run workflows on xcode-27 and ubuntu-26.04 (#767) * Update fastlane to 2.240.1 and move workflows off ubuntu-latest and macos-26 The Gemfile pins fastlane 2.240.1, which brings rubyzip and json onto version ranges without open security advisories. Jobs that ran on ubuntu-latest now run on ubuntu-26.04, since the ubuntu-latest label is about to move to Ubuntu 26 and GitHub posts a notice about it on every run. The build job runs on the xcode-27 runner image and selects Xcode 27.0. That image only ships Xcode 27, so the xcode-select path points at Xcode_27.0.app. * Run all macOS jobs on macos-26 The build job stays on Xcode 26.2. The certificate, identifier and secret validation jobs move from macos-15 to macos-26, so every workflow runs on either macos-26 or ubuntu-26.04. * Run all macOS jobs on the xcode-27 runner image The build job selects Xcode 27.0, which is the default on that image. The certificate, identifier and secret validation jobs run on the same image, so every workflow runs on either xcode-27 or ubuntu-26.04. --- .github/workflows/add_identifiers.yml | 2 +- .github/workflows/auto_version_dev.yml | 2 +- .github/workflows/build_LoopFollow.yml | 8 +-- .github/workflows/create_certs.yml | 4 +- .github/workflows/lint.yml | 2 +- .github/workflows/tag_on_main.yml | 2 +- .github/workflows/validate_secrets.yml | 4 +- .github/workflows/warn_main_pr.yml | 2 +- Gemfile | 2 +- Gemfile.lock | 94 +++++++++++++++----------- 10 files changed, 69 insertions(+), 53 deletions(-) diff --git a/.github/workflows/add_identifiers.yml b/.github/workflows/add_identifiers.yml index 702e7a91d..4ebd4b26a 100644 --- a/.github/workflows/add_identifiers.yml +++ b/.github/workflows/add_identifiers.yml @@ -12,7 +12,7 @@ jobs: identifiers: name: Add Identifiers needs: validate - runs-on: macos-15 + runs-on: xcode-27 steps: # Checks-out the repo - name: Checkout Repo diff --git a/.github/workflows/auto_version_dev.yml b/.github/workflows/auto_version_dev.yml index a2adf3c3d..1ae91da8f 100644 --- a/.github/workflows/auto_version_dev.yml +++ b/.github/workflows/auto_version_dev.yml @@ -36,7 +36,7 @@ on: jobs: bump-version: if: ${{ !github.event.repository.fork }} - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 steps: - name: Checkout repository diff --git a/.github/workflows/build_LoopFollow.yml b/.github/workflows/build_LoopFollow.yml index e6e1a5dda..dfb110d7f 100644 --- a/.github/workflows/build_LoopFollow.yml +++ b/.github/workflows/build_LoopFollow.yml @@ -16,7 +16,7 @@ env: jobs: # use a single runner for these sequential steps check_status: - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 name: Check status to decide whether to build permissions: contents: write @@ -154,7 +154,7 @@ jobs: cancel_when_no_build: needs: check_status name: Cancel run when there is nothing to build - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 permissions: actions: write if: | @@ -189,7 +189,7 @@ jobs: build: name: Build needs: [check_certs, check_status] - runs-on: macos-26 + runs-on: xcode-27 permissions: contents: write if: @@ -199,7 +199,7 @@ jobs: (vars.SCHEDULED_SYNC != 'false' && needs.check_status.outputs.NEW_COMMITS == 'true' ) steps: - name: Select Xcode version - run: "sudo xcode-select --switch /Applications/Xcode_26.2.app/Contents/Developer" + run: "sudo xcode-select --switch /Applications/Xcode_27.0.app/Contents/Developer" - name: Checkout Repo for building uses: actions/checkout@v5 diff --git a/.github/workflows/create_certs.yml b/.github/workflows/create_certs.yml index c284e397a..2f8bde21b 100644 --- a/.github/workflows/create_certs.yml +++ b/.github/workflows/create_certs.yml @@ -20,7 +20,7 @@ jobs: create_certs: name: Certificates needs: validate - runs-on: macos-15 + runs-on: xcode-27 outputs: new_certificate_needed: ${{ steps.set_output.outputs.new_certificate_needed }} @@ -92,7 +92,7 @@ jobs: nuke_certs: name: Nuke certificates needs: [validate, create_certs] - runs-on: macos-15 + runs-on: xcode-27 if: ${{ (needs.create_certs.outputs.new_certificate_needed == 'true' && vars.ENABLE_NUKE_CERTS == 'true') || vars.FORCE_NUKE_CERTS == 'true' }} steps: - name: Output from step id 'check_certs' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9d5005aa1..9da63a7b1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -22,7 +22,7 @@ jobs: # Run on open/sync/reopen always; on 'edited' only when the base branch # actually changed, so routine title/description edits don't re-lint. if: ${{ github.event.action != 'edited' || github.event.changes.base != null }} - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 container: swift:6.0 steps: - name: Checkout diff --git a/.github/workflows/tag_on_main.yml b/.github/workflows/tag_on_main.yml index 388a2b537..24ab348f1 100644 --- a/.github/workflows/tag_on_main.yml +++ b/.github/workflows/tag_on_main.yml @@ -25,7 +25,7 @@ on: jobs: tag: if: ${{ !github.event.repository.fork }} - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 permissions: contents: write diff --git a/.github/workflows/validate_secrets.yml b/.github/workflows/validate_secrets.yml index 415ba0019..0c87b7d29 100644 --- a/.github/workflows/validate_secrets.yml +++ b/.github/workflows/validate_secrets.yml @@ -5,7 +5,7 @@ on: [workflow_call, workflow_dispatch] jobs: validate-access-token: name: Access - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 env: GH_PAT: ${{ secrets.GH_PAT }} GH_TOKEN: ${{ secrets.GH_PAT }} @@ -105,7 +105,7 @@ jobs: validate-fastlane-secrets: name: Fastlane needs: [validate-access-token] - runs-on: macos-15 + runs-on: xcode-27 env: GH_PAT: ${{ secrets.GH_PAT }} GH_TOKEN: ${{ secrets.GH_PAT }} diff --git a/.github/workflows/warn_main_pr.yml b/.github/workflows/warn_main_pr.yml index 3f918f8f8..867c4ffbb 100644 --- a/.github/workflows/warn_main_pr.yml +++ b/.github/workflows/warn_main_pr.yml @@ -9,7 +9,7 @@ on: jobs: warn: if: ${{ !github.event.repository.fork }} - runs-on: ubuntu-latest + runs-on: ubuntu-26.04 permissions: pull-requests: write diff --git a/Gemfile b/Gemfile index 502d6331b..599db6eab 100644 --- a/Gemfile +++ b/Gemfile @@ -1,2 +1,2 @@ source "https://rubygems.org" -gem "fastlane", "2.237.0" +gem "fastlane", "2.240.1" diff --git a/Gemfile.lock b/Gemfile.lock index 8e70830ea..93511b5ac 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -30,6 +30,7 @@ GEM base64 (0.3.0) benchmark (0.5.0) bigdecimal (4.1.2) + cgi (0.5.2) claide (1.1.0) colored (1.2) colored2 (3.1.2) @@ -42,38 +43,26 @@ GEM domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) + erb (6.0.7) excon (1.5.0) logger - faraday (1.10.6) - faraday-em_http (~> 1.0) - faraday-em_synchrony (~> 1.0) - faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0) - faraday-multipart (~> 1.0) - faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.0) - faraday-patron (~> 1.0) - faraday-rack (~> 1.0) - faraday-retry (~> 1.0) - ruby2_keywords (>= 0.0.4) + faraday (2.14.4) + faraday-net_http (>= 2.0, < 3.5) + json + logger faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) http-cookie (>= 1.0.0) - faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.1) - faraday-excon (1.1.0) - faraday-httpclient (1.0.1) + faraday-follow_redirects (0.5.0) + faraday (>= 1, < 3) faraday-multipart (1.2.0) multipart-post (~> 2.0) - faraday-net_http (1.0.2) - faraday-net_http_persistent (1.2.0) - faraday-patron (1.0.0) - faraday-rack (1.0.0) - faraday-retry (1.0.4) - faraday_middleware (1.2.1) - faraday (~> 1.0) + faraday-net_http (3.4.4) + net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) fastimage (2.4.1) - fastlane (2.237.0) + fastlane (2.240.1) CFPropertyList (>= 2.3, < 5.0.0) abbrev (~> 0.1) addressable (>= 2.9.0, < 3.0.0) @@ -83,24 +72,27 @@ GEM base64 (~> 0.2) benchmark (>= 0.1.0) bundler (>= 2.4.0, < 5.0.0) + cgi (~> 0.4) colored (~> 1.2) commander (~> 4.6) csv (~> 3.3) dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) excon (>= 0.71.0, < 2.0.0) - faraday (~> 1.0) - faraday-cookie_jar (~> 0.0.6) - faraday_middleware (~> 1.0) + faraday (~> 2.7) + faraday-cookie_jar (~> 0.0.8) + faraday-follow_redirects (~> 0.3) + faraday-multipart (~> 1.0) + faraday-retry (~> 2.0) fastimage (>= 2.1.0, < 3.0.0) fastlane-sirp (>= 1.1.0) gh_inspector (>= 1.1.2, < 2.0.0) - google-apis-androidpublisher_v3 (~> 0.3) + google-apis-androidpublisher_v3 (~> 0.99) google-apis-playcustomapp_v1 (~> 0.1) - google-cloud-env (>= 1.6.0, < 2.3.0) + google-cloud-env (>= 1.6.0, < 2.4.0) google-cloud-storage (~> 1.31) highline (~> 2.0) - http-cookie (~> 1.0.5) + irb (>= 1.8) json (< 3.0.0) jwt (>= 2.10.3, < 4) logger (>= 1.6, < 2.0) @@ -113,11 +105,11 @@ GEM optparse (>= 0.1.1, < 1.0.0) ostruct (>= 0.1.0) plist (>= 3.1.0, < 4.0.0) - rubyzip (>= 2.0.0, < 3.0.0) - security (= 0.1.5) + rubyzip (>= 3.4.0, < 4.0.0) + security (~> 0.3) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (~> 3) + terminal-table (~> 4) tty-screen (>= 0.6.3, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) @@ -172,6 +164,12 @@ GEM domain_name (~> 0.5) httpclient (2.9.0) mutex_m + io-console (0.9.4) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) jmespath (1.6.2) json (2.20.0) jwt (3.2.0) @@ -184,14 +182,31 @@ GEM mutex_m (0.3.0) nanaimo (0.4.0) naturally (2.3.0) + net-http (0.9.1) + uri (>= 0.11.1) nkf (0.3.0) optparse (0.8.1) os (1.1.4) ostruct (0.6.3) plist (3.7.2) + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) pstore (0.2.1) public_suffix (7.0.5) rake (13.4.2) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.1.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + reline (0.7.0) + io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) @@ -199,9 +214,8 @@ GEM retriable (3.8.0) rexml (3.4.4) rouge (3.28.0) - ruby2_keywords (0.0.5) - rubyzip (2.4.1) - security (0.1.5) + rubyzip (3.7.0) + security (0.3.0) signet (0.22.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) @@ -210,15 +224,17 @@ GEM CFPropertyList naturally terminal-notifier (2.0.0) - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) trailblazer-option (0.1.2) + tsort (0.2.0) tty-cursor (0.7.1) tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) unicode-display_width (2.6.0) + uri (1.1.1) word_wrap (1.0.0) xcodeproj (1.28.1) CFPropertyList (>= 2.3.3, < 4.0) @@ -239,7 +255,7 @@ PLATFORMS ruby DEPENDENCIES - fastlane (= 2.237.0) + fastlane (= 2.240.1) BUNDLED WITH 4.0.12 From 34a162c3c08b6d23433adfe3ea0f71a8719542ba Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 3 Oct 2026 20:34:18 +0000 Subject: [PATCH 18/84] CI: Bump dev version to 7.1.1 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 04259f731..a0048d8ce 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.0 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.1 From fc2e90c62235ada8cad5f9bca782f302ecc8ef24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 10 Oct 2026 17:28:41 +0200 Subject: [PATCH 19/84] Bump json gem to 2.21.2 (#772) --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 93511b5ac..67110040a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -171,7 +171,7 @@ GEM rdoc (>= 4.0.0) reline (>= 0.4.2) jmespath (1.6.2) - json (2.20.0) + json (2.21.2) jwt (3.2.0) base64 logger (1.7.0) From c49c6e77657cfa70d6602f51fa2d190d711182e4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 10 Oct 2026 15:28:50 +0000 Subject: [PATCH 20/84] CI: Bump dev version to 7.1.2 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index a0048d8ce..bfbc33382 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.1 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.2 From 9d893bd684af5e57868f498d1e210ffa283f35a0 Mon Sep 17 00:00:00 2001 From: RAMAZAN TUNCER Date: Sat, 10 Oct 2026 19:23:22 +0300 Subject: [PATCH 21/84] Fix launch crash on non-string Nightscout treatment timestamps (#759) Treatment parsers force-cast `timestamp`/`created_at` to String after a `!= nil` check. A treatment carrying a JSON null or numeric timestamp passes the nil check and traps in swift_dynamicCast on the main queue, so the app aborts on every launch while that record is within the download window. Add NightscoutUtils.treatmentDate(from:) which accepts ISO 8601 strings, epoch milliseconds/seconds, and falls back to created_at on null; use it in the bolus, SMB, carb, note and stats parsers. Replace the remaining forced casts in device status, SAge, CAge and IAge handling with conditional binding so malformed data is skipped instead of crashing. Co-authored-by: Claude Fable 5.1 --- LoopFollow/Controllers/Nightscout/CAge.swift | 6 ++- .../Nightscout/DeviceStatusLoop.swift | 12 +++-- .../Nightscout/DeviceStatusOpenAPS.swift | 4 +- LoopFollow/Controllers/Nightscout/IAge.swift | 2 +- LoopFollow/Controllers/Nightscout/SAge.swift | 10 ++-- .../Nightscout/Treatments/Bolus.swift | 11 +--- .../Nightscout/Treatments/Carbs.swift | 11 +--- .../Nightscout/Treatments/Notes.swift | 11 +--- .../Nightscout/Treatments/SMB.swift | 11 +--- LoopFollow/Helpers/NightscoutUtils.swift | 26 +++++++++ LoopFollow/Stats/StatsDataFetcher.swift | 33 ++---------- Tests/NightscoutTreatmentDateTests.swift | 53 +++++++++++++++++++ 12 files changed, 110 insertions(+), 80 deletions(-) create mode 100644 Tests/NightscoutTreatmentDateTests.swift diff --git a/LoopFollow/Controllers/Nightscout/CAge.swift b/LoopFollow/Controllers/Nightscout/CAge.swift index 7e20bfade..ae02c46b4 100644 --- a/LoopFollow/Controllers/Nightscout/CAge.swift +++ b/LoopFollow/Controllers/Nightscout/CAge.swift @@ -39,7 +39,11 @@ extension MainViewController { .withTime, .withDashSeparatorInDate, .withColonSeparatorInTime] - Storage.shared.cageInsertTime.value = formatter.date(from: lastCageString)?.timeIntervalSince1970 as! TimeInterval + guard let cageInsertTime = formatter.date(from: lastCageString)?.timeIntervalSince1970 else { + LogManager.shared.log(category: .nightscout, message: "updateCage: unparseable created_at '\(lastCageString)'") + return + } + Storage.shared.cageInsertTime.value = cageInsertTime if let cageTime = formatter.date(from: lastCageString)?.timeIntervalSince1970 { let now = dateTimeUtils.getNowTimeIntervalUTC() let secondsAgo = now - cageTime diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index bd5699726..423c807ff 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -64,9 +64,11 @@ extension MainViewController { latestCOB = cobMetric } - if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject] { - let prediction = predictdata["values"] as! [Double] - Observable.shared.predictionText.value = Localizer.toDisplayUnits(String(Int(round(prediction.last!)))) + if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject], + let prediction = predictdata["values"] as? [Double], + let lastPrediction = prediction.last + { + Observable.shared.predictionText.value = Localizer.toDisplayUnits(String(Int(round(lastPrediction)))) Observable.shared.predictionColor.value = .purple if Storage.shared.downloadPrediction.value, previousLastLoopTime < lastLoopTime || predictionData.isEmpty { predictionData.removeAll() @@ -108,7 +110,9 @@ extension MainViewController { Observable.shared.deviceRecBolus.value = nil } if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] { - if let tempBasalTime = formatter.date(from: (loopStatus["timestamp"] as! String))?.timeIntervalSince1970 { + if let tempBasalString = loopStatus["timestamp"] as? String, + let tempBasalTime = formatter.date(from: tempBasalString)?.timeIntervalSince1970 + { var lastBGTime = lastLoopTime if bgData.count > 0 { lastBGTime = bgData[bgData.count - 1].date diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index 7dfdb4cdb..f15bef344 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -220,7 +220,9 @@ extension MainViewController { } if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] { - if let tempBasalTime = formatter.date(from: (loopStatus["timestamp"] as! String))?.timeIntervalSince1970 { + if let tempBasalString = loopStatus["timestamp"] as? String, + let tempBasalTime = formatter.date(from: tempBasalString)?.timeIntervalSince1970 + { var lastBGTime = updatedTime ?? Date().timeIntervalSince1970 if bgData.count > 0 { lastBGTime = bgData[bgData.count - 1].date diff --git a/LoopFollow/Controllers/Nightscout/IAge.swift b/LoopFollow/Controllers/Nightscout/IAge.swift index 71909cc49..d1f28dac7 100644 --- a/LoopFollow/Controllers/Nightscout/IAge.swift +++ b/LoopFollow/Controllers/Nightscout/IAge.swift @@ -44,7 +44,7 @@ extension MainViewController { .withDashSeparatorInDate, .withColonSeparatorInTime] - if let iageTime = formatter.date(from: (lastIageString as! String))?.timeIntervalSince1970 { + if let iageTime = formatter.date(from: lastIageString)?.timeIntervalSince1970 { Storage.shared.iageInsertTime.value = iageTime let now = dateTimeUtils.getNowTimeIntervalUTC() let secondsAgo = now - iageTime diff --git a/LoopFollow/Controllers/Nightscout/SAge.swift b/LoopFollow/Controllers/Nightscout/SAge.swift index 580d39f1a..596974e12 100644 --- a/LoopFollow/Controllers/Nightscout/SAge.swift +++ b/LoopFollow/Controllers/Nightscout/SAge.swift @@ -36,14 +36,18 @@ extension MainViewController { return } currentSage = data[0] - var lastSageString = data[0].created_at + let lastSageString = data[0].created_at let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withFullDate, .withTime, .withDashSeparatorInDate, .withColonSeparatorInTime] - Storage.shared.sageInsertTime.value = formatter.date(from: lastSageString)?.timeIntervalSince1970 as! TimeInterval + guard let sageInsertTime = formatter.date(from: lastSageString)?.timeIntervalSince1970 else { + LogManager.shared.log(category: .nightscout, message: "updateSage: unparseable created_at '\(lastSageString)'") + return + } + Storage.shared.sageInsertTime.value = sageInsertTime // -- Auto-snooze CGM start ──────────────────────────────────────────────── let now = Date() @@ -62,7 +66,7 @@ extension MainViewController { } } - if let sageTime = formatter.date(from: (lastSageString as! String))?.timeIntervalSince1970 { + if let sageTime = formatter.date(from: lastSageString)?.timeIntervalSince1970 { let now = dateTimeUtils.getNowTimeIntervalUTC() let secondsAgo = now - sageTime diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift b/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift index 58314a4d9..9fe4ba7b1 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift @@ -11,16 +11,7 @@ extension MainViewController { var lastFoundIndex = 0 for currentEntry in entries.reversed() { - var bolusDate: String - if currentEntry["timestamp"] != nil { - bolusDate = currentEntry["timestamp"] as! String - } else if currentEntry["created_at"] != nil { - bolusDate = currentEntry["created_at"] as! String - } else { - continue - } - - guard let parsedDate = NightscoutUtils.parseDate(bolusDate), + guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry), let bolus = currentEntry["insulin"] as? Double else { continue } let dateTimeStamp = parsedDate.timeIntervalSince1970 diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift index 23e2e0427..e9335efc4 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift @@ -13,18 +13,9 @@ extension MainViewController { var lastFoundSmb = 0 for currentEntry in entries.reversed() { - var carbDate: String - if currentEntry["timestamp"] != nil { - carbDate = currentEntry["timestamp"] as! String - } else if currentEntry["created_at"] != nil { - carbDate = currentEntry["created_at"] as! String - } else { - continue - } - let absorptionTime = currentEntry["absorptionTime"] as? Int ?? 0 - guard let parsedDate = NightscoutUtils.parseDate(carbDate), + guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry), let carbs = currentEntry["carbs"] as? Double else { continue } let dateTimeStamp = parsedDate.timeIntervalSince1970 diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift b/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift index cc4435212..9615f53ce 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift @@ -13,16 +13,7 @@ extension MainViewController { for currentEntry in entries.reversed() { guard let currentEntry = currentEntry as? [String: AnyObject] else { continue } - var date: String - if currentEntry["timestamp"] != nil { - date = currentEntry["timestamp"] as! String - } else if currentEntry["created_at"] != nil { - date = currentEntry["created_at"] as! String - } else { - continue - } - - if let parsedDate = NightscoutUtils.parseDate(date) { + if let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry) { let dateTimeStamp = parsedDate.timeIntervalSince1970 let sgv = findNearestBGbyTime(needle: dateTimeStamp, haystack: bgData, startingIndex: lastFoundIndex) lastFoundIndex = sgv.foundIndex diff --git a/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift b/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift index f896e4941..012bed356 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift @@ -10,16 +10,7 @@ extension MainViewController { var lastFoundIndex = 0 for currentEntry in entries.reversed() { - var bolusDate: String - if currentEntry["timestamp"] != nil { - bolusDate = currentEntry["timestamp"] as! String - } else if currentEntry["created_at"] != nil { - bolusDate = currentEntry["created_at"] as! String - } else { - continue - } - - guard let parsedDate = NightscoutUtils.parseDate(bolusDate), + guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry), let bolus = currentEntry["insulin"] as? Double else { continue } let dateTimeStamp = parsedDate.timeIntervalSince1970 diff --git a/LoopFollow/Helpers/NightscoutUtils.swift b/LoopFollow/Helpers/NightscoutUtils.swift index 07a6246bc..1fa6c5bdb 100644 --- a/LoopFollow/Helpers/NightscoutUtils.swift +++ b/LoopFollow/Helpers/NightscoutUtils.swift @@ -274,6 +274,32 @@ class NightscoutUtils { task.resume() } + /// Effective time of a Nightscout treatment: `timestamp` when present, otherwise `created_at`. + /// Uploaders disagree on the value's type (ISO 8601 string, epoch milliseconds, or JSON `null`), + /// so never force-cast it; an unreadable value yields `nil` and the entry is skipped. + static func treatmentDate(from entry: [String: AnyObject]) -> Date? { + for key in ["timestamp", "created_at"] { + guard let value = entry[key], !(value is NSNull) else { continue } + return parseDateValue(value) + } + return nil + } + + static func parseDateValue(_ value: Any) -> Date? { + switch value { + case let string as String: + return parseDate(string) + case let number as NSNumber: + // Booleans are NSNumbers too; they are never a date. + if CFGetTypeID(number) == CFBooleanGetTypeID() { return nil } + let raw = number.doubleValue + // Epoch milliseconds are ~1e12; epoch seconds are ~1e9. + return Date(timeIntervalSince1970: raw > 1e11 ? raw / 1000 : raw) + default: + return nil + } + } + static func parseDate(_ rawString: String) -> Date? { var mutableDate = rawString diff --git a/LoopFollow/Stats/StatsDataFetcher.swift b/LoopFollow/Stats/StatsDataFetcher.swift index 18353dce0..71f84b038 100644 --- a/LoopFollow/Stats/StatsDataFetcher.swift +++ b/LoopFollow/Stats/StatsDataFetcher.swift @@ -201,16 +201,7 @@ class StatsDataFetcher { var lastFoundIndex = 0 for currentEntry in bolusEntries.reversed() { - var bolusDate: String - if currentEntry["timestamp"] != nil { - bolusDate = currentEntry["timestamp"] as! String - } else if currentEntry["created_at"] != nil { - bolusDate = currentEntry["created_at"] as! String - } else { - continue - } - - guard let parsedDate = NightscoutUtils.parseDate(bolusDate), + guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry), let bolus = currentEntry["insulin"] as? Double else { continue } let dateTimeStamp = parsedDate.timeIntervalSince1970 @@ -250,16 +241,7 @@ class StatsDataFetcher { var lastFoundIndex = 0 for currentEntry in smbEntries.reversed() { - var bolusDate: String - if currentEntry["timestamp"] != nil { - bolusDate = currentEntry["timestamp"] as! String - } else if currentEntry["created_at"] != nil { - bolusDate = currentEntry["created_at"] as! String - } else { - continue - } - - guard let parsedDate = NightscoutUtils.parseDate(bolusDate), + guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry), let bolus = currentEntry["insulin"] as? Double else { continue } let dateTimeStamp = parsedDate.timeIntervalSince1970 @@ -296,18 +278,9 @@ class StatsDataFetcher { var lastFoundBolus = 0 for currentEntry in carbEntries.reversed() { - var carbDate: String - if currentEntry["timestamp"] != nil { - carbDate = currentEntry["timestamp"] as! String - } else if currentEntry["created_at"] != nil { - carbDate = currentEntry["created_at"] as! String - } else { - continue - } - let absorptionTime = currentEntry["absorptionTime"] as? Int ?? 0 - guard let parsedDate = NightscoutUtils.parseDate(carbDate), + guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry), let carbs = currentEntry["carbs"] as? Double else { continue } let dateTimeStamp = parsedDate.timeIntervalSince1970 diff --git a/Tests/NightscoutTreatmentDateTests.swift b/Tests/NightscoutTreatmentDateTests.swift new file mode 100644 index 000000000..abfb74105 --- /dev/null +++ b/Tests/NightscoutTreatmentDateTests.swift @@ -0,0 +1,53 @@ +// LoopFollow +// NightscoutTreatmentDateTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct NightscoutTreatmentDateTests { + private typealias Entry = [String: AnyObject] + + private let expected = Date(timeIntervalSince1970: 1_787_000_000) + + @Test("parses an ISO 8601 timestamp string") + func parsesStringTimestamp() { + let entry: Entry = ["timestamp": "2026-08-17T20:53:20.000Z" as AnyObject] + #expect(NightscoutUtils.treatmentDate(from: entry) == expected) + } + + @Test("falls back to created_at when timestamp is missing") + func fallsBackToCreatedAt() { + let entry: Entry = ["created_at": "2026-08-17T20:53:20Z" as AnyObject] + #expect(NightscoutUtils.treatmentDate(from: entry) == expected) + } + + @Test("falls back to created_at when timestamp is JSON null") + func nullTimestampFallsBack() { + let entry: Entry = [ + "timestamp": NSNull(), + "created_at": "2026-08-17T20:53:20Z" as AnyObject, + ] + #expect(NightscoutUtils.treatmentDate(from: entry) == expected) + } + + @Test("accepts epoch milliseconds and seconds") + func parsesNumericTimestamp() { + let millis: Entry = ["timestamp": NSNumber(value: 1_787_000_000_000)] + let seconds: Entry = ["timestamp": NSNumber(value: 1_787_000_000)] + #expect(NightscoutUtils.treatmentDate(from: millis) == expected) + #expect(NightscoutUtils.treatmentDate(from: seconds) == expected) + } + + @Test("returns nil instead of trapping on unsupported values") + func unsupportedValuesReturnNil() { + let bool: Entry = ["timestamp": NSNumber(value: true)] + let array: Entry = ["timestamp": ["2026-08-17T20:53:20Z"] as AnyObject] + let garbage: Entry = ["timestamp": "not a date" as AnyObject] + let empty: Entry = [:] + #expect(NightscoutUtils.treatmentDate(from: bool) == nil) + #expect(NightscoutUtils.treatmentDate(from: array) == nil) + #expect(NightscoutUtils.treatmentDate(from: garbage) == nil) + #expect(NightscoutUtils.treatmentDate(from: empty) == nil) + } +} From 5ae9867d79054570fd94748f194d86f717846c53 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 10 Oct 2026 16:23:32 +0000 Subject: [PATCH 22/84] CI: Bump dev version to 7.1.3 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index bfbc33382..56bec5b24 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.2 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.3 From 0684ccef829e04b67506230be5c0e1a730508f1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 10 Oct 2026 18:25:19 +0200 Subject: [PATCH 23/84] Keep no-op PR edits from cancelling in-flight lint runs (#773) --- .github/workflows/lint.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9da63a7b1..7044fdd31 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,7 +10,9 @@ on: workflow_dispatch: concurrency: - group: lint-${{ github.ref }} + # Edits that don't change the base branch get their own group, so they never + # cancel an in-flight lint run. + group: lint-${{ github.ref }}${{ github.event.action == 'edited' && github.event.changes.base == null && format('-noop-{0}', github.run_id) || '' }} cancel-in-progress: true permissions: From ab481da9cc1aeac9ec961e68c564ce18a23e2e10 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 10 Oct 2026 16:25:29 +0000 Subject: [PATCH 24/84] CI: Bump dev version to 7.1.4 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 56bec5b24..abdb78fec 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.3 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.4 From 79b03265d8115f2f0dbc30c51afe967ae7007dfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 15 Oct 2026 00:18:32 +0200 Subject: [PATCH 25/84] Device status history graphs and scrubbed-time values (#764) * Add device status history graphs and scrubbed-time values Cache the loop values from Nightscout devicestatus on the phone. The Show Days Back window is downloaded once in pages of 100 records, after that only missing ranges are fetched, and changing the Nightscout address drops the cache. Add optional IOB, COB, sensitivity ratio and smoothed BG graphs, shown as separate graphs under the main graph or in the bottom of the main graph. While scrubbing, the BG display and info table show the values at that time together with the forecast from that loop cycle, and the pill only lists treatments. A tap shows BG and treatments. Add a Smoothed BG info row and remove the divider between the BG display and the info table. * Remove the scrub pill and the main-graph legend While scrubbing, the BG display and info table show the values at that time, so the graph only shows the indicator and the forecast. The jump to now button is hidden while scrubbing. * Fade the jump to now button back in after scrubbing The button hides while scrubbing and fades in a moment after the finger lifts, so it is not in the way while reading the values. * Hide the jump to now button while panning and zooming The button hides while the chart is panned, zoomed or scrubbed and fades back in a moment after. --- LoopFollow/Charts/BGChartHistory.swift | 322 ++++++++++++++++ LoopFollow/Charts/BGChartHistoryPanes.swift | 113 ++++++ LoopFollow/Charts/BGChartModel.swift | 27 ++ LoopFollow/Charts/BGChartView.swift | 324 +++++++++++++--- .../Controllers/Nightscout/DeviceStatus.swift | 4 +- .../Nightscout/DeviceStatusHistory.swift | 362 ++++++++++++++++++ .../DeviceStatusHistorySample.swift | 192 ++++++++++ .../Nightscout/DeviceStatusHistoryState.swift | 136 +++++++ .../Nightscout/DeviceStatusOpenAPS.swift | 5 + .../Nightscout/ProfileManager.swift | 45 ++- .../Helpers/HistoryCurvePlacement.swift | 17 + LoopFollow/InfoTable/InfoTableView.swift | 22 +- LoopFollow/InfoTable/InfoType.swift | 3 +- LoopFollow/InfoTable/RetroSnapshot.swift | 269 +++++++++++++ LoopFollow/Settings/GraphSettingsView.swift | 27 ++ LoopFollow/Storage/Storage.swift | 5 + .../ViewControllers/BGDisplayView.swift | 61 ++- LoopFollow/ViewControllers/MainHomeView.swift | 6 - .../ViewControllers/MainViewController.swift | 5 + Tests/DeviceStatusHistoryTests.swift | 285 ++++++++++++++ 20 files changed, 2124 insertions(+), 106 deletions(-) create mode 100644 LoopFollow/Charts/BGChartHistory.swift create mode 100644 LoopFollow/Charts/BGChartHistoryPanes.swift create mode 100644 LoopFollow/Controllers/Nightscout/DeviceStatusHistory.swift create mode 100644 LoopFollow/Controllers/Nightscout/DeviceStatusHistorySample.swift create mode 100644 LoopFollow/Controllers/Nightscout/DeviceStatusHistoryState.swift create mode 100644 LoopFollow/Helpers/HistoryCurvePlacement.swift create mode 100644 LoopFollow/InfoTable/RetroSnapshot.swift create mode 100644 Tests/DeviceStatusHistoryTests.swift diff --git a/LoopFollow/Charts/BGChartHistory.swift b/LoopFollow/Charts/BGChartHistory.swift new file mode 100644 index 000000000..8d625d948 --- /dev/null +++ b/LoopFollow/Charts/BGChartHistory.swift @@ -0,0 +1,322 @@ +// LoopFollow +// BGChartHistory.swift + +import Foundation +import SwiftUI + +/// Chart series built from DeviceStatusHistory: the panes under the main +/// chart, the smoothed BG line, the selection pill values and the forecast of +/// a selected reading. +enum BGChartHistory { + /// Consecutive samples further apart than this are not joined by a line. + static let maxJoinGap: TimeInterval = 15 * 60 + + struct Point: Identifiable { + let date: Date + let value: Double + /// Index of the unbroken run this point belongs to (see `maxJoinGap`). + let segment: Int + var id: TimeInterval { date.timeIntervalSince1970 } + } + + enum PaneKind: Int, CaseIterable { + case iob, cob, sensitivityRatio + + var title: String { + switch self { + case .iob: return "IOB" + case .cob: return "COB" + case .sensitivityRatio: return "Sens. Ratio" + } + } + + var color: Color { + switch self { + case .iob: return Color("Insulin") + case .cob: return Color(.systemOrange) + case .sensitivityRatio: return Color(.systemIndigo) + } + } + + var fillsArea: Bool { + self == .iob || self == .cob + } + + var isEnabled: Bool { + switch self { + case .iob: return Storage.shared.showIOBGraph.value + case .cob: return Storage.shared.showCOBGraph.value + case .sensitivityRatio: return Storage.shared.showSensitivityRatioGraph.value && Storage.shared.device.value != "Loop" + } + } + + func value(of sample: DeviceStatusHistorySample) -> Double? { + switch self { + case .iob: return sample.iob + case .cob: return sample.cob + case .sensitivityRatio: return sample.sensitivityRatio + } + } + + func format(_ value: Double) -> String { + switch self { + case .iob: + return Localizer.formatToLocalizedString(value, maxFractionDigits: 2) + "U" + case .cob: + return Localizer.formatToLocalizedString(value, maxFractionDigits: 0) + "g" + case .sensitivityRatio: + return Localizer.formatToLocalizedString(value * 100, maxFractionDigits: 0) + "%" + } + } + + func axisLabel(_ value: Double) -> String { + switch self { + case .iob: + return Localizer.formatToLocalizedString(value, maxFractionDigits: 1) + "U" + default: + return format(value) + } + } + } + + struct Pane: Identifiable { + let kind: PaneKind + let points: [Point] + let yDomain: ClosedRange + /// Values labelled on the axis and drawn as grid lines. + let ticks: [Double] + /// Bottom of the area fill. + let baseline: Double + var id: Int { kind.rawValue } + } + + /// History curves drawn as lines in the bottom band of the main chart, each + /// scaled to its own range. + struct Overlay { + struct Series: Identifiable { + let kind: PaneKind + /// Negative IOB, drawn mirrored above the baseline. + let isNegative: Bool + let points: [Point] + var id: String { "\(kind.rawValue)-\(isNegative)" } + var color: Color { isNegative ? BGChartHistory.negativeIOBColor : kind.color } + } + + let series: [Series] + } + + static let negativeIOBColor = Color(.systemPink) + + static func overlay(kinds: [PaneKind], samples: [DeviceStatusHistorySample], bandTop: Double) -> Overlay? { + var series: [Overlay.Series] = [] + + for kind in kinds { + let points = points(from: samples, value: kind.value(of:)) + guard let minValue = points.map(\.value).min(), + let maxValue = points.map(\.value).max() + else { continue } + + if kind == .iob { + // Magnitude scale: negative IOB is mirrored upward in its own color. + let magnitude = max(abs(minValue), maxValue, 1).rounded(.up) + let (positive, negative) = splitAtZero(points) + series.append(.init(kind: kind, isNegative: false, points: positive.map { scaled($0, abs($0.value) / magnitude * bandTop) })) + if !negative.isEmpty { + series.append(.init(kind: kind, isNegative: true, points: negative.map { scaled($0, abs($0.value) / magnitude * bandTop) })) + } + continue + } + + let lower: Double + let upper: Double + switch kind { + case .cob: + lower = 0 + upper = max(10, (maxValue / 10).rounded(.up) * 10) + case .sensitivityRatio: + // Same range as the ratio graph, so a flat curve sits mid-band. + lower = min(minValue, 0.9) + upper = max(maxValue, 1.1) + case .iob: + continue + } + let span = max(upper - lower, .ulpOfOne) + series.append(.init(kind: kind, isNegative: false, points: points.map { scaled($0, ($0.value - lower) / span * bandTop) })) + } + + guard !series.isEmpty else { return nil } + return Overlay(series: series) + } + + private static func scaled(_ point: Point, _ value: Double) -> Point { + Point(date: point.date, value: value, segment: point.segment) + } + + /// Splits a curve into its positive and negative runs. A zero crossing ends + /// one run and starts the other at the interpolated crossing time, so both + /// meet the baseline there. + static func splitAtZero(_ points: [Point]) -> (positive: [Point], negative: [Point]) { + var positive: [Point] = [] + var negative: [Point] = [] + var segment = 0 + var previous: Point? + for point in points { + if let prev = previous { + if prev.segment != point.segment { + segment += 1 + } else if prev.value * point.value < 0 { + let fraction = abs(prev.value) / (abs(prev.value) + abs(point.value)) + let crossing = prev.date.addingTimeInterval(point.date.timeIntervalSince(prev.date) * fraction) + let zeroEnd = Point(date: crossing, value: 0, segment: segment) + if prev.value < 0 { negative.append(zeroEnd) } else { positive.append(zeroEnd) } + segment += 1 + let zeroStart = Point(date: crossing, value: 0, segment: segment) + if point.value < 0 { negative.append(zeroStart) } else { positive.append(zeroStart) } + } else if prev.value < 0, point.value == 0 { + // A reading of exactly zero belongs to the positive run; the + // negative run ends on it. + negative.append(Point(date: point.date, value: 0, segment: segment)) + segment += 1 + } else if prev.value == 0, point.value < 0 { + segment += 1 + negative.append(Point(date: prev.date, value: 0, segment: segment)) + } + } + let run = Point(date: point.date, value: point.value, segment: segment) + if point.value < 0 { negative.append(run) } else { positive.append(run) } + previous = point + } + return (positive, negative) + } + + struct ForecastCurve: Identifiable { + let name: String + let color: Color + let points: [(date: Date, value: Double)] + var id: String { name } + } + + struct Forecast { + let curves: [ForecastCurve] + /// Min/max envelope across the curves when the cone style is selected. + let cone: [(date: Date, yMin: Double, yMax: Double)] + } + + // MARK: - Building + + /// Splits the samples carrying a value into points, breaking the line at gaps. + static func points( + from samples: [DeviceStatusHistorySample], + value: (DeviceStatusHistorySample) -> Double? + ) -> [Point] { + var points: [Point] = [] + var segment = 0 + var previousDate: TimeInterval? + for sample in samples { + guard let v = value(sample) else { continue } + if let previousDate, sample.date - previousDate > maxJoinGap { + segment += 1 + } + points.append(Point(date: Date(timeIntervalSince1970: sample.date), value: v, segment: segment)) + previousDate = sample.date + } + return points + } + + static func pane(kind: PaneKind, samples: [DeviceStatusHistorySample]) -> Pane? { + let points = points(from: samples, value: kind.value(of:)) + guard let minValue = points.map(\.value).min(), + let maxValue = points.map(\.value).max() + else { return nil } + + let lower: Double + let upper: Double + var ticks: [Double] + switch kind { + case .iob: + lower = min(0, minValue) + upper = max(1, maxValue.rounded(.up)) + ticks = [0, upper] + case .cob: + lower = 0 + upper = max(10, (maxValue / 10).rounded(.up) * 10) + ticks = [0, upper] + case .sensitivityRatio: + lower = min(minValue, 0.9) + upper = max(maxValue, 1.1) + ticks = [1] + if minValue < 0.95 { ticks.insert(minValue, at: 0) } + if maxValue > 1.05 { ticks.append(maxValue) } + } + let pad = (upper - lower) * 0.08 + return Pane( + kind: kind, + points: points, + yDomain: (lower - pad) ... (upper + pad), + ticks: ticks, + baseline: kind == .iob || kind == .cob ? 0 : lower - pad + ) + } + + /// The forecast the loop made when it ran on the reading at `readingDate`, + /// cut to the Hours of Prediction setting. + static func forecast(forReadingAt readingDate: Date) -> Forecast? { + guard let stored = DeviceStatusHistory.shared.cycle(forReadingAt: readingDate)?.forecast else { return nil } + + let count = Int(Storage.shared.predictionToLoad.value * 12) + 1 + guard count > 1 else { return nil } + let minDisplay = Double(globalVariables.minDisplayGlucose) + let maxDisplay = Double(globalVariables.maxDisplayGlucose) + let step = DeviceStatusHistorySample.Forecast.step + + func curvePoints(_ values: [Int]) -> [(date: Date, value: Double)] { + values.prefix(count).enumerated().map { index, value in + ( + date: Date(timeIntervalSince1970: stored.start + Double(index) * step), + value: min(max(Double(value), minDisplay), maxDisplay) + ) + } + } + + if let main = stored.curves[DeviceStatusHistorySample.Forecast.loopCurve] { + return Forecast(curves: [ForecastCurve(name: "main", color: .purple, points: curvePoints(main))], cone: []) + } + + let names = DeviceStatusHistorySample.Forecast.openAPSCurves.filter { stored.curves[$0] != nil } + guard !names.isEmpty else { return nil } + + if Storage.shared.predictionDisplayType.value == .cone { + let arrays = names.compactMap { stored.curves[$0] } + let length = min(arrays.map(\.count).min() ?? 0, count) + let cone = (0 ..< length).map { index in + let values = arrays.map { Double($0[index]) } + var yMin = max(values.min() ?? 0, minDisplay) + var yMax = min(values.max() ?? 0, maxDisplay) + // Same Β±1 mg/dL minimum band as the live cone, so agreeing curves stay visible. + if yMin == yMax { + yMin -= 1 + yMax += 1 + } + return (date: Date(timeIntervalSince1970: stored.start + Double(index) * step), yMin: yMin, yMax: yMax) + } + return Forecast(curves: [], cone: cone) + } + + let curves = names.compactMap { name -> ForecastCurve? in + guard let values = stored.curves[name] else { return nil } + return ForecastCurve(name: name, color: color(forCurve: name), points: curvePoints(values)) + } + return Forecast(curves: curves, cone: []) + } + + /// Same colors as the live forecast lines. + private static func color(forCurve name: String) -> Color { + switch name { + case "ZT": return Color("ZT") + case "IOB": return Color("Insulin") + case "COB": return Color(.systemOrange) + case "UAM": return Color("UAM") + default: return .purple + } + } +} diff --git a/LoopFollow/Charts/BGChartHistoryPanes.swift b/LoopFollow/Charts/BGChartHistoryPanes.swift new file mode 100644 index 000000000..dfd45f37f --- /dev/null +++ b/LoopFollow/Charts/BGChartHistoryPanes.swift @@ -0,0 +1,113 @@ +// LoopFollow +// BGChartHistoryPanes.swift + +import Charts +import SwiftUI + +/// One device status history pane (IOB, COB or sensitivity ratio) laid +/// out over the main chart's render window. Like BGChartCanvas, it compares +/// only cheap metadata so panning, which translates it, skips its body. +struct BGChartHistoryPaneCanvas: View, Equatable { + let pane: BGChartHistory.Pane + let generation: Int + let now: Date + let windowStart: Date + let windowEnd: Date + let canvasWidth: CGFloat + let height: CGFloat + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.generation == rhs.generation && + lhs.pane.kind == rhs.pane.kind && + lhs.now == rhs.now && + lhs.windowStart == rhs.windowStart && + lhs.windowEnd == rhs.windowEnd && + lhs.canvasWidth == rhs.canvasWidth && + lhs.height == rhs.height + } + + var body: some View { + let points = chartWindowedLine(pane.points, windowStart: windowStart, windowEnd: windowEnd) { $0.date } + let color = pane.kind.color + Chart { + ForEach(pane.ticks, id: \.self) { tick in + RuleMark(y: .value("tick", tick)) + .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [2, 3])) + .foregroundStyle(Color.gray.opacity(0.4)) + } + if pane.kind.fillsArea { + ForEach(points) { pt in + AreaMark( + x: .value("time", pt.date), + yStart: .value("baseline", pane.baseline), + yEnd: .value(pane.kind.title, pt.value), + series: .value("series", "area-\(pt.segment)") + ) + .foregroundStyle(color.opacity(0.25)) + .interpolationMethod(.linear) + } + } + if now >= windowStart, now <= windowEnd { + RuleMark(x: .value("now", now)) + .lineStyle(StrokeStyle(lineWidth: 1)) + .foregroundStyle(Color.gray.opacity(0.6)) + } + ForEach(points) { pt in + LineMark( + x: .value("time", pt.date), + y: .value(pane.kind.title, pt.value), + series: .value("series", "line-\(pt.segment)") + ) + .foregroundStyle(color) + .lineStyle(StrokeStyle(lineWidth: 1.5)) + .interpolationMethod(.linear) + } + } + .chartXScale(domain: windowStart ... windowEnd) + .chartYScale(domain: pane.yDomain) + .chartLegend(.hidden) + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .frame(width: canvasWidth, height: height) + } +} + +/// Pinned title and value labels for a history pane, drawn over its +/// scrolling canvas. Labels are kept inside the pane so they never spill into +/// a neighbouring one. +struct BGChartHistoryPaneAxisOverlay: View, Equatable { + let pane: BGChartHistory.Pane + let generation: Int + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.generation == rhs.generation && lhs.pane.kind == rhs.pane.kind + } + + var body: some View { + GeometryReader { geo in + let height = geo.size.height + let span = pane.yDomain.upperBound - pane.yDomain.lowerBound + ZStack(alignment: .topLeading) { + ForEach(pane.ticks, id: \.self) { tick in + let fraction = span > 0 ? (pane.yDomain.upperBound - tick) / span : 0.5 + Text(pane.kind.axisLabel(tick)) + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.trailing, 4) + .frame(width: geo.size.width, height: 14, alignment: .trailing) + .position(x: geo.size.width / 2, y: min(max(CGFloat(fraction) * height, 7), height - 7)) + } + + Text(pane.kind.title) + .font(.caption2.bold()) + .foregroundStyle(pane.kind.color) + .padding(.leading, 4) + .padding(.top, 2) + + Rectangle() + .fill(Color.primary.opacity(0.15)) + .frame(height: 0.5) + } + } + } +} diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index 8acc2859c..4c37bb155 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -141,6 +141,12 @@ final class BGChartModel: ObservableObject { @Published var sensorStarts: [TreatmentPoint] = [] @Published var notes: [TreatmentPoint] = [] + /// Device status history panes under the main chart, in display order. + @Published var historyPanes: [BGChartHistory.Pane] = [] + @Published var smoothedBG: [BGChartHistory.Point] = [] + /// History curves in the main chart's bottom band, when placed there. + @Published var historyOverlay: BGChartHistory.Overlay? + @Published var overrides: [BandRect] = [] @Published var tempTargets: [BandRect] = [] @@ -623,6 +629,27 @@ final class BGChartModel: ObservableObject { } midnightMarkers = midnights + rebuildHistory(from: currentNow.addingTimeInterval(-hoursBack - BGChartHistory.maxJoinGap)) + generation &+= 1 } + + private func rebuildHistory(from start: Date) { + let enabledKinds = BGChartHistory.PaneKind.allCases.filter(\.isEnabled) + let showSmoothed = Storage.shared.showSmoothedBG.value && Storage.shared.device.value != "Loop" + let startTime = start.timeIntervalSince1970 + let samples = enabledKinds.isEmpty && !showSmoothed + ? [] + : DeviceStatusHistory.shared.state.samples.filter { $0.date >= startTime } + + if Storage.shared.historyCurvePlacement.value == .mainGraph { + historyPanes = [] + // The band stays below the low line so it does not cover in-range readings. + historyOverlay = BGChartHistory.overlay(kinds: enabledKinds, samples: samples, bandTop: min(maxBG * 0.22, lowLine * 0.9)) + } else { + historyPanes = enabledKinds.compactMap { BGChartHistory.pane(kind: $0, samples: samples) } + historyOverlay = nil + } + smoothedBG = showSmoothed ? BGChartHistory.points(from: samples) { $0.smoothedBG } : [] + } } diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index 74f9ec03e..93ddafd90 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -48,6 +48,19 @@ private enum BGChartConfig { static let tapHitRadius: CGFloat = 30 } +/// For line/area series: the items inside the window plus one beyond each +/// edge, so segments crossing (or spanning) the window survive. Assumes +/// ascending dates. +func chartWindowedLine(_ items: [T], windowStart: Date, windowEnd: Date, date: (T) -> Date) -> [T] { + guard items.count > 1 else { return items } + let firstInside = items.firstIndex { date($0) >= windowStart } ?? items.count + let firstBeyond = items.firstIndex { date($0) > windowEnd } ?? items.count - 1 + let lo = max(firstInside - 1, 0) + let hi = min(firstBeyond, items.count - 1) + guard lo <= hi else { return [] } + return Array(items[lo ... hi]) +} + /// Small y-domain headroom keeps the top axis label readable instead of /// pinning it to the chart edge. private func chartYDomainUpperBound(_ maxBG: Double) -> Double { @@ -172,6 +185,11 @@ private struct MainBGChart: View { /// away from the live edge, cleared on return to it. @State private var autoFollowPausedUntil: Date? + /// The jump to now button hides while the user pans, zooms or scrubs and + /// fades back in a moment after, so it stays out of the way. + @State private var showJumpToNow = true + @State private var jumpToNowFadeTask: Task? + private var timeZoneForAxis: TimeZone { if Storage.shared.graphTimeZoneEnabled.value, let tz = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value) @@ -197,36 +215,71 @@ private struct MainBGChart: View { let canvasOffsetX = CGFloat( interaction.scrollPosition.timeIntervalSince(renderWindowStart) / windowSeconds ) * canvasWidth + let scaleAnchor = pinchScaleAnchor(viewportWidth: viewportWidth, canvasWidth: canvasWidth) + + // Device status history panes stack under the main chart and share its + // render window, offset and pinch transform. + let panes = model.historyPanes + let paneHeight = historyPaneHeight(viewportHeight: viewport.height, count: panes.count) + let mainHeight = viewport.height - paneHeight * CGFloat(panes.count) return ZStack(alignment: .topLeading) { - BGChartCanvas( - model: model, - generation: model.generation, - isSmall: false, - windowStart: renderWindowStart, - windowEnd: renderWindowEnd, - canvasWidth: canvasWidth, - height: viewport.height, - visibleSeconds: interaction.visibleSeconds, - timeZone: timeZoneForAxis - ) - .equatable() - .offset(x: -canvasOffsetX) - .scaleEffect( - x: pinchScale, - y: 1, - anchor: pinchScaleAnchor(viewportWidth: viewportWidth, canvasWidth: canvasWidth) - ) + VStack(spacing: 0) { + ZStack(alignment: .topLeading) { + BGChartCanvas( + model: model, + generation: model.generation, + isSmall: false, + windowStart: renderWindowStart, + windowEnd: renderWindowEnd, + canvasWidth: canvasWidth, + height: mainHeight, + visibleSeconds: interaction.visibleSeconds, + timeZone: timeZoneForAxis + ) + .equatable() + .offset(x: -canvasOffsetX) + .scaleEffect(x: pinchScale, y: 1, anchor: scaleAnchor) + + // Pinned y-axis labels (BG trailing, basal leading) over the + // scrolling canvas. The canvas's own y-axis is hidden β€” its + // trailing edge sits far off-screen on the wide canvas. + StaticYAxisOverlay(generation: model.generation, maxBG: model.maxBG, maxBasal: model.maxBasal) + .equatable() + .frame(width: viewportWidth, height: mainHeight) + .allowsHitTesting(false) + } + .frame(width: viewportWidth, height: mainHeight, alignment: .topLeading) + + ForEach(panes) { pane in + ZStack(alignment: .topLeading) { + BGChartHistoryPaneCanvas( + pane: pane, + generation: model.generation, + now: model.now, + windowStart: renderWindowStart, + windowEnd: renderWindowEnd, + canvasWidth: canvasWidth, + height: paneHeight + ) + .equatable() + .offset(x: -canvasOffsetX) + .scaleEffect(x: pinchScale, y: 1, anchor: scaleAnchor) + + BGChartHistoryPaneAxisOverlay(pane: pane, generation: model.generation) + .equatable() + .frame(width: viewportWidth, height: paneHeight) + .allowsHitTesting(false) + } + .frame(width: viewportWidth, height: paneHeight, alignment: .topLeading) + .clipped() + } + } - // Pinned y-axis labels (BG trailing, basal leading) over the - // scrolling canvas. The canvas's own y-axis is hidden β€” its - // trailing edge sits far off-screen on the wide canvas. - StaticYAxisOverlay(generation: model.generation, maxBG: model.maxBG, maxBasal: model.maxBasal) - .equatable() - .frame(width: viewportWidth, height: viewport.height) + historyForecastOverlay(viewportWidth: viewportWidth) .allowsHitTesting(false) - selectionOverlay(viewportWidth: viewportWidth) + selectionOverlay(viewportWidth: viewportWidth, indicatorBottom: viewport.height) .allowsHitTesting(false) overrideBandLabelsOverlay(viewportWidth: viewportWidth) @@ -234,6 +287,9 @@ private struct MainBGChart: View { if !interaction.followLatest { jumpToNowButton + .frame(width: viewportWidth, height: mainHeight) + .opacity(showJumpToNow ? 1 : 0) + .allowsHitTesting(showJumpToNow) } } .frame(width: viewportWidth, height: viewport.height, alignment: .topLeading) @@ -251,11 +307,18 @@ private struct MainBGChart: View { case .first: cycleZoomPreset() case let .second(tap): - handleTap(at: tap.location, viewportWidth: viewportWidth) + handleTap(at: tap.location, viewportWidth: viewportWidth, mainHeight: mainHeight) } } ) .onPreferenceChange(PlotFramePreferenceKey.self) { plotFrame = $0 } + // The displays show the scrubbed time only while the finger is down. + .onChange(of: isInspectLatched) { _, latched in + if !latched { RetroSelection.shared.select(nil) } + } + .onChange(of: isInteracting) { _, interacting in + updateJumpToNowVisibility(interacting: interacting) + } .onPreferenceChange(PillSizePreferenceKey.self) { pillSize = $0 } .onChange(of: interaction.scrollPosition) { _, _ in updateRenderWindow() @@ -265,6 +328,11 @@ private struct MainBGChart: View { updateRenderWindow(force: true) } .onChange(of: model.now) { _, _ in + // New data while holding still: rebuild the scrubbed-time displays. + if isInspectLatched, let selection { + let anchor = selectionAnchor(for: selection) + RetroSelection.shared.select(anchor.map { $0.readingDate ?? $0.date }, refresh: true) + } if interaction.followLatest { scrollToNow(animated: true) } else if let pausedUntil = autoFollowPausedUntil, Date() >= pausedUntil { @@ -287,6 +355,7 @@ private struct MainBGChart: View { .onDisappear { momentumTask?.cancel() momentumTask = nil + jumpToNowFadeTask?.cancel() resetGestureState() } } @@ -307,6 +376,26 @@ private struct MainBGChart: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) } + /// A finger is on the chart (scrub, pan, pinch) or a flick is still gliding. + private var isInteracting: Bool { + isInspectLatched || panBaseline != nil || pinchAnchor != nil || momentumTask != nil + } + + private func updateJumpToNowVisibility(interacting: Bool) { + jumpToNowFadeTask?.cancel() + if interacting { + showJumpToNow = false + return + } + jumpToNowFadeTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 1_500_000_000) + guard !Task.isCancelled else { return } + withAnimation(.easeIn(duration: 0.4)) { + showJumpToNow = true + } + } + } + // MARK: Render window / follow state /// Re-anchors the render window when the visible window nears its edge. @@ -481,6 +570,7 @@ private struct MainBGChart: View { } private func resetGestureState() { + RetroSelection.shared.select(nil) inspectHoldTask?.cancel() inspectHoldTask = nil pinchAnchor = nil @@ -523,12 +613,14 @@ private struct MainBGChart: View { interaction.visibleSeconds * TimeInterval(fraction) ) selection = date + let anchor = selectionAnchor(for: date) // A featherlight tick whenever the indicator snaps to a different slot. - if let anchor = selectionAnchor(for: date), anchor.date != lastHapticAnchorDate { + if let anchor, anchor.date != lastHapticAnchorDate { lastHapticAnchorDate = anchor.date scrubHaptic.selectionChanged() scrubHaptic.prepare() } + RetroSelection.shared.select(anchor.map { $0.readingDate ?? $0.date }) } /// Deceleration after a flick. Mutates only scrollPosition (a transform), @@ -665,6 +757,9 @@ private struct MainBGChart: View { } updateRenderWindow(force: true) interaction.persistZoom() + // A double tap is over at once: hide the button and fade it back in. + showJumpToNow = false + updateJumpToNowVisibility(interacting: false) } // MARK: Selection @@ -676,6 +771,8 @@ private struct MainBGChart: View { let value: Double /// One pill entry per item under the selector (see PillLabel). let texts: [String] + /// The BG reading under the selector, whose forecast can be shown. + var readingDate: Date? } /// Feeds every treatment mark to `body`. Single source for both the scrub @@ -736,7 +833,14 @@ private struct MainBGChart: View { /// across the block. The indicator's height comes from the reading nearest /// the mark, else the nearest treatment, else the band; an empty block /// shows nothing. - private func selectionAnchor(for selected: Date) -> SelectionAnchor? { + private enum SelectionPurpose { + /// Press-and-hold scrubbing: how things were at that time. + case scrub + /// A tap: what happened at that point. + case tap + } + + private func selectionAnchor(for selected: Date, purpose: SelectionPurpose = .scrub) -> SelectionAnchor? { let slot = model.scrubSlots.slot(containing: selected) let mark = slot.date @@ -746,15 +850,17 @@ private struct MainBGChart: View { } treatments.sort { $0.date < $1.date } let readings = model.bg.filter { slot.contains($0.date) } - - var texts = treatments.map(\.pillText) + readings.map(bgPillText) let bandTexts = bandPillTexts(at: mark) - texts += bandTexts + + // Scrubbing shows no pill: the BG display and info table show the + // values at the scrubbed time. A tap lists what happened there. + let texts = purpose == .tap ? treatments.map(\.pillText) + readings.map(bgPillText) + bandTexts : [] func distanceToMark(_ date: Date) -> TimeInterval { abs(date.timeIntervalSince(mark)) } var value: Double? - if let reading = readings.min(by: { distanceToMark($0.date) < distanceToMark($1.date) }) { + let nearestReading = readings.min(by: { distanceToMark($0.date) < distanceToMark($1.date) }) + if let reading = nearestReading { value = reading.value } else if let nearest = treatments.min(by: { distanceToMark($0.date) < distanceToMark($1.date) }) { value = nearest.sgv @@ -767,7 +873,7 @@ private struct MainBGChart: View { } guard let value else { return nil } - return SelectionAnchor(date: mark, value: value, texts: texts) + return SelectionAnchor(date: mark, value: value, texts: texts, readingDate: nearestReading?.date) } /// Tap hit test (screen-space, 2D). Treatments take priority, then BG @@ -778,20 +884,20 @@ private struct MainBGChart: View { var best: SelectionAnchor? var bestDistance2 = radius * radius - func consider(_ date: Date, _ value: Double, _ text: String) { + func consider(_ date: Date, _ value: Double, _ text: String, isReading: Bool = false) { let dx = xPosition(for: date, viewportWidth: viewportWidth) - location.x let dy = yPosition(forValue: value) - location.y let d2 = dx * dx + dy * dy if d2 <= bestDistance2 { bestDistance2 = d2 - best = SelectionAnchor(date: date, value: value, texts: [text]) + best = SelectionAnchor(date: date, value: value, texts: [text], readingDate: isReading ? date : nil) } } forEachTreatmentAnchor { consider($0.drawnDate, $0.sgv, $0.pillText) } if best == nil { for p in model.bg { - consider(p.date, p.value, bgPillText(for: p)) + consider(p.date, p.value, bgPillText(for: p), isReading: true) } } if best == nil { @@ -802,14 +908,30 @@ private struct MainBGChart: View { } if let best { let texts = best.texts + bandPillTexts(at: best.date) - return SelectionAnchor(date: best.date, value: best.value, texts: texts) + return SelectionAnchor(date: best.date, value: best.value, texts: texts, readingDate: best.readingDate) } return nil } - private func handleTap(at location: CGPoint, viewportWidth: CGFloat) { + /// Taps on the main chart hit-test its marks; taps on a history pane select + /// by time alone, like a scrub. + private func handleTap(at location: CGPoint, viewportWidth: CGFloat, mainHeight: CGFloat) { guard plotFrame.height > 0 else { return } - tapped = tappedAnchor(at: location, viewportWidth: viewportWidth) + if location.y > mainHeight { + let date = interaction.scrollPosition.addingTimeInterval( + interaction.visibleSeconds * TimeInterval(location.x / viewportWidth) + ) + tapped = selectionAnchor(for: date, purpose: .tap) + } else { + tapped = tappedAnchor(at: location, viewportWidth: viewportWidth) + } + } + + /// Pane height for `count` history panes, leaving the main chart most of the space. + private func historyPaneHeight(viewportHeight: CGFloat, count: Int) -> CGFloat { + guard count > 0 else { return 0 } + let preferred = min(max(viewportHeight * 0.15, 44), 72) + return min(preferred, viewportHeight * 0.45 / CGFloat(count)) } /// The anchor the overlay should show: a live scrub wins over a sticky tap. @@ -870,6 +992,51 @@ private struct MainBGChart: View { } } + /// Where the pill goes for an indicator at (x, y): under the anchor when + /// there is room, else above it. + private func pillPosition(x: CGFloat, y: CGFloat, pillW: CGFloat, pillH: CGFloat, viewportWidth: CGFloat) -> CGPoint { + let labelX = min(max(x, pillW / 2 + 4), viewportWidth - pillW / 2 - 4) + let below = y + 14 + pillH / 2 + let above = y - 14 - pillH / 2 + let fitsBelow = below + pillH / 2 <= plotFrame.maxY - 4 + return CGPoint(x: labelX, y: fitsBelow ? below : max(above, plotFrame.minY + pillH / 2 + 4)) + } + + /// The forecast the loop made from the scrubbed BG reading, drawn in the + /// shell like the pill so scrubbing never re-lays the canvas. + @ViewBuilder + private func historyForecastOverlay(viewportWidth: CGFloat) -> some View { + if isInspectLatched, plotFrame.height > 0, + let readingDate = activeAnchor()?.readingDate, + let forecast = BGChartHistory.forecast(forReadingAt: readingDate) + { + ZStack(alignment: .topLeading) { + if !forecast.cone.isEmpty { + Path { path in + for (index, pt) in forecast.cone.enumerated() { + let point = CGPoint(x: xPosition(for: pt.date, viewportWidth: viewportWidth), y: yPosition(forValue: pt.yMax)) + if index == 0 { path.move(to: point) } else { path.addLine(to: point) } + } + for pt in forecast.cone.reversed() { + path.addLine(to: CGPoint(x: xPosition(for: pt.date, viewportWidth: viewportWidth), y: yPosition(forValue: pt.yMin))) + } + path.closeSubpath() + } + .fill(Color(.systemBlue).opacity(0.3)) + } + ForEach(forecast.curves) { curve in + Path { path in + for (index, pt) in curve.points.enumerated() { + let point = CGPoint(x: xPosition(for: pt.date, viewportWidth: viewportWidth), y: yPosition(forValue: pt.value)) + if index == 0 { path.move(to: point) } else { path.addLine(to: point) } + } + } + .stroke(curve.color, style: StrokeStyle(lineWidth: 2, dash: [5, 3])) + } + } + } + } + /// Vertical indicator + pill for the current selection, rendered in the /// shell with the same linear maps the canvas uses β€” neither scrubbing /// nor a tapped pill ever re-lays the canvas. @@ -880,28 +1047,28 @@ private struct MainBGChart: View { /// SwiftUI's word wrapping, which respects the actual font metrics, so /// there is no manual line splitting. @ViewBuilder - private func selectionOverlay(viewportWidth: CGFloat) -> some View { + private func selectionOverlay(viewportWidth: CGFloat, indicatorBottom: CGFloat) -> some View { if plotFrame.height > 0, let anchor = activeAnchor() { let x = xPosition(for: anchor.date, viewportWidth: viewportWidth) if x >= 0, x <= viewportWidth { let y = yPosition(forValue: anchor.value) + // With history panes the indicator runs down through them too. + let lineBottom = model.historyPanes.isEmpty ? plotFrame.maxY : indicatorBottom Rectangle() .fill(Color.primary.opacity(0.5)) - .frame(width: 1, height: plotFrame.height) - .position(x: x, y: plotFrame.midY) + .frame(width: 1, height: lineBottom - plotFrame.minY) + .position(x: x, y: (plotFrame.minY + lineBottom) / 2) // Measured size lags the text by one frame; fall back to a // small nominal size until the first measurement lands. let pillW = max(pillSize.width, 60) let pillH = max(pillSize.height, 28) - let labelX = min(max(x, pillW / 2 + 4), viewportWidth - pillW / 2 - 4) - let below = y + 14 + pillH / 2 - let above = y - 14 - pillH / 2 - let fitsBelow = below + pillH / 2 <= plotFrame.maxY - 4 - let labelY = fitsBelow ? below : max(above, plotFrame.minY + pillH / 2 + 4) - PillLabel(texts: anchor.texts, maxWidth: min(300, viewportWidth - 16)) - .position(x: labelX, y: labelY) + if !anchor.texts.isEmpty { + let position = pillPosition(x: x, y: y, pillW: pillW, pillH: pillH, viewportWidth: viewportWidth) + PillLabel(texts: anchor.texts, maxWidth: min(300, viewportWidth - 16)) + .position(x: position.x, y: position.y) + } } } } @@ -1046,9 +1213,14 @@ private struct BGChartCanvas: View, Equatable { coneMarks if !isSmall { yesterdayMarks + // Under the readings, so low BG stays visible above the band. + historyOverlayMarks } bgLineMarks bgPointsMark + if !isSmall { + smoothedBGMarks + } predictionLineMark predictionVariantMarks if showTreatments { @@ -1099,17 +1271,8 @@ private struct BGChartCanvas: View, Equatable { } } - /// For line/area series: like `windowed`, but keeps one point beyond each - /// edge so segments crossing (or spanning) the window survive. Assumes - /// ascending dates. private func windowedLine(_ items: [T], date: (T) -> Date) -> [T] { - guard items.count > 1 else { return items } - let firstInside = items.firstIndex { date($0) >= windowStart } ?? items.count - let firstBeyond = items.firstIndex { date($0) > windowEnd } ?? items.count - 1 - let lo = max(firstInside - 1, 0) - let hi = min(firstBeyond, items.count - 1) - guard lo <= hi else { return [] } - return Array(items[lo ... hi]) + chartWindowedLine(items, windowStart: windowStart, windowEnd: windowEnd, date: date) } // MARK: X axis @@ -1274,6 +1437,37 @@ private struct BGChartCanvas: View, Equatable { } } + @ChartContentBuilder + private var historyOverlayMarks: some ChartContent { + if let overlay = model.historyOverlay { + ForEach(overlay.series) { series in + ForEach(windowedLine(series.points) { $0.date }) { pt in + LineMark( + x: .value("time", pt.date), + y: .value(series.kind.title, pt.value), + series: .value("series", "overlay-\(series.id)-\(pt.segment)") + ) + .foregroundStyle(series.color) + .lineStyle(StrokeStyle(lineWidth: 1.5)) + } + } + } + } + + @ChartContentBuilder + private var smoothedBGMarks: some ChartContent { + ForEach(windowedLine(model.smoothedBG) { $0.date }) { pt in + LineMark( + x: .value("time", pt.date), + y: .value("bg", pt.value), + series: .value("series", "smoothed-\(pt.segment)") + ) + .foregroundStyle(Color.cyan) + .lineStyle(StrokeStyle(lineWidth: 2)) + .interpolationMethod(.linear) + } + } + @ChartContentBuilder private var predictionLineMark: some ChartContent { ForEach(windowedLine(model.prediction) { $0.date }) { pt in @@ -1554,10 +1748,15 @@ private struct StaticYAxisOverlay: View, Equatable { } } +/// Only the main chart's axis overlay reports a frame; siblings that report +/// nothing must not reset it to zero. private struct PlotFramePreferenceKey: PreferenceKey { static var defaultValue: CGRect = .zero static func reduce(value: inout CGRect, nextValue: () -> CGRect) { - value = nextValue() + let next = nextValue() + if next != .zero { + value = next + } } } @@ -1566,7 +1765,10 @@ private struct PlotFramePreferenceKey: PreferenceKey { private struct PillSizePreferenceKey: PreferenceKey { static var defaultValue: CGSize = .zero static func reduce(value: inout CGSize, nextValue: () -> CGSize) { - value = nextValue() + let next = nextValue() + if next != .zero { + value = next + } } } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index 4cd3a65a1..ed672fa37 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -67,7 +67,7 @@ extension MainViewController { func updateDeviceStatusDisplay(jsonDeviceStatus: [[String: AnyObject]]) { let previousIOBText = Observable.shared.iobText.value let previousDeviceWasLoop = Storage.shared.device.value == "Loop" - infoManager.clearInfoData(types: [.iob, .cob, .battery, .pump, .pumpBattery, .target, .isf, .carbRatio, .updated, .recBolus, .tdd]) + infoManager.clearInfoData(types: [.iob, .cob, .battery, .pump, .pumpBattery, .target, .isf, .carbRatio, .updated, .recBolus, .tdd, .smoothedBG]) // For Loop, clear the current override here - For Trio, it is handled using treatments if Storage.shared.device.value == "Loop" { @@ -261,6 +261,8 @@ extension MainViewController { evaluateNotLooping() + DeviceStatusHistory.shared.ingestLatest(lastDeviceStatus) + // Mark device status as loaded for initial loading state markDataLoaded("deviceStatus") diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusHistory.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusHistory.swift new file mode 100644 index 000000000..c2e252300 --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusHistory.swift @@ -0,0 +1,362 @@ +// LoopFollow +// DeviceStatusHistory.swift + +import Combine +import Foundation + +/// History of the values the looping app reports in Nightscout devicestatus +/// records (IOB, COB, smoothed BG, sensitivity ratio, forecasts and the info +/// table values), shared by the history graphs and the scrubbed-time displays. +/// +/// Devicestatus records never change once written, so the extracted fields are +/// cached on disk and only missing spans are fetched: the Show Days Back window +/// once, then gaps after the app was suspended. The regular device status poll +/// hands each record it reads to `ingestLatest`, which extends the history +/// without a request. Changing the Nightscout address drops the cache. +/// +/// Main-thread confined. +final class DeviceStatusHistory { + static let shared = DeviceStatusHistory() + + private(set) var state = DeviceStatusHistoryState() + + /// Called on the main thread whenever the history changes. + var onChange: (() -> Void)? + + private enum LoadState { + case notLoaded, loading, loaded + } + + private struct CacheFile: Codable { + static let currentVersion = 2 + let version: Int + let url: String + let state: DeviceStatusHistoryState + } + + /// Trio records carry four forecast curves (a few KB each), so a page stays + /// around half a megabyte; small Nightscout hosts fail on much larger responses. + private static let pageSize = 100 + private static let saveDelay: TimeInterval = 5 + + private var loadState = LoadState.notLoaded + /// Bumped whenever the history is dropped; work started under an older + /// generation is discarded when it completes. + private var generation = 0 + private var isFetching = false + private var consecutiveFailures = 0 + private var retryAfter = Date.distantPast + /// Where a range that failed part-way resumes, so pages already merged are + /// not downloaded again. + private var resumePoint: ResumePoint? + + private struct ResumePoint { + let range: DeviceStatusHistoryState.FetchRange + let upperBound: String + let pagesFetched: Int + } + + private var saveScheduled = false + private var cancellables = Set() + private let ioQueue = DispatchQueue(label: "com.loopfollow.devicestatushistory", qos: .utility) + + private static var cacheFileURL: URL? { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first? + .appendingPathComponent("DeviceStatusHistory.json") + } + + private init() {} + + /// Subscribes to the settings the history depends on. Idempotent. + func start() { + guard cancellables.isEmpty else { return } + + Storage.shared.url.$value + .removeDuplicates() + .dropFirst() + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.reset() } + .store(in: &cancellables) + + let s = Storage.shared + Publishers.MergeMany( + s.showIOBGraph.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(), + s.showCOBGraph.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(), + s.showSensitivityRatioGraph.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(), + s.showSmoothedBG.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(), + s.historyCurvePlacement.$value.dropFirst().map { _ in () }.eraseToAnyPublisher(), + s.downloadDays.$value.dropFirst().map { _ in () }.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] in self?.settingsChanged() } + .store(in: &cancellables) + + loadIfNeeded() + } + + /// Takes the record the regular device status poll just read. Extends the + /// history directly when it continues it, otherwise fetches what is missing. + func ingestLatest(_ record: [String: Any]?) { + guard let record else { return } + guard loadState == .loaded else { + loadIfNeeded() + return + } + guard let parsed = DeviceStatusHistoryParser.parse(record) else { + // Uploader-only records (for example from a CGM app) still show that + // newer records exist. + let createdAt = (record["created_at"] as? String).flatMap(DeviceStatusHistoryParser.parseDate) + refreshIfNeeded(latest: createdAt?.timeIntervalSince1970) + return + } + + let createdAtTime = parsed.createdAt.timeIntervalSince1970 + let previous = state + if state.appendLatest(parsed.sample, createdAt: createdAtTime), state != previous { + state.prune(before: windowStart()) + scheduleSave() + onChange?() + } + refreshIfNeeded(latest: createdAtTime) + } + + /// The sample of the loop cycle nearest `date`, within `tolerance`. + func sample(near date: Date, tolerance: TimeInterval = 5 * 60) -> DeviceStatusHistorySample? { + state.sample(near: date.timeIntervalSince1970, tolerance: tolerance) + } + + /// The loop cycle that ran on the BG reading at `date`. + func cycle(forReadingAt date: Date) -> DeviceStatusHistorySample? { + state.cycle(forReadingAt: date.timeIntervalSince1970) + } + + // MARK: - Settings + + private func settingsChanged() { + if loadState == .loaded { + state.prune(before: windowStart()) + refreshIfNeeded(latest: nil) + } + onChange?() + } + + /// Drops the history (memory and disk) after the Nightscout address changed. + private func reset() { + generation &+= 1 + state = DeviceStatusHistoryState() + isFetching = false + consecutiveFailures = 0 + retryAfter = .distantPast + resumePoint = nil + loadState = .loaded + ioQueue.async { + if let url = Self.cacheFileURL { + try? FileManager.default.removeItem(at: url) + } + } + onChange?() + } + + private func windowStart(now: Date = Date()) -> TimeInterval { + now.timeIntervalSince1970 - TimeInterval(Storage.shared.downloadDays.value) * 24 * 3600 + } + + // MARK: - Fetching + + /// Fetches the spans the history is missing. `latest` is the `created_at` of + /// the newest record known to exist; nil assumes one exists up to now. + private func refreshIfNeeded(latest: TimeInterval?) { + guard loadState == .loaded, !isFetching, + IsNightscoutEnabled(), Date() >= retryAfter + else { return } + + let now = Date().timeIntervalSince1970 + let resume = resumePoint + resumePoint = nil + // A resumed range runs on its own; the next poll plans what is still missing. + let ranges = resume.map { [$0.range] } + ?? state.missingRanges(windowStart: windowStart(), latest: latest ?? now, now: now) + guard !ranges.isEmpty else { return } + + isFetching = true + fetch(ranges, resume: resume, generation: generation) + } + + /// Fetches `ranges` in order; the first continues from `resume` when set. + private func fetch(_ ranges: [DeviceStatusHistoryState.FetchRange], resume: ResumePoint? = nil, generation: Int) { + guard let range = ranges.first else { + isFetching = false + return + } + LogManager.shared.log( + category: .deviceStatus, + message: "Device status history fetch \(DeviceStatusHistoryParser.formatDate(Date(timeIntervalSince1970: range.from))) – \(DeviceStatusHistoryParser.formatDate(Date(timeIntervalSince1970: range.to)))", + isDebug: true + ) + fetchPages( + range: range, + upperBound: resume?.upperBound, + pagesFetched: resume?.pagesFetched ?? 0, + generation: generation + ) { [weak self] outcome in + guard let self, self.generation == generation else { return } + switch outcome { + case .completed: + self.consecutiveFailures = 0 + self.state.markCovered(from: range.from, to: range.to) + self.state.prune(before: self.windowStart()) + self.scheduleSave() + self.fetch(Array(ranges.dropFirst()), generation: generation) + case let .failed(reason, upperBound, pagesFetched): + if let upperBound { + self.resumePoint = ResumePoint(range: range, upperBound: upperBound, pagesFetched: pagesFetched) + } + self.consecutiveFailures += 1 + let backoff = min(30 * pow(2, Double(self.consecutiveFailures - 1)), 600) + self.retryAfter = Date().addingTimeInterval(backoff) + self.isFetching = false + LogManager.shared.log( + category: .deviceStatus, + message: "Device status history fetch failed (\(reason)), retrying in \(Int(backoff)) s", + limitIdentifier: "Device status history fetch failed" + ) + } + } + } + + private enum PageOutcome { + case completed + /// `upperBound` is the cursor of the page that failed; nil for the first page. + case failed(reason: String, upperBound: String?, pagesFetched: Int) + } + + /// Reads one range newest-first, a page at a time. Each page is merged as + /// it arrives so the graphs fill in progressively. + private func fetchPages( + range: DeviceStatusHistoryState.FetchRange, + upperBound: String?, + pagesFetched: Int, + generation: Int, + completion: @escaping (PageOutcome) -> Void + ) { + var parameters = [ + "count": String(Self.pageSize), + "find[created_at][$gte]": DeviceStatusHistoryParser.formatDate(Date(timeIntervalSince1970: range.from)), + ] + if let upperBound { + parameters["find[created_at][$lt]"] = upperBound + } else { + parameters["find[created_at][$lte]"] = DeviceStatusHistoryParser.formatDate(Date(timeIntervalSince1970: range.to)) + } + + NightscoutUtils.executeDynamicRequest(eventType: .deviceStatus, parameters: parameters) { [weak self] result in + let records: [Any] + switch result { + case let .success(json): + guard let array = json as? [Any] else { + DispatchQueue.main.async { + completion(.failed(reason: "unexpected response", upperBound: upperBound, pagesFetched: pagesFetched)) + } + return + } + records = array + case let .failure(error): + DispatchQueue.main.async { + completion(.failed(reason: error.localizedDescription, upperBound: upperBound, pagesFetched: pagesFetched)) + } + return + } + DispatchQueue.global(qos: .utility).async { + var samples: [DeviceStatusHistorySample] = [] + var oldestCreatedAt: String? + var oldestDate = Date.distantFuture + for case let record as [String: Any] in records { + if let raw = record["created_at"] as? String, + let date = DeviceStatusHistoryParser.parseDate(raw), date < oldestDate + { + oldestDate = date + oldestCreatedAt = raw + } + if let parsed = DeviceStatusHistoryParser.parse(record) { + samples.append(parsed.sample) + } + } + + DispatchQueue.main.async { + guard let self, self.generation == generation else { return } + self.state.merge(samples) + self.onChange?() + + let pages = pagesFetched + 1 + let isFull = records.count >= Self.pageSize && oldestCreatedAt != nil + if isFull, pages < Self.maxPages(for: range), let oldestCreatedAt { + self.fetchPages(range: range, upperBound: oldestCreatedAt, pagesFetched: pages, generation: generation, completion: completion) + } else { + if isFull { + LogManager.shared.log(category: .deviceStatus, message: "Device status history page limit reached; older records in the range are skipped") + } + completion(.completed) + } + } + } + } + } + + /// Room for one record per minute across the range, several times the + /// loop cycle rate, so only a runaway uploader hits the limit. + private static func maxPages(for range: DeviceStatusHistoryState.FetchRange) -> Int { + Int(((range.to - range.from) / 60 / Double(pageSize)).rounded(.up)) + 1 + } + + // MARK: - Persistence + + private func loadIfNeeded() { + guard loadState == .notLoaded else { return } + loadState = .loading + let generation = generation + let expectedURL = Storage.shared.url.value + + ioQueue.async { + var loaded: DeviceStatusHistoryState? + if let fileURL = Self.cacheFileURL, + let data = try? Data(contentsOf: fileURL), + let file = try? JSONDecoder().decode(CacheFile.self, from: data), + file.version == CacheFile.currentVersion, file.url == expectedURL + { + loaded = file.state + } + + DispatchQueue.main.async { [weak self] in + guard let self, self.generation == generation else { return } + if let loaded { + self.state = loaded + self.state.prune(before: self.windowStart()) + } + self.loadState = .loaded + self.onChange?() + self.refreshIfNeeded(latest: nil) + } + } + } + + private func scheduleSave() { + guard !saveScheduled else { return } + saveScheduled = true + DispatchQueue.main.asyncAfter(deadline: .now() + Self.saveDelay) { [weak self] in + guard let self else { return } + self.saveScheduled = false + let file = CacheFile(version: CacheFile.currentVersion, url: Storage.shared.url.value, state: self.state) + self.ioQueue.async { + guard let fileURL = Self.cacheFileURL, + let data = try? JSONEncoder().encode(file) + else { return } + do { + try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try data.write(to: fileURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + } catch { + LogManager.shared.log(category: .deviceStatus, message: "Device status history save failed: \(error.localizedDescription)") + } + } + } + } +} diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusHistorySample.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusHistorySample.swift new file mode 100644 index 000000000..bcc233fba --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusHistorySample.swift @@ -0,0 +1,192 @@ +// LoopFollow +// DeviceStatusHistorySample.swift + +import Foundation + +/// The fields LoopFollow graphs over time or shows for a scrubbed time, +/// extracted from one Nightscout devicestatus record. Glucose values (smoothed +/// BG, eventual BG, target, forecasts) are mg/dL. +struct DeviceStatusHistorySample: Codable, Equatable { + /// Time of the loop cycle that produced the record. + let date: TimeInterval + var iob: Double? + var cob: Double? + /// Glucose the OpenAPS/Trio algorithm ran on (`suggested.bg`); smoothed in Trio. + var smoothedBG: Double? + /// Effective ISF of the loop cycle, as uploaded (Trio reports mg/dL per U). + var isf: Double? + /// `sensitivityRatio`: the autosens ratio, or the dynamic ratio when Dynamic ISF is on. + var sensitivityRatio: Double? + var forecast: Forecast? + /// Loop: the last forecast value. OpenAPS/Trio: `eventualBG`. + var eventualBG: Double? + var recommendedBolus: Double? + /// OpenAPS/Trio `current_target`. + var target: Double? + /// OpenAPS/Trio `CR`. + var carbRatio: Double? + var tdd: Double? + var reservoir: Double? + var pumpBattery: Double? + var uploaderBattery: Double? + /// Loop's active override, as reported in the record. + var loopOverride: LoopOverride? + + struct LoopOverride: Codable, Equatable { + let multiplier: Double? + let minTarget: Double + let maxTarget: Double + } + + struct Forecast: Codable, Equatable { + /// Time of the first value; values follow at five-minute steps. + let start: TimeInterval + /// Loop publishes one curve (`main`); OpenAPS/Trio publish `ZT`, `IOB`, `COB` and `UAM`. + let curves: [String: [Int]] + + static let loopCurve = "main" + static let openAPSCurves = ["ZT", "IOB", "COB", "UAM"] + static let step: TimeInterval = 300 + /// Six hours, the longest forecast the graph settings allow. + static let maxValues = 73 + } +} + +enum DeviceStatusHistoryParser { + /// Parses a devicestatus record. Returns nil for records without loop data + /// (for example uploader-only records from a CGM app). + static func parse(_ record: [String: Any]) -> (sample: DeviceStatusHistorySample, createdAt: Date)? { + let createdAt = (record["created_at"] as? String).flatMap(parseDate) + + if let loop = record["loop"] as? [String: Any] { + guard let date = (loop["timestamp"] as? String).flatMap(parseDate) ?? createdAt else { return nil } + var sample = DeviceStatusHistorySample(date: date.timeIntervalSince1970) + sample.iob = number((loop["iob"] as? [String: Any])?["iob"]) + sample.cob = number((loop["cob"] as? [String: Any])?["cob"]) + if let predicted = loop["predicted"] as? [String: Any], + let values = predicted["values"] as? [Any] + { + let start = (predicted["startDate"] as? String).flatMap(parseDate) ?? date + sample.forecast = forecast(start: start, curves: [DeviceStatusHistorySample.Forecast.loopCurve: values]) + sample.eventualBG = number(values.last) + } + sample.recommendedBolus = number(loop["recommendedBolus"]) + if let override = record["override"] as? [String: Any], + override["active"] as? Bool == true, + let range = override["currentCorrectionRange"] as? [String: Any], + let minTarget = number(range["minValue"]), + let maxTarget = number(range["maxValue"]) + { + sample.loopOverride = .init(multiplier: number(override["multiplier"]), minTarget: minTarget, maxTarget: maxTarget) + } + addDeviceFields(from: record, to: &sample) + return (sample, createdAt ?? date) + } + + if let openAPS = record["openaps"] as? [String: Any] { + guard let determination = openAPS["suggested"] as? [String: Any] ?? openAPS["enacted"] as? [String: Any] else { return nil } + let determinationDate = (determination["deliverAt"] as? String ?? determination["timestamp"] as? String).flatMap(parseDate) + guard let date = determinationDate ?? createdAt else { return nil } + + var sample = DeviceStatusHistorySample(date: date.timeIntervalSince1970) + sample.iob = openAPSIOB(openAPS["iob"]) + sample.cob = number(determination["COB"]) ?? reasonValue("COB", in: determination["reason"] as? String) + sample.smoothedBG = number(determination["bg"]).flatMap { $0 > 0 ? $0 : nil } + sample.isf = number(determination["ISF"]).flatMap { $0 > 0 ? $0 : nil } + sample.sensitivityRatio = number(determination["sensitivityRatio"]).flatMap { $0 > 0 ? $0 : nil } + sample.eventualBG = number(determination["eventualBG"]) + sample.recommendedBolus = number(openAPS["recommendedBolus"]) + // Same convention as the live Target row: values below 40 are mmol/L. + sample.target = number(determination["current_target"]).map { $0 < 40 ? $0 * GlucoseConversion.mmolToMgDl : $0 } + sample.carbRatio = number(determination["CR"]).flatMap { $0 > 0 ? $0 : nil } + sample.tdd = number(determination["TDD"]) ?? number((openAPS["enacted"] as? [String: Any])?["TDD"]) + addDeviceFields(from: record, to: &sample) + if let predBGs = determination["predBGs"] as? [String: Any] { + var curves: [String: [Any]] = [:] + for name in DeviceStatusHistorySample.Forecast.openAPSCurves { + if let values = predBGs[name] as? [Any] { curves[name] = values } + } + sample.forecast = forecast(start: date, curves: curves) + } + return (sample, createdAt ?? date) + } + + return nil + } + + private static func addDeviceFields(from record: [String: Any], to sample: inout DeviceStatusHistorySample) { + if let pump = record["pump"] as? [String: Any] { + sample.reservoir = number(pump["reservoir"]) + sample.pumpBattery = number((pump["battery"] as? [String: Any])?["percent"]) + } + sample.uploaderBattery = number((record["uploader"] as? [String: Any])?["battery"]) + } + + /// `openaps.iob` is a dictionary on Trio/AAPS and an array of projections on + /// oref0 rigs, where the first element is the current value. + private static func openAPSIOB(_ value: Any?) -> Double? { + if let dict = value as? [String: Any] { return number(dict["iob"]) } + if let array = value as? [[String: Any]] { return array.first.flatMap { number($0["iob"]) } } + return nil + } + + private static func forecast(start: Date, curves raw: [String: [Any]]) -> DeviceStatusHistorySample.Forecast? { + var curves: [String: [Int]] = [:] + for (name, values) in raw { + let ints = values.prefix(DeviceStatusHistorySample.Forecast.maxValues).compactMap { value in + number(value).map { Int(min(max($0, 0), 10000).rounded()) } + } + if !ints.isEmpty { curves[name] = ints } + } + guard !curves.isEmpty else { return nil } + return DeviceStatusHistorySample.Forecast(start: start.timeIntervalSince1970, curves: curves) + } + + private static func number(_ value: Any?) -> Double? { + switch value { + case let n as NSNumber: + // JSONSerialization decodes true/false as NSNumber too. + guard CFGetTypeID(n) != CFBooleanGetTypeID() else { return nil } + let d = n.doubleValue + return d.isFinite ? d : nil + case let s as String: + return Double(s) + default: + return nil + } + } + + private static func reasonValue(_ key: String, in reason: String?) -> Double? { + guard let reason, + let range = reason.range(of: "\\b\(key): (-?\\d+(?:\\.\\d+)?)", options: .regularExpression) + else { return nil } + return Double(reason[range].dropFirst(key.count + 2)) + } + + private static let fractionalFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let wholeSecondFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + /// ISO 8601 with a `Z` or `Β±HH:MM` offset, with or without fractional seconds. + static func parseDate(_ string: String) -> Date? { + fractionalFormatter.date(from: string) ?? wholeSecondFormatter.date(from: string) + } + + static func formatDate(_ date: Date) -> String { + fractionalFormatter.string(from: date) + } +} + +extension DeviceStatusHistorySample { + init(date: TimeInterval) { + self.date = date + } +} diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusHistoryState.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusHistoryState.swift new file mode 100644 index 000000000..ed8a157f0 --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusHistoryState.swift @@ -0,0 +1,136 @@ +// LoopFollow +// DeviceStatusHistoryState.swift + +import Foundation + +/// The cached device status history and the span of Nightscout records it +/// covers. Pure value logic; DeviceStatusHistory owns I/O and networking. +struct DeviceStatusHistoryState: Codable, Equatable { + struct FetchRange: Equatable { + let from: TimeInterval + let to: TimeInterval + } + + /// Ascending by date, one sample per loop cycle. + private(set) var samples: [DeviceStatusHistorySample] = [] + /// Every record whose `created_at` falls in `coverageStart ... coverageEnd` + /// has been fetched. + private(set) var coverageStart: TimeInterval? + private(set) var coverageEnd: TimeInterval? + + /// A new record this close to the covered span extends it without a fetch: + /// loops upload every five minutes. + static let gapTolerance: TimeInterval = 6.5 * 60 + /// Fetched ranges re-read this much of the covered span so a record written + /// right at the edge is not missed. + static let overlap: TimeInterval = 60 + + var isEmpty: Bool { samples.isEmpty && coverageStart == nil } + + /// Inserts samples, replacing any existing sample from the same second. + mutating func merge(_ newSamples: [DeviceStatusHistorySample]) { + guard !newSamples.isEmpty else { return } + var byKey: [Int: DeviceStatusHistorySample] = [:] + byKey.reserveCapacity(samples.count + newSamples.count) + for sample in samples { + byKey[Int(sample.date.rounded())] = sample + } + for sample in newSamples { + byKey[Int(sample.date.rounded())] = sample + } + samples = byKey.values.sorted { $0.date < $1.date } + } + + /// Records that `from ... to` has been fetched. A range that neither + /// overlaps nor touches the covered span replaces it. + mutating func markCovered(from: TimeInterval, to: TimeInterval) { + guard to >= from else { return } + if let start = coverageStart, let end = coverageEnd, + from <= end + Self.gapTolerance, to >= start - Self.gapTolerance + { + coverageStart = min(start, from) + coverageEnd = max(end, to) + } else { + coverageStart = from + coverageEnd = to + } + } + + /// Adds the record the regular device status poll just read, when it + /// continues the covered span. Returns false when there is a gap to fetch. + mutating func appendLatest(_ sample: DeviceStatusHistorySample, createdAt: TimeInterval) -> Bool { + guard let start = coverageStart, let end = coverageEnd, + createdAt >= start, createdAt <= end + Self.gapTolerance + else { return false } + merge([sample]) + coverageEnd = max(end, createdAt) + return true + } + + /// Drops samples before `cutoff` and shrinks the covered span to match. + mutating func prune(before cutoff: TimeInterval) { + if let firstKept = samples.firstIndex(where: { $0.date >= cutoff }) { + if firstKept > 0 { samples.removeFirst(firstKept) } + } else { + samples.removeAll() + } + if let end = coverageEnd, end < cutoff { + coverageStart = nil + coverageEnd = nil + } else if let start = coverageStart, start < cutoff { + coverageStart = cutoff + } + } + + /// Ranges still to fetch so the history spans `windowStart` up to + /// `latest`, the newest record known to exist. + func missingRanges(windowStart: TimeInterval, latest: TimeInterval, now: TimeInterval) -> [FetchRange] { + guard let start = coverageStart, let end = coverageEnd, end >= windowStart else { + return [FetchRange(from: windowStart, to: now)] + } + var ranges: [FetchRange] = [] + if latest > end + Self.gapTolerance { + // An uploader clock ahead of the phone can stamp records after `now`. + ranges.append(FetchRange(from: end - Self.overlap, to: max(now, latest))) + } + if windowStart < start - Self.gapTolerance { + ranges.append(FetchRange(from: windowStart, to: start + Self.overlap)) + } + return ranges + } + + /// The sample of the loop cycle nearest `date`, within `tolerance`. + func sample(near date: TimeInterval, tolerance: TimeInterval) -> DeviceStatusHistorySample? { + guard let index = nearestIndex(to: date) else { return nil } + let sample = samples[index] + return abs(sample.date - date) <= tolerance ? sample : nil + } + + /// The loop cycle that ran on a BG reading at `readingDate`: the first cycle + /// at or after the reading (allowing a little clock skew), within one cycle. + func cycle(forReadingAt readingDate: TimeInterval) -> DeviceStatusHistorySample? { + let earliest = readingDate - 60 + let latest = readingDate + Self.gapTolerance + var lo = 0 + var hi = samples.count + while lo < hi { + let mid = (lo + hi) / 2 + if samples[mid].date < earliest { lo = mid + 1 } else { hi = mid } + } + guard lo < samples.count, samples[lo].date <= latest else { return nil } + return samples[lo] + } + + private func nearestIndex(to date: TimeInterval) -> Int? { + guard !samples.isEmpty else { return nil } + var lo = 0 + var hi = samples.count + while lo < hi { + let mid = (lo + hi) / 2 + if samples[mid].date < date { lo = mid + 1 } else { hi = mid } + } + if lo == samples.count { return samples.count - 1 } + if lo == 0 { return 0 } + return date - samples[lo - 1].date <= samples[lo].date - date ? lo - 1 : lo + } +} diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index f15bef344..e9cb08ba5 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -105,6 +105,11 @@ extension MainViewController { } } + // Smoothed BG: the glucose the algorithm ran on (smoothed in Trio) + if let smoothedBG = enactedOrSuggested["bg"] as? Double, smoothedBG > 0 { + infoManager.updateInfoData(type: .smoothedBG, value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: smoothedBG)) + } + // Autosens if let sens = enactedOrSuggested["sensitivityRatio"] as? Double { let formattedSens = String(format: "%.0f", sens * 100.0) + "%" diff --git a/LoopFollow/Controllers/Nightscout/ProfileManager.swift b/LoopFollow/Controllers/Nightscout/ProfileManager.swift index 520afde88..bfe5ae993 100644 --- a/LoopFollow/Controllers/Nightscout/ProfileManager.swift +++ b/LoopFollow/Controllers/Nightscout/ProfileManager.swift @@ -125,38 +125,57 @@ final class ProfileManager { } func currentISF() -> HKQuantity? { - return getCurrentValue(from: isfSchedule) + isf(at: Date()) } func currentBasal() -> String? { - if let basal = getCurrentValue(from: basalSchedule) { - return Localizer.formatToLocalizedString(basal, maxFractionDigits: 2, minFractionDigits: 0) - } - return nil + basal(at: Date()) } func currentCarbRatio() -> Double? { - return getCurrentValue(from: carbRatioSchedule) + carbRatio(at: Date()) } func currentTargetLow() -> HKQuantity? { - return getCurrentValue(from: targetLowSchedule) + targetLow(at: Date()) } func currentTargetHigh() -> HKQuantity? { - return getCurrentValue(from: targetHighSchedule) + targetHigh(at: Date()) + } + + /// The loaded profile's scheduled values at the clock time of `date`. + func isf(at date: Date) -> HKQuantity? { + value(from: isfSchedule, at: date) + } + + func basal(at date: Date) -> String? { + value(from: basalSchedule, at: date).map { + Localizer.formatToLocalizedString($0, maxFractionDigits: 2, minFractionDigits: 0) + } + } + + func carbRatio(at date: Date) -> Double? { + value(from: carbRatioSchedule, at: date) + } + + func targetLow(at date: Date) -> HKQuantity? { + value(from: targetLowSchedule, at: date) + } + + func targetHigh(at date: Date) -> HKQuantity? { + value(from: targetHighSchedule, at: date) } - private func getCurrentValue(from schedule: [TimeValue]) -> T? { + private func value(from schedule: [TimeValue], at date: Date) -> T? { guard !schedule.isEmpty else { return nil } - let now = Date() var calendar = Calendar.current calendar.timeZone = timezone - let currentTimeInSeconds = calendar.component(.hour, from: now) * 3600 + - calendar.component(.minute, from: now) * 60 + - calendar.component(.second, from: now) + let currentTimeInSeconds = calendar.component(.hour, from: date) * 3600 + + calendar.component(.minute, from: date) * 60 + + calendar.component(.second, from: date) var lastValue: T? for timeValue in schedule { diff --git a/LoopFollow/Helpers/HistoryCurvePlacement.swift b/LoopFollow/Helpers/HistoryCurvePlacement.swift new file mode 100644 index 000000000..d5dc6919f --- /dev/null +++ b/LoopFollow/Helpers/HistoryCurvePlacement.swift @@ -0,0 +1,17 @@ +// LoopFollow +// HistoryCurvePlacement.swift + +/// Where the device status history curves (IOB, COB, sensitivity ratio) are drawn. +enum HistoryCurvePlacement: String, CaseIterable, Codable { + /// Each curve in its own graph under the main graph, with its own axis. + case separate + /// All curves in the bottom band of the main graph, each scaled to its own range. + case mainGraph + + var displayName: String { + switch self { + case .separate: return "Separate Graphs" + case .mainGraph: return "In Main Graph" + } + } +} diff --git a/LoopFollow/InfoTable/InfoTableView.swift b/LoopFollow/InfoTable/InfoTableView.swift index 6c36c6bbe..337def504 100644 --- a/LoopFollow/InfoTable/InfoTableView.swift +++ b/LoopFollow/InfoTable/InfoTableView.swift @@ -6,6 +6,7 @@ import SwiftUI struct InfoTableView: View { @ObservedObject var infoManager: InfoManager var timeZoneOverride: String? + @ObservedObject private var retro = RetroSelection.shared @ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = 17 @ScaledMetric(relativeTo: .body) private var rowHeight: CGFloat = 21 @@ -16,17 +17,30 @@ struct InfoTableView: View { row(name: "Time Zone", value: tz) } ForEach(infoManager.visibleRows) { item in - row(name: item.name, value: item.value, valueColor: color(for: item)) + if let snapshot = retro.snapshot { + // Scrubbing: the value at the scrubbed time, or "β€”" when unknown. + let retroRow = InfoType(rawValue: item.id).flatMap { snapshot.rows[$0] } + row(name: item.name, value: retroRow?.value ?? "", valueColor: color(for: item.id, numericValue: retroRow?.numericValue)) + .listRowBackground(Color.clear) + } else { + row(name: item.name, value: item.value, valueColor: color(for: item.id, numericValue: item.numericValue)) + } } } .listStyle(.plain) .environment(\.defaultMinListRowHeight, rowHeight) + .scrollContentBackground(retro.snapshot != nil ? .hidden : .automatic) + .background { + if retro.snapshot != nil { + RetroCardBackground() + } + } } /// Threshold-based color for a row's value, or nil to use the default color. - private func color(for item: InfoData) -> Color? { - guard let numericValue = item.numericValue, - let type = InfoType(rawValue: item.id), + private func color(for id: Int, numericValue: Double?) -> Color? { + guard let numericValue, + let type = InfoType(rawValue: id), let config = type.colorConfig else { return nil } return Storage.shared.infoDisplayItems.value.item(for: type)? diff --git a/LoopFollow/InfoTable/InfoType.swift b/LoopFollow/InfoTable/InfoType.swift index 173ca416e..ca127c027 100644 --- a/LoopFollow/InfoTable/InfoType.swift +++ b/LoopFollow/InfoTable/InfoType.swift @@ -4,7 +4,7 @@ import Foundation enum InfoType: Int, CaseIterable, Codable { - case iob, cob, basal, override, battery, pump, pumpBattery, sage, cage, recBolus, minMax, carbsToday, autosens, profile, target, isf, carbRatio, updated, tdd, iage, dbSize + case iob, cob, basal, override, battery, pump, pumpBattery, sage, cage, recBolus, minMax, carbsToday, autosens, profile, target, isf, carbRatio, updated, tdd, iage, dbSize, smoothedBG var name: String { switch self { @@ -29,6 +29,7 @@ enum InfoType: Int, CaseIterable, Codable { case .tdd: return "TDD" case .iage: return "IAGE" case .dbSize: return "DB Size" + case .smoothedBG: return "Smoothed BG" } } diff --git a/LoopFollow/InfoTable/RetroSnapshot.swift b/LoopFollow/InfoTable/RetroSnapshot.swift new file mode 100644 index 000000000..d7a0a7cff --- /dev/null +++ b/LoopFollow/InfoTable/RetroSnapshot.swift @@ -0,0 +1,269 @@ +// LoopFollow +// RetroSnapshot.swift + +import HealthKit +import SwiftUI + +/// What the BG display and info table show while the user scrubs the main +/// chart: the state at the scrubbed time instead of the latest values. +struct RetroSnapshot: Equatable { + struct Row: Equatable { + let value: String + let numericValue: Double? + } + + let date: Date + let timeText: String + let bgText: String + let bgColor: Color + let directionText: String + let deltaText: String + let predictionText: String + let predictionColor: Color + /// Info rows with a value at the scrubbed time; other rows show "β€”". + let rows: [InfoType: Row] + + /// Color that marks the displays as showing a past time. + static let accent = Color(.systemGray) +} + +/// Card behind the BG display and info table while they show a past time. +/// It extends past the content into the surrounding spacing, so the text +/// keeps its place and does not touch the card's edge. +struct RetroCardBackground: View { + var body: some View { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(LinearGradient( + colors: [RetroSnapshot.accent.opacity(0.4), RetroSnapshot.accent.opacity(0.12)], + startPoint: .top, + endPoint: .bottom + )) + .padding(EdgeInsets(top: -8, leading: -4, bottom: -4, trailing: -4)) + } +} + +/// Publishes the snapshot for the time under the user's finger while +/// scrubbing, and nil otherwise. Only the displays change; alarms, the Live +/// Activity and everything else keep using the latest values. +final class RetroSelection: ObservableObject { + static let shared = RetroSelection() + + @Published private(set) var snapshot: RetroSnapshot? + + private init() { + // A touch the system cancels (a call, Notification Center, leaving the + // app) may never end the scrub; the displays return to live anyway. + for name in [UIApplication.willResignActiveNotification, UIApplication.didEnterBackgroundNotification] { + NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + self?.clear() + } + } + } + + /// Shows the state at `date`, or the live values when nil. `refresh` + /// rebuilds the snapshot for an unchanged date after new data arrived. + func select(_ date: Date?, refresh: Bool = false) { + guard let date, let vc = MainViewController.shared + else { + clear() + return + } + guard refresh || snapshot?.date != date else { return } + snapshot = vc.retroSnapshot(at: date) + } + + private func clear() { + if snapshot != nil { snapshot = nil } + } +} + +extension MainViewController { + /// The BG display and info table values at `date`, a scrub position on the + /// BG reading grid. + func retroSnapshot(at date: Date) -> RetroSnapshot { + let time = date.timeIntervalSince1970 + let history = DeviceStatusHistory.shared + let sample = history.cycle(forReadingAt: date) ?? history.sample(near: date) + + // BG, direction and delta of the reading at the scrub position. + var bgText = "β€”" + var bgColor = Color.primary + var directionText = "" + var deltaText = "" + // Scrub positions on a reading carry its exact time; slots without one show no BG. + if let index = bgData.lastIndex(where: { abs($0.date - time) <= 30 }) { + let reading = bgData[index] + if reading.sgv <= globalVariables.minDisplayGlucose { + bgText = "LOW" + } else if reading.sgv >= globalVariables.maxDisplayGlucose { + bgText = "HIGH" + } else { + bgText = Localizer.toDisplayUnits(String(reading.sgv)) + } + if Storage.shared.colorBGText.value { + let thresholds = UnitSettingsStore.shared.effectiveThresholds() + if Double(reading.sgv) >= thresholds.high { + bgColor = .yellow + } else if Double(reading.sgv) <= thresholds.low { + bgColor = .red + } else { + bgColor = .green + } + } + if let direction = reading.direction { + directionText = bgDirectionGraphic(direction) + } + if index > 0 { + let delta = reading.sgv - bgData[index - 1].sgv + deltaText = (delta < 0 ? "" : "+") + Localizer.toDisplayUnits(String(delta)) + } + } + + let isLoopSample = sample?.forecast?.curves[DeviceStatusHistorySample.Forecast.loopCurve] != nil + let predictionText = sample?.eventualBG.map { Localizer.formatQuantity($0) } ?? "" + + return RetroSnapshot( + date: date, + timeText: chartModel.pillTimeString(for: date), + bgText: bgText, + bgColor: bgColor, + directionText: directionText, + deltaText: deltaText, + predictionText: predictionText, + predictionColor: isLoopSample ? .purple : .gray, + rows: retroRows(at: date, sample: sample) + ) + } + + private func retroRows(at date: Date, sample: DeviceStatusHistorySample?) -> [InfoType: RetroSnapshot.Row] { + let time = date.timeIntervalSince1970 + let isLoop = Storage.shared.device.value == "Loop" + var rows: [InfoType: RetroSnapshot.Row] = [:] + + func set(_ type: InfoType, _ value: String?, _ numeric: Double? = nil) { + guard let value, !value.isEmpty else { return } + rows[type] = .init(value: value, numericValue: numeric) + } + + // Values from the loop cycle's devicestatus record. + if let sample { + if let iob = sample.iob, let metric = InsulinMetric(from: ["iob": iob as AnyObject], key: "iob") { + set(.iob, metric.formattedValue(), iob) + } + if let cob = sample.cob, let metric = CarbMetric(from: ["cob": cob as AnyObject], key: "cob") { + set(.cob, metric.formattedValue(), cob) + } + if let tdd = sample.tdd, let metric = InsulinMetric(from: ["TDD": tdd as AnyObject], key: "TDD") { + set(.tdd, metric.formattedValue(), tdd) + } + set(.recBolus, sample.recommendedBolus.map { InsulinFormatter.shared.string($0) }, sample.recommendedBolus) + set(.battery, sample.uploaderBattery.map { String(format: "%.0f", $0) + "%" }, sample.uploaderBattery) + set(.pumpBattery, sample.pumpBattery.map { String(format: "%.0f", $0) + "%" }, sample.pumpBattery) + set(.pump, sample.reservoir.map { String(format: "%.0f", $0) + "U" }, sample.reservoir) + set(.autosens, sample.sensitivityRatio.map { String(format: "%.0f", $0 * 100) + "%" }) + set(.smoothedBG, sample.smoothedBG.map { Localizer.formatQuantity($0) }) + set(.updated, Localizer.formatTimestampToLocalString(sample.date)) + set(.minMax, retroMinMax(sample.forecast)) + if let override = sample.loopOverride { + let percent = override.multiplier.map { String(format: "%.0f%%", $0 * 100) } ?? "100%" + set(.override, "\(percent) (\(Localizer.toDisplayUnits(String(override.minTarget)))-\(Localizer.toDisplayUnits(String(override.maxTarget))))") + } + } + + // Profile-based rows, from the loaded profile's schedule at that time + // of day, with the loop's own value where the record carries one. + if let profileISF = profileManager.isf(at: date) { + if let isf = sample?.isf, + Localizer.formatQuantity(profileISF) != Localizer.formatQuantity(isf) + { + set(.isf, "\(Localizer.formatQuantity(profileISF)) \(InfoDataSeparator.arrow.rawValue) \(Localizer.formatQuantity(isf))") + } else { + set(.isf, Localizer.formatQuantity(profileISF)) + } + } else if let isf = sample?.isf { + set(.isf, Localizer.formatQuantity(isf)) + } + + let profileCR = profileManager.carbRatio(at: date) + let loopCR = sample?.carbRatio + if let profileCR, let loopCR, profileCR != loopCR { + set(.carbRatio, "\(Localizer.formatToLocalizedString(profileCR)) \(InfoDataSeparator.arrow.rawValue) \(Localizer.formatToLocalizedString(loopCR))") + } else if let cr = profileCR ?? loopCR { + set(.carbRatio, Localizer.formatToLocalizedString(cr)) + } + + if let target = sample?.target { + set(.target, Localizer.formatQuantity(target)) + } else if let low = profileManager.targetLow(at: date) { + let lowText = Localizer.formatQuantity(low) + if let high = profileManager.targetHigh(at: date), Localizer.formatQuantity(high) != lowText { + set(.target, "\(lowText) \(InfoDataSeparator.dash.rawValue) \(Localizer.formatQuantity(high))") + } else { + set(.target, lowText) + } + } + + // Rows that do not depend on the time keep their live value. + for type in [InfoType.profile, .dbSize] { + let live = infoManager.tableData[type.rawValue] + set(type, live.value, live.numericValue) + } + + // Rows derived from data the chart already holds. + if let rate = basalData.filter({ $0.date <= time }).max(by: { $0.date < $1.date })?.basalRate { + let actual = Localizer.formatToLocalizedString(rate, maxFractionDigits: 2, minFractionDigits: 0) + if let scheduled = profileManager.basal(at: date), scheduled != actual { + set(.basal, "\(scheduled) \(InfoDataSeparator.arrow.rawValue) \(actual)") + } else { + set(.basal, actual) + } + } + + if !isLoop, + let band = overrideGraphData.last(where: { $0.date <= time && time < $0.endDate }) + { + set(.override, band.reason.isEmpty ? "Override" : band.reason) + } + + var calendar = Calendar.current + calendar.timeZone = .current + let carbs = carbData + .filter { $0.date <= time && calendar.isDate(Date(timeIntervalSince1970: $0.date), inSameDayAs: date) } + .reduce(0) { $0 + $1.value } + set(.carbsToday, String(format: "%.0f", carbs), carbs) + + // Site and sensor ages, when the current insert predates the scrubbed time. + for (type, insertTime) in [ + (InfoType.sage, Storage.shared.sageInsertTime.value), + (.cage, Storage.shared.cageInsertTime.value), + (.iage, Storage.shared.iageInsertTime.value), + ] where insertTime > 0 && insertTime <= time { + let age = time - insertTime + set(type, Self.ageFormatter.string(from: age), age / 86400) + } + + return rows + } + + /// Min/max of the forecast, as the live row shows it. + private func retroMinMax(_ forecast: DeviceStatusHistorySample.Forecast?) -> String? { + guard let forecast else { return nil } + let values: [Int] + if let main = forecast.curves[DeviceStatusHistorySample.Forecast.loopCurve] { + values = main + } else { + let count = Int(Storage.shared.predictionToLoad.value * 12) + 1 + values = forecast.curves.values.flatMap { $0.prefix(count) } + } + guard let low = values.min(), let high = values.max() else { return nil } + return "\(Localizer.toDisplayUnits(String(low)))/\(Localizer.toDisplayUnits(String(high)))" + } + + private static let ageFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .positional + formatter.allowedUnits = [.day, .hour] + formatter.zeroFormattingBehavior = [.pad] + return formatter + }() +} diff --git a/LoopFollow/Settings/GraphSettingsView.swift b/LoopFollow/Settings/GraphSettingsView.swift index 7efb38c40..493142ca9 100644 --- a/LoopFollow/Settings/GraphSettingsView.swift +++ b/LoopFollow/Settings/GraphSettingsView.swift @@ -15,6 +15,12 @@ struct GraphSettingsView: View { @ObservedObject private var showPriorDayTimeLines = Storage.shared.showPriorDayTimeLines @ObservedObject private var showYesterdayLine = Storage.shared.showYesterdayLine @ObservedObject private var smallGraphTreatments = Storage.shared.smallGraphTreatments + @ObservedObject private var showIOBGraph = Storage.shared.showIOBGraph + @ObservedObject private var showCOBGraph = Storage.shared.showCOBGraph + @ObservedObject private var showSensitivityRatioGraph = Storage.shared.showSensitivityRatioGraph + @ObservedObject private var showSmoothedBG = Storage.shared.showSmoothedBG + @ObservedObject private var device = Storage.shared.device + @ObservedObject private var historyCurvePlacement = Storage.shared.historyCurvePlacement @ObservedObject private var smallGraphHeight = Storage.shared.smallGraphHeight @ObservedObject private var predictionToLoad = Storage.shared.predictionToLoad @@ -69,6 +75,27 @@ struct GraphSettingsView: View { } } + // ── History Graphs ─────────────────────────────────────────── + if nightscoutEnabled { + Section { + Toggle("Show IOB Graph", isOn: $showIOBGraph.value) + Toggle("Show COB Graph", isOn: $showCOBGraph.value) + if device.value != "Loop" { + Toggle("Show Smoothed BG", isOn: $showSmoothedBG.value) + Toggle("Show Sensitivity Ratio Graph", isOn: $showSensitivityRatioGraph.value) + } + if showIOBGraph.value || showCOBGraph.value || showSensitivityRatioGraph.value { + Picker("Curve Placement", selection: $historyCurvePlacement.value) { + ForEach(HistoryCurvePlacement.allCases, id: \.self) { placement in + Text(placement.displayName).tag(placement) + } + } + } + } header: { + Text("History Graphs") + } + } + // ── Small Graph ────────────────────────────────────────────── Section("Small Graph") { SettingsStepperRow( diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index dd63f6dd6..c162a4207 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -132,6 +132,11 @@ class Storage { var showPriorDayTimeLines = StorageValue(key: "showPriorDayTimeMarkers", defaultValue: false) var showYesterdayLine = StorageValue(key: "showYesterdayLine", defaultValue: false) var smallGraphTreatments = StorageValue(key: "smallGraphTreatments", defaultValue: true) + var showIOBGraph = StorageValue(key: "showIOBGraph", defaultValue: false) + var showCOBGraph = StorageValue(key: "showCOBGraph", defaultValue: false) + var showSensitivityRatioGraph = StorageValue(key: "showSensitivityRatioGraph", defaultValue: false) + var showSmoothedBG = StorageValue(key: "showSmoothedBG", defaultValue: false) + var historyCurvePlacement = StorageValue(key: "historyCurvePlacement", defaultValue: .separate) var smallGraphHeight = StorageValue(key: "smallGraphHeight", defaultValue: 40) var predictionToLoad = StorageValue(key: "predictionToLoad", defaultValue: 1.0) diff --git a/LoopFollow/ViewControllers/BGDisplayView.swift b/LoopFollow/ViewControllers/BGDisplayView.swift index ae626fa47..11bf3d16b 100644 --- a/LoopFollow/ViewControllers/BGDisplayView.swift +++ b/LoopFollow/ViewControllers/BGDisplayView.swift @@ -16,57 +16,78 @@ struct BGDisplayView: View { @ObservedObject var predictionText = Observable.shared.predictionText @ObservedObject var predictionColor = Observable.shared.predictionColor @ObservedObject var isNotLooping = Observable.shared.isNotLooping + @ObservedObject var retro = RetroSelection.shared var onRefresh: (() -> Void)? var body: some View { + // While scrubbing the chart, the display shows the scrubbed time. + let snapshot = retro.snapshot + let isStale = snapshot == nil && bgStale.value + ScrollView { VStack(spacing: 0) { Text(serverText.value) .font(.system(size: 13)) - Text(bgText.value) + Text(snapshot?.bgText ?? bgText.value) .font(.system(size: 85, weight: .black)) - .foregroundColor(bgTextColor.value) + .foregroundColor(snapshot?.bgColor ?? bgTextColor.value) .strikethrough( - bgStale.value, + isStale, pattern: .solid, - color: bgStale.value ? .red : .clear + color: isStale ? .red : .clear ) .frame(maxWidth: .infinity) .lineLimit(1) .minimumScaleFactor(0.5) HStack { - Text(directionText.value) + Text(snapshot?.directionText ?? directionText.value) .font(.system(size: 60, weight: .black)) - Text(deltaText.value) + Text(snapshot?.deltaText ?? deltaText.value) .font(.system(size: 32)) } .lineLimit(1) .minimumScaleFactor(0.5) - Text(minAgoText.value) - .font(.system(size: 17)) - - if isNotLooping.value { - Text(loopStatusText.value) - .font(.system(size: 18, weight: .bold)) - .foregroundColor(loopStatusColor.value) - .frame(maxWidth: .infinity) + if let snapshot { + Label(snapshot.timeText, systemImage: "clock.arrow.circlepath") + .font(.system(size: 17, weight: .semibold)) + .padding(.horizontal, 10) + .padding(.vertical, 2) + .background(Capsule().fill(RetroSnapshot.accent.opacity(0.3))) + Text(snapshot.predictionText) + .foregroundColor(snapshot.predictionColor) + .font(.system(size: 17)) } else { - HStack { - Spacer() + Text(minAgoText.value) + .font(.system(size: 17)) + + if isNotLooping.value { Text(loopStatusText.value) + .font(.system(size: 18, weight: .bold)) .foregroundColor(loopStatusColor.value) - Text(predictionText.value) - .foregroundColor(predictionColor.value) - Spacer() + .frame(maxWidth: .infinity) + } else { + HStack { + Spacer() + Text(loopStatusText.value) + .foregroundColor(loopStatusColor.value) + Text(predictionText.value) + .foregroundColor(predictionColor.value) + Spacer() + } + .font(.system(size: 17)) } - .font(.system(size: 17)) } } } + .background { + if snapshot != nil { + RetroCardBackground() + } + } .refreshable { onRefresh?() } diff --git a/LoopFollow/ViewControllers/MainHomeView.swift b/LoopFollow/ViewControllers/MainHomeView.swift index 599120f7d..23ee1e191 100644 --- a/LoopFollow/ViewControllers/MainHomeView.swift +++ b/LoopFollow/ViewControllers/MainHomeView.swift @@ -43,12 +43,6 @@ struct MainHomeView: View { InfoTableView(infoManager: infoManager, timeZoneOverride: timeZoneOverride) .dynamicTypeSize(...DynamicTypeSize.accessibility1) .frame(minWidth: 160, maxWidth: 250) - .overlay( - Rectangle() - .fill(Color(UIColor.darkGray)) - .frame(width: 2), - alignment: .leading - ) } } .fixedSize(horizontal: false, vertical: true) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 06c5e1f76..dace62437 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -233,6 +233,11 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { // setup display for NS vs Dex showHideNSDetails() + DeviceStatusHistory.shared.onChange = { [weak self] in + self?.chartModel.rebuild() + } + DeviceStatusHistory.shared.start() + scheduleAllTasks() setupNightscoutSocket() diff --git a/Tests/DeviceStatusHistoryTests.swift b/Tests/DeviceStatusHistoryTests.swift new file mode 100644 index 000000000..b2b9f87f2 --- /dev/null +++ b/Tests/DeviceStatusHistoryTests.swift @@ -0,0 +1,285 @@ +// LoopFollow +// DeviceStatusHistoryTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct DeviceStatusHistoryParserTests { + private func json(_ text: String) -> [String: Any] { + try! JSONSerialization.jsonObject(with: Data(text.utf8)) as! [String: Any] + } + + @Test("Loop record yields IOB, COB and the forecast from its start date") + func loopRecord() throws { + let record = json(""" + {"created_at": "2026-09-27T10:00:30.000Z", + "loop": {"timestamp": "2026-09-27T10:00:05Z", + "iob": {"iob": 1.25, "timestamp": "2026-09-27T10:00:00Z"}, + "cob": {"cob": 20}, + "predicted": {"startDate": "2026-09-27T09:59:00Z", "values": [110.4, 112.6, 115]}}} + """) + let (sample, createdAt) = try #require(DeviceStatusHistoryParser.parse(record)) + #expect(sample.eventualBG == 115) + #expect(sample.date == DeviceStatusHistoryParser.parseDate("2026-09-27T10:00:05Z")!.timeIntervalSince1970) + #expect(createdAt == DeviceStatusHistoryParser.parseDate("2026-09-27T10:00:30.000Z")) + #expect(sample.iob == 1.25) + #expect(sample.cob == 20) + #expect(sample.smoothedBG == nil) + #expect(sample.forecast?.curves["main"] == [110, 113, 115]) + #expect(sample.forecast?.start == DeviceStatusHistoryParser.parseDate("2026-09-27T09:59:00Z")!.timeIntervalSince1970) + } + + @Test("Trio record yields smoothed BG, ISF, ratio and the four forecast curves") + func trioRecord() throws { + let record = json(""" + {"created_at": "2026-09-27T10:00:30.000Z", "device": "Trio", + "pump": {"reservoir": 120.5, "battery": {"percent": 80}}, "uploader": {"battery": 55}, + "openaps": {"iob": {"iob": 2.5, "time": "2026-09-27T10:00:00Z"}, + "suggested": {"deliverAt": "2026-09-27T10:00:10.123Z", "bg": 142, "COB": 12.5, + "ISF": 45.2, "sensitivityRatio": 1.15, "eventualBG": 130, + "current_target": 5.5, "CR": 9, "TDD": 42.1, + "predBGs": {"IOB": [142, 140], "ZT": [142, 138], "UAM": [142, 145]}}}} + """) + let (sample, _) = try #require(DeviceStatusHistoryParser.parse(record)) + #expect(sample.date == DeviceStatusHistoryParser.parseDate("2026-09-27T10:00:10.123Z")!.timeIntervalSince1970) + #expect(sample.iob == 2.5) + #expect(sample.cob == 12.5) + #expect(sample.smoothedBG == 142) + #expect(sample.isf == 45.2) + #expect(sample.sensitivityRatio == 1.15) + #expect(sample.forecast?.curves.keys.sorted() == ["IOB", "UAM", "ZT"]) + #expect(sample.forecast?.start == sample.date) + #expect(sample.eventualBG == 130) + #expect(abs((sample.target ?? 0) - 99.09) < 0.1) + #expect(sample.carbRatio == 9) + #expect(sample.tdd == 42.1) + #expect(sample.reservoir == 120.5) + #expect(sample.pumpBattery == 80) + #expect(sample.uploaderBattery == 55) + } + + @Test("Loop's active override is kept") + func loopOverride() throws { + let record = json(""" + {"created_at": "2026-09-27T10:00:30Z", + "override": {"active": true, "multiplier": 1.5, "currentCorrectionRange": {"minValue": 90, "maxValue": 100}}, + "loop": {"timestamp": "2026-09-27T10:00:05Z", "recommendedBolus": 0.4}} + """) + let (sample, _) = try #require(DeviceStatusHistoryParser.parse(record)) + #expect(sample.loopOverride == .init(multiplier: 1.5, minTarget: 90, maxTarget: 100)) + #expect(sample.recommendedBolus == 0.4) + } + + @Test("A zero ISF is a placeholder, COB falls back to the reason string") + func openAPSFallbacks() throws { + let record = json(""" + {"created_at": "2026-09-27T10:00:30Z", + "openaps": {"iob": [{"iob": 0.8}, {"iob": 0.6}], + "enacted": {"timestamp": "2026-09-27T10:00:00Z", "ISF": 0, + "reason": "maxCOB: 120, COB: 7.5, Dev: 3, ISF: 54"}}} + """) + let (sample, _) = try #require(DeviceStatusHistoryParser.parse(record)) + #expect(sample.isf == nil) + #expect(sample.cob == 7.5) + #expect(sample.iob == 0.8) + } + + @Test("Out-of-range forecast values are clamped") + func hugeForecastValue() throws { + let record = json(""" + {"created_at": "2026-09-27T10:00:30Z", + "loop": {"timestamp": "2026-09-27T10:00:05Z", "predicted": {"values": [1e30, -5, 120]}}} + """) + let (sample, _) = try #require(DeviceStatusHistoryParser.parse(record)) + #expect(sample.forecast?.curves["main"] == [10000, 0, 120]) + } + + @Test("Uploader-only records carry nothing to graph") + func uploaderOnly() { + let record = json(""" + {"created_at": "2026-09-27T10:00:30Z", "uploader": {"battery": 80}} + """) + #expect(DeviceStatusHistoryParser.parse(record) == nil) + } + + @Test("Offsets are honored") + func offsetDates() { + let utc = DeviceStatusHistoryParser.parseDate("2026-09-27T10:00:00Z") + let offset = DeviceStatusHistoryParser.parseDate("2026-09-27T12:00:00+02:00") + #expect(utc != nil) + #expect(utc == offset) + } +} + +struct DeviceStatusHistoryStateTests { + private let t0: TimeInterval = 1_800_000_000 + + private func sample(_ minutes: Double, iob: Double = 1) -> DeviceStatusHistorySample { + var s = DeviceStatusHistorySample(date: t0 + minutes * 60) + s.iob = iob + return s + } + + @Test("An empty history fetches the whole window") + func emptyPlan() { + let state = DeviceStatusHistoryState() + let ranges = state.missingRanges(windowStart: t0, latest: t0 + 3600, now: t0 + 3600) + #expect(ranges == [.init(from: t0, to: t0 + 3600)]) + } + + @Test("The next record extends the history without a fetch") + func appendNext() { + var state = DeviceStatusHistoryState() + state.markCovered(from: t0, to: t0 + 3600) + let appended = state.appendLatest(sample(65), createdAt: t0 + 65 * 60) + #expect(appended) + #expect(state.coverageEnd == t0 + 65 * 60) + #expect(state.missingRanges(windowStart: t0, latest: t0 + 65 * 60, now: t0 + 66 * 60).isEmpty) + } + + @Test("A record after a gap is refused and only the gap is fetched") + func gapFetch() { + var state = DeviceStatusHistoryState() + state.markCovered(from: t0, to: t0 + 3600) + let appended = state.appendLatest(sample(120), createdAt: t0 + 120 * 60) + #expect(!appended) + let now = t0 + 121 * 60 + let ranges = state.missingRanges(windowStart: t0, latest: t0 + 120 * 60, now: now) + #expect(ranges == [.init(from: t0 + 3600 - DeviceStatusHistoryState.overlap, to: now)]) + } + + @Test("A record stamped ahead of the phone clock is included in the gap fetch") + func futureRecord() { + var state = DeviceStatusHistoryState() + state.markCovered(from: t0, to: t0 + 3600) + let now = t0 + 3600 + 60 + let latest = now + 600 + let ranges = state.missingRanges(windowStart: t0, latest: latest, now: now) + #expect(ranges == [.init(from: t0 + 3600 - DeviceStatusHistoryState.overlap, to: latest)]) + } + + @Test("A longer Show Days Back window fetches only the older span") + func widenedWindow() { + var state = DeviceStatusHistoryState() + state.markCovered(from: t0, to: t0 + 3600) + let ranges = state.missingRanges(windowStart: t0 - 86400, latest: t0 + 3600, now: t0 + 3600) + #expect(ranges == [.init(from: t0 - 86400, to: t0 + DeviceStatusHistoryState.overlap)]) + } + + @Test("A cache that ended before the window refetches the window") + func staleCache() { + var state = DeviceStatusHistoryState() + state.markCovered(from: t0, to: t0 + 3600) + let windowStart = t0 + 2 * 86400 + let ranges = state.missingRanges(windowStart: windowStart, latest: windowStart + 86400, now: windowStart + 86400) + #expect(ranges == [.init(from: windowStart, to: windowStart + 86400)]) + } + + @Test("Merging replaces same-second samples and keeps dates ascending") + func mergeDedupes() { + var state = DeviceStatusHistoryState() + state.merge([sample(10), sample(0)]) + state.merge([sample(5), sample(10, iob: 3)]) + #expect(state.samples.map(\.date) == [t0, t0 + 300, t0 + 600]) + #expect(state.samples.last?.iob == 3) + } + + @Test("Pruning drops old samples and shrinks coverage") + func prune() { + var state = DeviceStatusHistoryState() + state.merge([sample(0), sample(5), sample(10)]) + state.markCovered(from: t0, to: t0 + 600) + state.prune(before: t0 + 240) + #expect(state.samples.count == 2) + #expect(state.coverageStart == t0 + 240) + state.prune(before: t0 + 3600) + #expect(state.isEmpty) + } + + @Test("The forecast of a reading comes from the first loop cycle after it") + func cycleForReading() { + var state = DeviceStatusHistoryState() + state.merge([sample(0), sample(5), sample(10)]) + #expect(state.cycle(forReadingAt: t0 + 4 * 60)?.date == t0 + 300) + #expect(state.cycle(forReadingAt: t0 + 5.5 * 60)?.date == t0 + 300) + #expect(state.cycle(forReadingAt: t0 + 30 * 60) == nil) + } + + @Test("Round-trips through JSON") + func codable() throws { + var state = DeviceStatusHistoryState() + var s = sample(0) + s.forecast = .init(start: t0, curves: ["main": [100, 105]]) + state.merge([s]) + state.markCovered(from: t0, to: t0 + 600) + let decoded = try JSONDecoder().decode(DeviceStatusHistoryState.self, from: JSONEncoder().encode(state)) + #expect(decoded == state) + } +} + +struct BGChartHistoryTests { + private let t0: TimeInterval = 1_800_000_000 + + private func samples(_ values: [(minutes: Double, iob: Double)]) -> [DeviceStatusHistorySample] { + values.map { + var s = DeviceStatusHistorySample(date: t0 + $0.minutes * 60) + s.iob = $0.iob + return s + } + } + + @Test("Lines break across gaps longer than fifteen minutes") + func segments() { + let points = BGChartHistory.points(from: samples([(0, 1), (5, 1), (30, 1), (35, 1)])) { $0.iob } + #expect(points.map(\.segment) == [0, 0, 1, 1]) + } + + @Test("The IOB pane includes zero and negative values") + func iobScale() throws { + let pane = try #require(BGChartHistory.pane(kind: .iob, samples: samples([(0, -0.4), (5, 2.3)]))) + #expect(pane.ticks == [0, 3]) + #expect(pane.yDomain.lowerBound < -0.4) + #expect(pane.yDomain.upperBound > 3) + } + + @Test("Negative IOB splits into its own run at the zero crossing") + func negativeIOBSplit() { + let points = BGChartHistory.points(from: samples([(0, 1), (5, -1), (10, -2)])) { $0.iob } + let (positive, negative) = BGChartHistory.splitAtZero(points) + #expect(positive.map(\.value) == [1, 0]) + #expect(negative.map(\.value) == [0, -1, -2]) + let crossing = t0 + 150 + #expect(positive.last?.date.timeIntervalSince1970 == crossing) + #expect(negative.first?.date.timeIntervalSince1970 == crossing) + #expect(positive.last?.segment != negative.first?.segment) + } + + @Test("An IOB of exactly zero joins both runs without duplicate points") + func exactZeroSplit() { + for values in [[1.0, 0, -1], [-1.0, 0, 1]] { + let points = BGChartHistory.points(from: samples(values.enumerated().map { (Double($0.offset * 5), $0.element) })) { $0.iob } + let (positive, negative) = BGChartHistory.splitAtZero(points) + for run in [positive, negative] { + let keys = run.map { "\($0.segment)-\($0.date.timeIntervalSince1970)" } + #expect(Set(keys).count == keys.count) + } + #expect(negative.contains { $0.value == 0 }) + #expect(positive.contains { $0.value == 0 }) + } + } + + @Test("The main-graph overlay mirrors negative IOB inside the band") + func overlayNegativeIOB() throws { + let overlay = try #require(BGChartHistory.overlay(kinds: [.iob], samples: samples([(0, 2), (5, -1)]), bandTop: 60)) + #expect(overlay.series.map(\.isNegative) == [false, true]) + let maxDrawn = overlay.series.flatMap(\.points).map(\.value).max() ?? 0 + #expect(maxDrawn <= 60) + #expect(overlay.series.flatMap(\.points).allSatisfy { $0.value >= 0 }) + } + + @Test("A pane without values is not shown") + func emptyPane() { + #expect(BGChartHistory.pane(kind: .cob, samples: samples([(0, 1)])) == nil) + } +} From 75f9b6041eee6ec9cb1f8f7154500af21530b888 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 14 Oct 2026 22:18:43 +0000 Subject: [PATCH 26/84] CI: Bump dev version to 7.1.5 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index abdb78fec..a7b28678d 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.4 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.5 From e2e8cbf4408f48b536502f4b41bdb013f377d541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 15 Oct 2026 00:56:19 +0200 Subject: [PATCH 27/84] Larger Snoozer text (#749) * Larger Snoozer text * Keep the Snoozer emoji and clock above the tab bar on small phones In portrait the left column's heights scale down together to leave room for the emoji and clock, and its text can shrink to 30% to fit. --- LoopFollow/Snoozer/SnoozerView.swift | 102 +++++++++++++++++---------- 1 file changed, 63 insertions(+), 39 deletions(-) diff --git a/LoopFollow/Snoozer/SnoozerView.swift b/LoopFollow/Snoozer/SnoozerView.swift index f70daaf8c..b95310460 100644 --- a/LoopFollow/Snoozer/SnoozerView.swift +++ b/LoopFollow/Snoozer/SnoozerView.swift @@ -46,9 +46,16 @@ struct SnoozerView: View { rightColumn(isLandscape: true) } } else { - VStack(spacing: 0) { - leftColumn(isLandscape: false, barShowing: barShowing) - rightColumn(isLandscape: false) + GeometryReader { content in + VStack(spacing: 0) { + leftColumn( + isLandscape: false, + barShowing: barShowing, + availableHeight: content.size.height - portraitRightColumnMinHeight + ) + .layoutPriority(1) + rightColumn(isLandscape: false) + } } } } @@ -155,23 +162,49 @@ struct SnoozerView: View { // MARK: - Columns - private func leftColumn(isLandscape: Bool, barShowing: Bool) -> some View { + private let portraitEmojiMinHeight: CGFloat = 70 + private let portraitClockMinHeight: CGFloat = 80 + private let portraitClockBottomSpace: CGFloat = 16 + private let displayNameHeight: CGFloat = 60 + + /// Height the emoji and clock need below the left column in portrait. + private var portraitRightColumnMinHeight: CGFloat { + let emoji = snoozerEmoji.value ? portraitEmojiMinHeight + 4 : 0 + return emoji + portraitClockMinHeight + portraitClockBottomSpace + 8 + } + + /// The left column's heights are scaled down together when `availableHeight` + /// is smaller than their combined maximum, so the column never pushes the + /// emoji and clock off screen. + private func leftColumn(isLandscape: Bool, barShowing: Bool, availableHeight: CGFloat? = nil) -> some View { let topPad: CGFloat = barShowing ? 0 : 16 - let bigMaxH: CGFloat = barShowing ? (isLandscape ? 210 : 220) : 240 - let dirMaxH: CGFloat = barShowing ? (isLandscape ? 72 : 72) : 80 - let deltaMaxH: CGFloat = barShowing ? (isLandscape ? 60 : 60) : 68 - let ageMaxH: CGFloat = barShowing ? 36 : 40 + let nameH: CGFloat = (!isLandscape && showDisplayName.value) ? displayNameHeight : 0 + let maxHeights: [CGFloat] = [ + barShowing ? (isLandscape ? 210 : 200) : 240, + barShowing ? (isLandscape ? 72 : 90) : (isLandscape ? 80 : 110), + barShowing ? (isLandscape ? 60 : 80) : (isLandscape ? 68 : 100), + barShowing ? (isLandscape ? 36 : 60) : (isLandscape ? 40 : 80), + ] + let fit: CGFloat = availableHeight.map { + min(1, max(0, $0 - topPad - nameH) / maxHeights.reduce(0, +)) + } ?? 1 + let bigMaxH = maxHeights[0] * fit + let dirMaxH = maxHeights[1] * fit + let deltaMaxH = maxHeights[2] * fit + let ageMaxH = maxHeights[3] * fit + let minScale: CGFloat = isLandscape ? 0.5 : 0.3 return VStack(spacing: 0) { if !isLandscape && showDisplayName.value { Text(Bundle.main.displayName) .font(.system(size: 50, weight: .bold)) .foregroundColor(.white.opacity(0.9)) + .frame(height: displayNameHeight) } Text(bgText.value) .font(.system(size: 300, weight: .black)) - .minimumScaleFactor(0.5) + .minimumScaleFactor(minScale) .foregroundColor(bgTextColor.value) .strikethrough( bgStale.value, @@ -185,29 +218,29 @@ struct SnoozerView: View { Text(directionText.value) .font(.system(size: 90, weight: .black)) Text(deltaText.value) - .font(.system(size: 70)) + .font(.system(size: 70).monospacedDigit()) } - .minimumScaleFactor(0.5) + .minimumScaleFactor(minScale) .foregroundColor(.white) .frame(maxWidth: .infinity, maxHeight: dirMaxH) } else { Text(directionText.value) - .font(.system(size: 110, weight: .black)) - .minimumScaleFactor(0.5) + .font(.system(size: 160, weight: .black)) + .minimumScaleFactor(minScale) .foregroundColor(.white) .frame(maxWidth: .infinity, maxHeight: dirMaxH) Text(deltaText.value) - .font(.system(size: 70)) - .minimumScaleFactor(0.5) - .foregroundColor(.white.opacity(0.8)) + .font(.system(size: 140).monospacedDigit()) + .minimumScaleFactor(minScale) + .foregroundColor(.white) .frame(maxWidth: .infinity, maxHeight: deltaMaxH) } Text(minAgoText.value) - .font(.system(size: 60)) - .minimumScaleFactor(0.5) - .foregroundColor(.white.opacity(0.6)) + .font(.system(size: isLandscape ? 60 : 80).monospacedDigit()) + .minimumScaleFactor(minScale) + .foregroundColor(.white) .frame(maxWidth: .infinity, maxHeight: ageMaxH) } .padding(.top, topPad) @@ -224,32 +257,23 @@ struct SnoozerView: View { .padding(.bottom, 8) } - if snoozerEmoji.value { - TimelineView(.periodic(from: .now, by: 1)) { context in - VStack(spacing: 4) { + TimelineView(.periodic(from: .now, by: 1)) { context in + VStack(spacing: 4) { + if snoozerEmoji.value { Text(bgEmoji) .font(.system(size: 128)) .minimumScaleFactor(0.5) - - Text(context.date, format: Date.FormatStyle(date: .omitted, time: .shortened)) - .font(.system(size: 70)) - .minimumScaleFactor(0.5) - .foregroundColor(.white) - .frame(height: 78) - } - } - } else { - TimelineView(.periodic(from: .now, by: 1)) { context in - VStack(spacing: 4) { - Text(context.date, format: Date.FormatStyle(date: .omitted, time: .shortened)) - .font(.system(size: 70)) - .minimumScaleFactor(0.5) - .foregroundColor(.white) - .frame(height: 78) + .frame(minHeight: isLandscape ? nil : portraitEmojiMinHeight) } + + Text(context.date, format: Date.FormatStyle(date: .omitted, time: .shortened)) + .font(.system(size: isLandscape ? 70 : 100).monospacedDigit()) + .minimumScaleFactor(0.5) + .foregroundColor(.white) + .frame(minHeight: isLandscape ? 78 : portraitClockMinHeight, maxHeight: isLandscape ? 78 : 100) } } - Spacer() + Spacer(minLength: isLandscape ? nil : portraitClockBottomSpace) } } From 81d47ea348211646b4d14dd2d67f2f841aa50691 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 14 Oct 2026 22:56:30 +0000 Subject: [PATCH 28/84] CI: Bump dev version to 7.1.6 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index a7b28678d..0b1965e0b 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.5 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.6 From 9e2ec50a1794d76e8c3e8c81b4266855242e6d3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 16 Oct 2026 23:18:12 +0200 Subject: [PATCH 29/84] Show Loop APNS environment as info instead of a toggle (#776) The environment is read from the isAPNSProduction value that Loop uploads to the Nightscout profile, so the manual Production toggle is removed from the Loop APNS settings. The current environment is shown under Debug / Info, the same as for Trio. --- LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift | 12 +----------- LoopFollow/Remote/Settings/RemoteSettingsView.swift | 12 +----------- .../Remote/Settings/RemoteSettingsViewModel.swift | 9 --------- 3 files changed, 2 insertions(+), 31 deletions(-) diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index 38dc4fb23..b91901bd9 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -305,15 +305,6 @@ class LoopAPNSService { return regex?.firstMatch(in: text, options: [], range: range) != nil } - /// Provides simple environment guidance for APNS configuration - /// - Returns: String with simple guidance to try opposite setting - private func getEnvironmentGuidance() -> String { - let currentSetting = storage.productionEnvironment.value ? "ON" : "OFF" - let trySetting = storage.productionEnvironment.value ? "OFF" : "ON" - - return "Try changing Production Environment from \(currentSetting) to \(trySetting) in your Loop APNS settings." - } - /// Sends an APNS notification /// - Parameters: /// - deviceToken: The device token to send to @@ -424,8 +415,7 @@ class LoopAPNSService { LogManager.shared.log(category: .apns, message: "APNS notification sent successfully") completion(true, nil) case 400: - let environmentGuidance = self.getEnvironmentGuidance() - let errorMessage = "Bad request. The request was invalid or malformed. \(responseBodyMessage)\n\n\(environmentGuidance)" + let errorMessage = "Bad request. The request was invalid or malformed. \(responseBodyMessage)" LogManager.shared.log(category: .apns, message: "APNS error 400: \(responseBodyMessage) - Check device token and environment settings") completion(false, errorMessage) case 403: diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index 6e9b2acaa..c4f8a5c92 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -235,17 +235,6 @@ struct RemoteSettingsView: View { .buttonStyle(.borderedProminent) .frame(maxWidth: .infinity) .padding(.vertical, 10) - - HStack { - Text("Environment") - Spacer() - Toggle("Production", isOn: $viewModel.productionEnvironment) - .toggleStyle(SwitchToggleStyle()) - } - - Text("Production is used for browser builders and should be switched off for Xcode builders") - .font(.caption) - .foregroundColor(.secondary) } if let errorMessage = viewModel.loopAPNSErrorMessage, !errorMessage.isEmpty { @@ -258,6 +247,7 @@ struct RemoteSettingsView: View { Section(header: Text("Debug / Info")) { Text("Device Token: \(Storage.shared.deviceToken.value)") + Text("APNS Environment: \(Storage.shared.productionEnvironment.value ? "Production" : "Development")") Text("Bundle ID: \(Storage.shared.bundleId.value)") if let otpCode = TOTPGenerator.extractOTPFromURL(Storage.shared.loopAPNSQrCodeURL.value) { diff --git a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift index 2b3ab9af0..04c1a8ce7 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsViewModel.swift @@ -25,7 +25,6 @@ class RemoteSettingsViewModel: ObservableObject { @Published var loopDeveloperTeamId: String @Published var loopAPNSQrCodeURL: String - @Published var productionEnvironment: Bool @Published var isShowingLoopAPNSScanner: Bool = false @Published var loopAPNSErrorMessage: String? @@ -103,8 +102,6 @@ class RemoteSettingsViewModel: ObservableObject { loopDeveloperTeamId = storage.teamId.value ?? "" loopAPNSQrCodeURL = storage.loopAPNSQrCodeURL.value - productionEnvironment = storage.productionEnvironment.value - setupBindings() } @@ -188,11 +185,6 @@ class RemoteSettingsViewModel: ObservableObject { .dropFirst() .sink { [weak self] in self?.storage.loopAPNSQrCodeURL.value = $0 } .store(in: &cancellables) - - $productionEnvironment - .dropFirst() - .sink { [weak self] in self?.storage.productionEnvironment.value = $0 } - .store(in: &cancellables) } func handleLoopAPNSQRCodeScanResult(_ result: Result) { @@ -233,7 +225,6 @@ class RemoteSettingsViewModel: ObservableObject { mealWithFatProtein = storage.mealWithFatProtein.value loopDeveloperTeamId = storage.teamId.value ?? "" loopAPNSQrCodeURL = storage.loopAPNSQrCodeURL.value - productionEnvironment = storage.productionEnvironment.value // Update device-related properties isTrioDevice = (storage.device.value == "Trio") From b432ef64231327962bb5743ba0f6940d82508aad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 16 Oct 2026 21:18:22 +0000 Subject: [PATCH 30/84] CI: Bump dev version to 7.1.7 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 0b1965e0b..f696188d9 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.6 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.7 From 202a3cc9b9e13a92847d3b5caefac5d5fdb2f206 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 16 Oct 2026 23:42:20 +0200 Subject: [PATCH 31/84] Fit the Live Activity lock screen card on narrow screens (#775) The left column and the metric grid used fixed minimum widths that needed about 369 pt, so with Display Zoom the card was wider than the lock screen and the glucose value and delta got cut off. They now share the available width, and the metric blocks shrink below 72 pt when needed. Fixes #744 --- LoopFollowLAExtension/LoopFollowLiveActivity.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 6d438531e..caeb90393 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -214,8 +214,7 @@ private struct LockScreenLiveActivityView: View { .foregroundStyle(.white.opacity(0.80)) .lineLimit(1) } - .frame(minWidth: 160, maxWidth: 184, alignment: .leading) - .layoutPriority(2) + .frame(maxWidth: .infinity, alignment: .leading) Rectangle() .fill(Color.white.opacity(0.20)) @@ -322,7 +321,7 @@ private struct MetricBlock: View { .allowsTightening(true) .layoutPriority(1) } - .frame(width: 72, alignment: .leading) + .frame(maxWidth: 72, alignment: .leading) } } @@ -360,7 +359,7 @@ private struct SlotView: View { var body: some View { if option == .none { Color.clear - .frame(width: 72, height: 36) + .frame(maxWidth: 72, minHeight: 36, maxHeight: 36) } else { MetricBlock(label: option.gridLabel, value: slotFormattedValue(option: option, snapshot: snapshot)) } From b8723776de8bcc5ee7b592fe0389e5f6f29058b6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 16 Oct 2026 21:42:30 +0000 Subject: [PATCH 32/84] CI: Bump dev version to 7.1.8 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index f696188d9..9fa5a1d06 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.7 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.8 From 4530d57684ff89fea97dc9f82b5b6e8ed8b67b9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 17 Oct 2026 00:49:48 +0200 Subject: [PATCH 33/84] Use the selected range mode for Live Activity colors (#762) The Live Activity now colors the reading against the same low and high values as the chart and BG header, so TIR and TITR use 70-180 and 70-140 and Custom uses the user's lines. A reading exactly on a line counts as low or high, like in the header. Changing the range mode refreshes the Live Activity. --- .../LiveActivity/LiveActivityManager.swift | 24 +++++++++---------- .../ViewControllers/MainViewController.swift | 1 + .../LoopFollowLiveActivity.swift | 12 +++++----- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 125ba4e09..e8eb43de3 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -182,10 +182,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, - ) + writeThresholds() GlucoseSnapshotStore.shared.save(snapshot) seq += 1 @@ -624,10 +621,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, - ) + writeThresholds() LAAppGroupSettings.setDisplayName( Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow", show: Storage.shared.showDisplayName.value @@ -904,6 +898,12 @@ final class LiveActivityManager { } } + /// Publishes the range for the selected time-in-range mode, matching the chart and BG header. + private func writeThresholds() { + 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 { @@ -928,12 +928,10 @@ final class LiveActivityManager { let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60 // Capture dedup result BEFORE saving so the store comparison is valid. let snapshotUnchanged = GlucoseSnapshotStore.shared.load() == snapshot + let thresholdsChanged = LAAppGroupSettings.thresholdsMgdl() != UnitSettingsStore.shared.effectiveThresholds() // Store + Watch: always update, independent of LA state. - LAAppGroupSettings.setThresholds( - lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value, - ) + writeThresholds() GlucoseSnapshotStore.shared.save(snapshot) // WatchConnectivityManager.shared.send(snapshot: snapshot) @@ -946,7 +944,7 @@ final class LiveActivityManager { LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped β€” dismissedByUser=true reason=\(reason)") return } - guard !snapshotUnchanged || forceRefreshNeeded else { return } + guard !snapshotUnchanged || thresholdsChanged || forceRefreshNeeded else { return } guard ActivityAuthorizationInfo().areActivitiesEnabled else { LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped β€” areActivitiesEnabled=false reason=\(reason)") return diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index dace62437..1d594ea69 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -287,6 +287,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { .sink { [weak self] _ in self?.updateBGGraphSettings() self?.updateBGGraph() + LiveActivityManager.shared.refreshFromCurrentState(reason: "range mode changed") } .store(in: &cancellables) diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index caeb90393..6414882d7 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -778,12 +778,12 @@ private enum LAColors { let low = t.low let high = t.high - if mgdl < low { - let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / (low - 54.0)) + if mgdl <= low { + let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / max(low - 54.0, 1)) let opacity = min(max(raw, 0.48), 0.85) return Color(uiColor: UIColor.systemRed).opacity(opacity) - } else if mgdl > high { - let raw = 0.44 + (0.85 - 0.44) * ((mgdl - high) / (324.0 - high)) + } else if mgdl >= high { + let raw = 0.44 + (0.85 - 0.44) * ((mgdl - high) / max(324.0 - high, 1)) let opacity = min(max(raw, 0.44), 0.85) return Color(uiColor: UIColor.systemOrange).opacity(opacity) } else { @@ -797,9 +797,9 @@ private enum LAColors { let low = t.low let high = t.high - if mgdl < low { + if mgdl <= low { return Color(uiColor: UIColor.systemRed) - } else if mgdl > high { + } else if mgdl >= high { return Color(uiColor: UIColor.systemOrange) } else { return Color(uiColor: UIColor.systemGreen) From 986993fc24584195174bea9769d3c578fe476e08 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 16 Oct 2026 22:49:58 +0000 Subject: [PATCH 34/84] CI: Bump dev version to 7.1.9 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 9fa5a1d06..1bf90b29d 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.8 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.9 From d4bf4b7db2a74b533c84e4ff08a33c6eb348fdce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 17 Oct 2026 00:55:06 +0200 Subject: [PATCH 35/84] Fix Missed Bolus alarm never firing (#769) MissedBolusCondition was not in the AlarmManager evaluator list, so the alarm could be created but was never checked. The condition also required the optional BG limit to be set, which it is not by default, and only looked at the last carb entry, which can be a future-dated one. The BG limit is now handled by the shared BG limit check like other alarms, and the most recent carb entry that is not in the future is used. --- .../AlarmCondition/MissedBolusCondition.swift | 14 +-- LoopFollow/Alarm/AlarmManager.swift | 1 + Tests/AlarmConditions/Helpers.swift | 13 ++- .../MissedBolusConditionTests.swift | 108 ++++++++++++++++++ 4 files changed, 125 insertions(+), 11 deletions(-) create mode 100644 Tests/AlarmConditions/MissedBolusConditionTests.swift diff --git a/LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift b/LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift index 53bbeabc4..3faf61ce9 100644 --- a/LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift @@ -5,7 +5,8 @@ import Foundation /// Fires when a carb entry is logged but **no** qualifying bolus is given /// within the user-defined β€œdelay” (after allowing for a pre-bolus window). -/// β€’ Ignores small-carb treatments, tiny boluses, and low-BG scenarios. +/// β€’ Ignores small-carb treatments and tiny boluses; the optional BG limit +/// (`aboveBG`) is enforced by `passesBGLimits`. /// β€’ Triggers once per carb entry. struct MissedBolusCondition: AlarmCondition { static let type: AlarmType = .missedBolus @@ -19,14 +20,13 @@ struct MissedBolusCondition: AlarmCondition { let delayMin = alarm.monitoringWindow, delayMin > 0, let prebolusMin = alarm.predictiveMinutes, let minBolusU = alarm.delta, // ignore bolus ≀ - let minCarbGr = alarm.threshold, // ignore carbs ≀ - let minBG = alarm.aboveBG // ignore BG ≀ + let minCarbGr = alarm.threshold // ignore carbs ≀ else { return false } // ──────────────────────────────── - // 1. get most-recent carb entry + // 1. get most-recent past carb entry // ──────────────────────────────── - guard let carb = data.recentCarbs.last else { return false } + guard let carb = data.recentCarbs.last(where: { $0.date <= now }) else { return false } // – must be at least `delayMin` old, but not older than 60 min guard carb.date > now.addingTimeInterval(-3600), @@ -36,10 +36,6 @@ struct MissedBolusCondition: AlarmCondition { // – ignore tiny carbs guard carb.grams > minCarbGr else { return false } - // – ignore if BG is low - if let latestBG = data.bgReadings.last, - Double(latestBG.sgv) <= minBG { return false } - // ──────────────────────────────── // 2. already alerted for this carb? // ──────────────────────────────── diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index 29c6128d9..5ed8afafa 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -34,6 +34,7 @@ class AlarmManager { BatteryCondition.self, BatteryDropCondition.self, FutureCarbsCondition.self, + MissedBolusCondition.self, DBSizeCondition.self, ] ) { diff --git a/Tests/AlarmConditions/Helpers.swift b/Tests/AlarmConditions/Helpers.swift index b4287504e..905330552 100644 --- a/Tests/AlarmConditions/Helpers.swift +++ b/Tests/AlarmConditions/Helpers.swift @@ -29,6 +29,15 @@ extension Alarm { return alarm } + static func missedBolus(delay: Int = 15, prebolus: Int = 15, minBolus: Double = 0.1, minCarbs: Double = 4) -> Self { + var alarm = Alarm(type: .missedBolus) + alarm.monitoringWindow = delay + alarm.predictiveMinutes = prebolus + alarm.delta = minBolus + alarm.threshold = minCarbs + return alarm + } + static func low(belowBG: Double?, predictiveMinutes: Int? = nil, persistentMinutes: Int? = nil) -> Self { var alarm = Alarm(type: .low) alarm.belowBG = belowBG @@ -160,7 +169,7 @@ extension AlarmData { ) } - static func withCarbs(_ carbs: [CarbSample]) -> Self { + static func withCarbs(_ carbs: [CarbSample], boluses: [BolusEntry] = []) -> Self { AlarmData( bgReadings: [], predictionData: [], @@ -176,7 +185,7 @@ extension AlarmData { pumpInsertTime: nil, latestPumpVolume: nil, IOB: nil, - recentBoluses: [], + recentBoluses: boluses, latestBattery: nil, latestBatteryIsCharging: nil, latestPumpBattery: nil, diff --git a/Tests/AlarmConditions/MissedBolusConditionTests.swift b/Tests/AlarmConditions/MissedBolusConditionTests.swift new file mode 100644 index 000000000..942b548ea --- /dev/null +++ b/Tests/AlarmConditions/MissedBolusConditionTests.swift @@ -0,0 +1,108 @@ +// LoopFollow +// MissedBolusConditionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@Suite(.serialized) +struct MissedBolusConditionTests { + let cond = MissedBolusCondition() + + private func resetNotified() { + Storage.shared.lastMissedBolusNotified.value = nil + } + + private func carb(minutesAgo: Double, grams: Double = 30, relativeTo now: Date) -> CarbSample { + CarbSample(grams: grams, date: now.addingTimeInterval(-minutesAgo * 60)) + } + + private func bolus(minutesAgo: Double, units: Double, relativeTo now: Date) -> BolusEntry { + BolusEntry(units: units, date: now.addingTimeInterval(-minutesAgo * 60)) + } + + @Test("#fires β€” default alarm, carbs past delay with no bolus") + func firesWithDefaults() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs([carb(minutesAgo: 20, relativeTo: now)]) + + #expect(cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + } + + @Test("#fires once β€” same carb entry does not fire twice") + func firesOncePerCarb() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs([carb(minutesAgo: 20, relativeTo: now)]) + + #expect(cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + #expect(!cond.evaluate(alarm: .missedBolus(), data: data, now: now.addingTimeInterval(300))) + } + + @Test("#within delay β€” carbs newer than delay do not fire") + func withinDelayNoFire() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs([carb(minutesAgo: 10, relativeTo: now)]) + + #expect(!cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + } + + @Test("#bolus given β€” bolus after carbs suppresses") + func bolusAfterCarbsNoFire() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs( + [carb(minutesAgo: 20, relativeTo: now)], + boluses: [bolus(minutesAgo: 18, units: 2, relativeTo: now)] + ) + + #expect(!cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + } + + @Test("#pre-bolus β€” bolus inside pre-bolus window suppresses") + func preBolusNoFire() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs( + [carb(minutesAgo: 20, relativeTo: now)], + boluses: [bolus(minutesAgo: 30, units: 2, relativeTo: now)] + ) + + #expect(!cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + } + + @Test("#small bolus β€” bolus at or below limit is ignored") + func smallBolusIgnored() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs( + [carb(minutesAgo: 20, relativeTo: now)], + boluses: [bolus(minutesAgo: 18, units: 0.1, relativeTo: now)] + ) + + #expect(cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + } + + @Test("#small carbs β€” carbs at or below limit do not fire") + func smallCarbsNoFire() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs([carb(minutesAgo: 20, grams: 4, relativeTo: now)]) + + #expect(!cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + } + + @Test("#future carbs β€” a future-dated entry does not mask a past one") + func futureCarbDoesNotMaskPast() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs([ + carb(minutesAgo: 20, relativeTo: now), + carb(minutesAgo: -30, relativeTo: now), + ]) + + #expect(cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + } +} From 1740bbec6830f97325c8f30f884c6aec340cdab3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 16 Oct 2026 22:55:16 +0000 Subject: [PATCH 36/84] CI: Bump dev version to 7.1.10 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 1bf90b29d..d504929ae 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.9 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.10 From 9b160e61e3c9afc67932ec00d421eaf5ae3ea4f1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 16 Oct 2026 23:03:26 +0000 Subject: [PATCH 37/84] CI: Bump dev version to 7.1.11 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index d504929ae..54d7699b8 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.10 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.11 From 4f1c69a7650ccf1897c76d714ee8b5465ee578d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 17 Oct 2026 21:31:19 +0200 Subject: [PATCH 38/84] Open treatment details from a chart pill (#781) A single tap on a chart mark shows its pill as before. If the mark is in the Treatments list (carbs, boluses, SMBs, overrides, temp targets) the pill shows a chevron, and tapping the pill opens the treatment's details. Double-tap only zooms. The chart and the Treatments list build their entries with the same Treatment.historyEntries, so the pill opens the entry from the Nightscout treatment that drew the mark. --- LoopFollow/Charts/BGChartModel.swift | 23 +- LoopFollow/Charts/BGChartView.swift | 170 ++++---- LoopFollow/Controllers/NightScout.swift | 9 +- .../Nightscout/Treatments/Bolus.swift | 3 +- .../Nightscout/Treatments/Carbs.swift | 2 +- .../Nightscout/Treatments/Overrides.swift | 3 +- .../Nightscout/Treatments/SMB.swift | 3 +- .../Treatments/TemporaryTarget.swift | 3 +- LoopFollow/Helpers/DataStructs.swift | 8 +- LoopFollow/Treatments/CarbTreatment.swift | 30 -- LoopFollow/Treatments/TreatmentsView.swift | 375 +++++++++--------- Tests/TreatmentHistoryEntriesTests.swift | 58 +++ 12 files changed, 384 insertions(+), 303 deletions(-) delete mode 100644 LoopFollow/Treatments/CarbTreatment.swift create mode 100644 Tests/TreatmentHistoryEntriesTests.swift diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index a09906be3..f0bf42859 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -54,19 +54,20 @@ final class BGChartModel: ObservableObject { let sgv: Double let label: String let pillText: String - let treatment: CarbTreatment? + /// The Treatments list entry the pill links to; nil for marks the list doesn't show. + let detail: Treatment? /// Where the symbol is drawn. Equals `date` unless `spread` nudged it /// left to keep a crowded run of treatments from stacking up. var drawnDate: Date var id: Double { date.timeIntervalSince1970 } - init(date: Date, value: Double, sgv: Double, label: String, pillText: String, treatment: CarbTreatment? = nil) { + init(date: Date, value: Double, sgv: Double, label: String, pillText: String, detail: Treatment? = nil) { self.date = date self.value = value self.sgv = sgv self.label = label self.pillText = pillText - self.treatment = treatment + self.detail = detail drawnDate = date } } @@ -91,6 +92,8 @@ final class BGChartModel: ObservableObject { let yTop: Double let label: String let pillText: String + /// The Treatments list entry the pill links to. + var detail: Treatment? = nil var id: String { "\(start.timeIntervalSince1970)-\(end.timeIntervalSince1970)" } } @@ -469,7 +472,8 @@ final class BGChartModel: ObservableObject { value: $0.value, sgv: Double($0.sgv), label: dose, - pillText: "Bolus\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" + pillText: "Bolus\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))", + detail: $0.detail ) } carbs = Self.spread((showCarbs ? vc.carbData : []).map { @@ -484,7 +488,7 @@ final class BGChartModel: ObservableObject { sgv: Double($0.sgv), label: label, pillText: "Carbs\n\(grams)g\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))", - treatment: $0.treatment + detail: $0.detail ) }, minGap: Spread.carbGap, maxShift: Spread.carbShift) let smbPoints = (showBolus ? vc.smbData : []).map { @@ -494,7 +498,8 @@ final class BGChartModel: ObservableObject { value: $0.value, sgv: Double($0.sgv), label: dose, - pillText: "SMB\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" + pillText: "SMB\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))", + detail: $0.detail ) } (boluses, smbs) = Self.spreadTogether(bolusPoints, smbPoints, minGap: Spread.bolusGap, maxShift: Spread.bolusShift) @@ -559,7 +564,8 @@ final class BGChartModel: ObservableObject { yBottom: yBottom, yTop: yTop, label: displayName, - pillText: "Override\n\(displayName)\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" + pillText: "Override\n\(displayName)\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))", + detail: $0.detail ) } tempTargets = (showOtherTreatments ? vc.tempTargetGraphData : []).map { @@ -573,7 +579,8 @@ final class BGChartModel: ObservableObject { yBottom: yCenter - 5, yTop: yCenter + 5, label: "Temp Target", - pillText: "Temp Target\n\(Localizer.toDisplayUnits(target))\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))" + pillText: "Temp Target\n\(Localizer.toDisplayUnits(target))\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))", + detail: $0.detail ) } diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index 50a64b129..b35663863 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -46,6 +46,8 @@ private enum BGChartConfig { static let autoFollowPause: TimeInterval = 5 * 60 /// Screen-space radius (pt) within which a tap selects a mark. static let tapHitRadius: CGFloat = 30 + /// How far (pt) outside a linked pill a tap still opens its treatment. + static let pillLinkSlop: CGFloat = 8 } /// For line/area series: the items inside the window plus one beyond each @@ -83,7 +85,7 @@ struct BGChartView: View { /// attachments while BGChartInteraction preserves the viewport. @State private var gestureMountEpoch = 0 - /// Carb entry opened by a double-tap on the main chart. Owned here, outside + /// Treatment opened from a tapped pill on the main chart. Owned here, outside /// the remounted subtree, so the sheet survives a foregrounding remount. @State private var selectedTreatment: Treatment? @@ -112,7 +114,7 @@ struct BGChartView: View { } private func rootMeal(for treatment: Treatment) -> Treatment? { - treatment.rootMeal(in: Treatment.rootMealsByFPUID(model.carbs.compactMap { $0.treatment?.detailTreatment })) + treatment.rootMeal(in: Treatment.rootMealsByFPUID(model.carbs.compactMap(\.detail))) } } @@ -129,13 +131,14 @@ struct BGChartView: View { /// whose live preview is a `.scaleEffect(x:)` stretch anchored under the /// pinch centroid, committed on a geometric zoom grid. A one-finger press /// held stationary latches into inspect mode and scrubs a selection that is -/// rendered by a shell overlay (never re-laying the canvas). Double-tap on a -/// carb mark opens its details; elsewhere it cycles zoom presets. +/// rendered by a shell overlay (never re-laying the canvas). A tap on a mark +/// shows its pill, and a tap on a pill that links to the Treatments list opens +/// that treatment's details. Double-tap cycles zoom presets. /// No `.chartScrollableAxes`, no UIKit gesture hacks. private struct MainBGChart: View { @ObservedObject var model: BGChartModel @ObservedObject var interaction: BGChartInteraction - /// Carb entry to show in the detail sheet; the shell owns the sheet. + /// Treatment to show in the detail sheet; the shell owns the sheet. @Binding var selectedTreatment: Treatment? /// Rendered slice of the domain. The canvas covers only this window @@ -319,15 +322,15 @@ private struct MainBGChart: View { .contentShape(Rectangle()) .simultaneousGesture(panAndInspectGesture(viewportWidth: viewportWidth)) .simultaneousGesture(magnifyGesture(viewportWidth: viewportWidth)) - // Double-tap opens carb treatment details, otherwise zooms. Single-tap - // waits for the double-tap window before selecting a mark. + // Double-tap zooms. Single-tap waits for the double-tap window before + // selecting a mark or following a pill's link. .simultaneousGesture( SpatialTapGesture(count: 2) .exclusively(before: SpatialTapGesture()) .onEnded { value in switch value { - case let .first(tap): - handleDoubleTap(at: tap.location, viewportWidth: viewportWidth) + case .first: + cycleZoomPreset() case let .second(tap): handleTap(at: tap.location, viewportWidth: viewportWidth, mainHeight: mainHeight) } @@ -795,6 +798,8 @@ private struct MainBGChart: View { let texts: [String] /// The BG reading under the selector, whose forecast can be shown. var readingDate: Date? + /// The Treatments list entry a tap on the pill opens. + var detail: Treatment? } /// Feeds every treatment mark to `body`. Single source for both the scrub @@ -831,18 +836,30 @@ private struct MainBGChart: View { return texts } + /// Treatments list entry of a band at `date`, the override's before the + /// temp target's (the order bandPillTexts lists them in). + private func bandDetail(at date: Date) -> Treatment? { + let override = model.overrides + .filter { date >= $0.start && date <= $0.end } + .max { $0.start < $1.start } + let tempTarget = model.tempTargets + .filter { date >= $0.start && date <= $0.end } + .max { $0.start < $1.start } + return override?.detail ?? tempTarget?.detail + } + /// Band (override / temp target) under the given date+value, if any. private func bandAnchor(at date: Date, value: Double) -> SelectionAnchor? { for band in model.overrides where date >= band.start && date <= band.end { if value >= band.yBottom, value <= band.yTop { let midY = (band.yTop + band.yBottom) / 2 - return SelectionAnchor(date: date, value: midY, texts: [band.pillText]) + return SelectionAnchor(date: date, value: midY, texts: [band.pillText], detail: band.detail) } } for band in model.tempTargets where date >= band.start && date <= band.end { if value >= band.yBottom, value <= band.yTop { let midY = (band.yTop + band.yBottom) / 2 - return SelectionAnchor(date: date, value: midY, texts: [band.pillText]) + return SelectionAnchor(date: date, value: midY, texts: [band.pillText], detail: band.detail) } } return nil @@ -906,17 +923,17 @@ private struct MainBGChart: View { var best: SelectionAnchor? var bestDistance2 = radius * radius - func consider(_ date: Date, _ value: Double, _ text: String, isReading: Bool = false) { + func consider(_ date: Date, _ value: Double, _ text: String, isReading: Bool = false, detail: Treatment? = nil) { let dx = xPosition(for: date, viewportWidth: viewportWidth) - location.x let dy = yPosition(forValue: value) - location.y let d2 = dx * dx + dy * dy if d2 <= bestDistance2 { bestDistance2 = d2 - best = SelectionAnchor(date: date, value: value, texts: [text], readingDate: isReading ? date : nil) + best = SelectionAnchor(date: date, value: value, texts: [text], readingDate: isReading ? date : nil, detail: detail) } } - forEachTreatmentAnchor { consider($0.drawnDate, $0.sgv, $0.pillText) } + forEachTreatmentAnchor { consider($0.drawnDate, $0.sgv, $0.pillText, detail: $0.detail) } if best == nil { for p in model.bg { consider(p.date, p.value, bgPillText(for: p), isReading: true) @@ -930,15 +947,28 @@ private struct MainBGChart: View { } if let best { let texts = best.texts + bandPillTexts(at: best.date) - return SelectionAnchor(date: best.date, value: best.value, texts: texts, readingDate: best.readingDate) + let detail = best.detail ?? bandDetail(at: best.date) + return SelectionAnchor(date: best.date, value: best.value, texts: texts, readingDate: best.readingDate, detail: detail) } return nil } - /// Taps on the main chart hit-test its marks; taps on a history pane select - /// by time alone, like a scrub. + /// A tap on a linked pill opens its treatment. Otherwise taps on the main + /// chart hit-test its marks, and taps on a history pane select by time + /// alone, like a scrub. private func handleTap(at location: CGPoint, viewportWidth: CGFloat, mainHeight: CGFloat) { guard plotFrame.height > 0 else { return } + if let detail = tapped?.detail, + let frame = pillFrame(for: tapped, viewportWidth: viewportWidth), + frame.insetBy(dx: -BGChartConfig.pillLinkSlop, dy: -BGChartConfig.pillLinkSlop).contains(location) + { + momentumTask?.cancel() + momentumTask = nil + resetGestureState() + tapped = nil + selectedTreatment = detail + return + } if location.y > mainHeight { let date = interaction.scrollPosition.addingTimeInterval( interaction.visibleSeconds * TimeInterval(location.x / viewportWidth) @@ -956,39 +986,6 @@ private struct MainBGChart: View { return min(preferred, viewportHeight * 0.45 / CGFloat(count)) } - /// Double-tap hit test (screen-space, 2D) over carb marks only, at their - /// drawn (decluttered) positions. Returns nil when no carb is under the - /// finger β€” which cycles the zoom preset. - private func tappedCarb(at location: CGPoint, viewportWidth: CGFloat) -> BGChartModel.TreatmentPoint? { - let radius = BGChartConfig.tapHitRadius - var best: BGChartModel.TreatmentPoint? - var bestDistance2 = radius * radius - - for point in model.carbs { - let dx = xPosition(for: point.drawnDate, viewportWidth: viewportWidth) - location.x - let dy = yPosition(forValue: point.sgv) - location.y - let d2 = dx * dx + dy * dy - if d2 <= bestDistance2 { - bestDistance2 = d2 - best = point - } - } - return best - } - - private func handleDoubleTap(at location: CGPoint, viewportWidth: CGFloat) { - // A carb mark without AID metadata (no remote handle) has no details to show. - guard plotFrame.height > 0, let carb = tappedCarb(at: location, viewportWidth: viewportWidth)?.treatment else { - cycleZoomPreset() - return - } - momentumTask?.cancel() - momentumTask = nil - resetGestureState() - tapped = nil - selectedTreatment = carb.detailTreatment - } - /// The anchor the overlay should show: a live scrub wins over a sticky tap. private func activeAnchor() -> SelectionAnchor? { if isInspectLatched, let selected = selection { @@ -1047,6 +1044,20 @@ private struct MainBGChart: View { } } + /// Screen frame of the pill for `anchor`, or nil when no pill is drawn. + /// Shared by the overlay and the tap that follows a pill's link. + private func pillFrame(for anchor: SelectionAnchor?, viewportWidth: CGFloat) -> CGRect? { + guard plotFrame.height > 0, let anchor, !anchor.texts.isEmpty else { return nil } + let x = xPosition(for: anchor.date, viewportWidth: viewportWidth) + guard x >= 0, x <= viewportWidth else { return nil } + // Measured size lags the text by one frame; fall back to a + // small nominal size until the first measurement lands. + let pillW = max(pillSize.width, 60) + let pillH = max(pillSize.height, 28) + let center = pillPosition(x: x, y: yPosition(forValue: anchor.value), pillW: pillW, pillH: pillH, viewportWidth: viewportWidth) + return CGRect(x: center.x - pillW / 2, y: center.y - pillH / 2, width: pillW, height: pillH) + } + /// Where the pill goes for an indicator at (x, y): under the anchor when /// there is room, else above it. private func pillPosition(x: CGFloat, y: CGFloat, pillW: CGFloat, pillH: CGFloat, viewportWidth: CGFloat) -> CGPoint { @@ -1106,7 +1117,6 @@ private struct MainBGChart: View { if plotFrame.height > 0, let anchor = activeAnchor() { let x = xPosition(for: anchor.date, viewportWidth: viewportWidth) if x >= 0, x <= viewportWidth { - let y = yPosition(forValue: anchor.value) // With history panes the indicator runs down through them too. let lineBottom = model.historyPanes.isEmpty ? plotFrame.maxY : indicatorBottom @@ -1115,14 +1125,9 @@ private struct MainBGChart: View { .frame(width: 1, height: lineBottom - plotFrame.minY) .position(x: x, y: (plotFrame.minY + lineBottom) / 2) - // Measured size lags the text by one frame; fall back to a - // small nominal size until the first measurement lands. - let pillW = max(pillSize.width, 60) - let pillH = max(pillSize.height, 28) - if !anchor.texts.isEmpty { - let position = pillPosition(x: x, y: y, pillW: pillW, pillH: pillH, viewportWidth: viewportWidth) - PillLabel(texts: anchor.texts, maxWidth: min(300, viewportWidth - 16)) - .position(x: position.x, y: position.y) + if let frame = pillFrame(for: anchor, viewportWidth: viewportWidth) { + PillLabel(texts: anchor.texts, maxWidth: min(300, viewportWidth - 16), showsLink: anchor.detail != nil) + .position(x: frame.midX, y: frame.midY) } } } @@ -1848,27 +1853,36 @@ private struct PillLabel: View { /// readable over a busy cluster. let texts: [String] let maxWidth: CGFloat + /// The pill opens a Treatments list entry when tapped. + var showsLink = false var body: some View { - content - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color(.secondarySystemBackground)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color.primary, lineWidth: 0.5) - ) - ) - .background( - GeometryReader { geo in - Color.clear.preference(key: PillSizePreferenceKey.self, value: geo.size) - } - ) - // Transparent flexible container: it caps the width the text can - // wrap to, while the visible pill above still hugs its content. - .frame(maxWidth: maxWidth) + HStack(spacing: 4) { + content + if showsLink { + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundColor(.accentColor) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color(.secondarySystemBackground)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(showsLink ? Color.accentColor : Color.primary, lineWidth: showsLink ? 1 : 0.5) + ) + ) + .background( + GeometryReader { geo in + Color.clear.preference(key: PillSizePreferenceKey.self, value: geo.size) + } + ) + // Transparent flexible container: it caps the width the text can + // wrap to, while the visible pill above still hugs its content. + .frame(maxWidth: maxWidth) } @ViewBuilder diff --git a/LoopFollow/Controllers/NightScout.swift b/LoopFollow/Controllers/NightScout.swift index 34a368ab1..2dcfe5a7e 100644 --- a/LoopFollow/Controllers/NightScout.swift +++ b/LoopFollow/Controllers/NightScout.swift @@ -31,19 +31,22 @@ extension MainViewController { } // NS Bolus Data Struct - struct bolusGraphStruct: Codable { + struct bolusGraphStruct { var value: Double var date: TimeInterval var sgv: Int + /// The Treatments list entry this dot opens. + var detail: Treatment? = nil } // NS Bolus Data Struct - struct carbGraphStruct: Codable { + struct carbGraphStruct { var value: Double var date: TimeInterval var sgv: Int var absorptionTime: Int - var treatment: CarbTreatment? = nil + /// The Treatments list entry this dot opens. + var detail: Treatment? = nil } func clearOldTempBasal() { diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift b/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift index 9fe4ba7b1..1c8a4c160 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift @@ -20,7 +20,8 @@ extension MainViewController { if dateTimeStamp < (dateTimeUtils.getNowTimeIntervalUTC() + (60 * 60)) { // Make the dot - let dot = bolusGraphStruct(value: bolus, date: Double(dateTimeStamp), sgv: Int(sgv.sgv + 20)) + let detail = Treatment.historyEntries(from: currentEntry, date: dateTimeStamp) { Int(sgv.sgv) }.bolus + let dot = bolusGraphStruct(value: bolus, date: Double(dateTimeStamp), sgv: Int(sgv.sgv + 20), detail: detail) bolusData.append(dot) } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift index 82d05714c..43a1b2b9b 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift @@ -40,7 +40,7 @@ extension MainViewController { date: Double(dateTimeStamp), sgv: Int(sgv.sgv + Double(offset)), absorptionTime: absorptionTime, - treatment: CarbTreatment(nightscoutEntry: currentEntry, date: dateTimeStamp) + detail: Treatment.historyEntries(from: currentEntry, date: dateTimeStamp) { Int(sgv.sgv) }.carb ) carbData.append(dot) } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift index 00dd745bd..fddc55200 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift @@ -84,7 +84,8 @@ extension MainViewController { reason: (e["notes"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? (e["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "", - sgv: -20 + sgv: -20, + detail: Treatment.historyEntries(from: e, date: startDate.timeIntervalSince1970) { 0 }.entries.first ) overrideGraphData.append(dot) diff --git a/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift b/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift index 012bed356..466503e87 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift @@ -19,7 +19,8 @@ extension MainViewController { if dateTimeStamp < (dateTimeUtils.getNowTimeIntervalUTC() + (60 * 60)) { // Make the dot - let dot = bolusGraphStruct(value: bolus, date: Double(dateTimeStamp), sgv: Int(sgv.sgv + 20)) + let detail = Treatment.historyEntries(from: currentEntry, date: dateTimeStamp) { Int(sgv.sgv) }.bolus + let dot = bolusGraphStruct(value: bolus, date: Double(dateTimeStamp), sgv: Int(sgv.sgv + 20), detail: detail) smbData.append(dot) } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift b/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift index 592e47f7a..2222a230f 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift @@ -53,7 +53,8 @@ extension MainViewController { let endDate = dateTimeStamp + duration - let dot = DataStructs.tempTargetStruct(date: dateTimeStamp, endDate: endDate, duration: duration, correctionRange: [Int(targetValue!)], enteredBy: enteredBy, reason: reason) + let detail = Treatment.historyEntries(from: currentEntry, date: parsedDate.timeIntervalSince1970) { 0 }.entries.first + let dot = DataStructs.tempTargetStruct(date: dateTimeStamp, endDate: endDate, duration: duration, correctionRange: [Int(targetValue!)], enteredBy: enteredBy, reason: reason, detail: detail) tempTargetGraphData.append(dot) // Set activeTempTarget only if it is still active diff --git a/LoopFollow/Helpers/DataStructs.swift b/LoopFollow/Helpers/DataStructs.swift index 9cd8a175f..d6330a536 100644 --- a/LoopFollow/Helpers/DataStructs.swift +++ b/LoopFollow/Helpers/DataStructs.swift @@ -37,7 +37,7 @@ class DataStructs { } // NS Override Data Struct - struct overrideStruct: Codable { + struct overrideStruct { var insulNeedsScaleFactor: Double var date: TimeInterval var endDate: TimeInterval @@ -46,14 +46,18 @@ class DataStructs { var enteredBy: String var reason: String var sgv: Float + /// The Treatments list entry this band opens. + var detail: Treatment? = nil } - struct tempTargetStruct: Codable { + struct tempTargetStruct { var date: TimeInterval var endDate: TimeInterval var duration: Double var correctionRange: [Int] var enteredBy: String var reason: String + /// The Treatments list entry this band opens. + var detail: Treatment? = nil } } diff --git a/LoopFollow/Treatments/CarbTreatment.swift b/LoopFollow/Treatments/CarbTreatment.swift deleted file mode 100644 index 8a373cf61..000000000 --- a/LoopFollow/Treatments/CarbTreatment.swift +++ /dev/null @@ -1,30 +0,0 @@ -// LoopFollow -// CarbTreatment.swift - -import Foundation - -/// Source metadata for a carb mark, preserving the identifier used by its AID app. -enum CarbTreatment: Codable, Equatable { - case loop(LoopCarbTreatment) - case trio(TrioMealTreatment) - - init?(nightscoutEntry entry: [String: AnyObject], date: TimeInterval) { - if let meal = TrioMealTreatment(nightscoutEntry: entry, date: date) { - self = .trio(meal) - } else if let carb = LoopCarbTreatment(nightscoutEntry: entry, date: date) { - self = .loop(carb) - } else { - return nil - } - } - - /// The existing detail screen owns all remote action availability and command handling. - var detailTreatment: Treatment { - switch self { - case let .loop(carb): - return .carb(nightscoutID: carb.nightscoutID, date: carb.date, carbs: carb.carbs, bgValue: 0, loopCarb: carb) - case let .trio(meal): - return .carb(nightscoutID: meal.nightscoutID, date: meal.date, carbs: meal.carbs, bgValue: 0, trioMeal: meal) - } - } -} diff --git a/LoopFollow/Treatments/TreatmentsView.swift b/LoopFollow/Treatments/TreatmentsView.swift index 7aea0415a..c2a91c208 100644 --- a/LoopFollow/Treatments/TreatmentsView.swift +++ b/LoopFollow/Treatments/TreatmentsView.swift @@ -1121,6 +1121,18 @@ enum TreatmentFilter: Hashable { } } +/// What the Treatments list shows for one Nightscout treatment. +struct TreatmentHistoryEntries { + var entries: [Treatment] = [] + /// The entry is an SMB, which picks the list's automatic filter label. + var sawSMB = false + /// The entry is flagged automatic, which picks the list's automatic filter label. + var sawAutomatic = false + + var carb: Treatment? { entries.first { $0.type == .carb } } + var bolus: Treatment? { entries.first { [.bolusManual, .bolusAutomatic, .smb].contains($0.type) } } +} + struct Treatment: Identifiable { let id: String let type: TreatmentType @@ -1182,6 +1194,187 @@ extension Treatment { return parts.compactMap { $0 }.joined(separator: " β€’ ") } + /// The Treatments list entries for one Nightscout treatment. A Meal Bolus yields a carb entry and a bolus entry. + static func historyEntries(from entry: [String: AnyObject], date: TimeInterval, nearestBG: () -> Int) -> TreatmentHistoryEntries { + var result = TreatmentHistoryEntries() + guard let eventType = entry["eventType"] as? String else { return result } + let nsId = entry["_id"] as? String ?? "unknown-\(date)" + + switch eventType { + case "Carb Correction", "Meal Bolus": + let trioMeal = eventType == "Carb Correction" ? TrioMealTreatment(nightscoutEntry: entry, date: date) : nil + let loopCarb = trioMeal == nil ? LoopCarbTreatment(nightscoutEntry: entry, date: date) : nil + let carbs = entry["carbs"] as? Double ?? 0 + if carbs > 0 || trioMeal != nil { + let actualBG = nearestBG() + let treatment = Treatment.carb( + nightscoutID: nsId, + date: date, + carbs: carbs, + bgValue: actualBG, + trioMeal: trioMeal, + loopCarb: loopCarb + ) + result.entries.append(treatment) + } + + // Also process bolus from Meal Bolus + if eventType == "Meal Bolus", + let insulin = entry["insulin"] as? Double, insulin > 0 + { + let actualBG = nearestBG() + let treatment = Treatment( + id: "\(nsId)-bolus", + type: .bolusManual, + date: date, + title: String(format: "%.2f U", insulin), + subtitle: "Bolus", + icon: "circle.fill", + color: .blue, + bgValue: actualBG + ) + result.entries.append(treatment) + } + + case "Correction Bolus", "Bolus", "External Insulin": + if let insulin = entry["insulin"] as? Double, insulin > 0 { + let isAutomatic = entry["automatic"] as? Bool ?? false + let isSMB = entry["isSMB"] as? Bool ?? false + let typeString = (entry["type"] as? String)?.lowercased() + let isTrioSMBType = typeString == "smb" + let treatAsAutomatic = isAutomatic || isSMB || isTrioSMBType + let actualBG = nearestBG() + + if treatAsAutomatic { + result.sawSMB = isSMB || isTrioSMBType + result.sawAutomatic = isAutomatic + let treatment = Treatment( + id: "\(nsId)-smb", + type: .bolusAutomatic, + date: date, + title: String(format: "%.2f U", insulin), + subtitle: isSMB || isTrioSMBType ? "SMB" : "Automatic Bolus", + icon: "arrowtriangle.down.fill", + color: .blue, + bgValue: actualBG + ) + result.entries.append(treatment) + } else { + let treatment = Treatment( + id: "\(nsId)-bolus", + type: .bolusManual, + date: date, + title: String(format: "%.2f U", insulin), + subtitle: "Bolus", + icon: "circle.fill", + color: .blue, + bgValue: actualBG + ) + result.entries.append(treatment) + } + } + + case "SMB": + if let insulin = entry["insulin"] as? Double, insulin > 0 { + result.sawSMB = true + let actualBG = nearestBG() + let treatment = Treatment( + id: "\(nsId)-smb", + type: .smb, + date: date, + title: String(format: "%.2f U", insulin), + subtitle: "SMB", + icon: "arrowtriangle.down.fill", + color: .blue, + bgValue: actualBG + ) + result.entries.append(treatment) + } + + case "Temp Basal": + if let rate = entry["rate"] as? Double { + let treatment = Treatment( + id: "\(nsId)-basal", + type: .tempBasal, + date: date, + title: String(format: "%.2f U/hr", rate), + subtitle: "Temp Basal", + icon: "chart.xyaxis.line", + color: .blue, + bgValue: 0 + ) + result.entries.append(treatment) + } + + case "Temporary Override", "Exercise": + let durationMinutes = (entry["duration"] as? Double) ?? Double(entry["duration"] as? Int ?? 0) + if durationMinutes == 0 { + return result + } + + let reason = (entry["notes"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + ?? (entry["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + ?? "" + + let subtitleParts = ["Override", durationMinutes > 0 ? "\(Int(durationMinutes))m" : nil].compactMap { $0 } + let subtitle = subtitleParts.joined(separator: " β€’ ") + let title = reason.isEmpty ? "Temporary Override" : reason + + let treatment = Treatment( + id: "\(nsId)-override", + type: .override, + date: date, + title: title, + subtitle: subtitle, + icon: "slider.horizontal.3", + color: .purple, + bgValue: 0 + ) + result.entries.append(treatment) + + case "Temporary Target": + let durationMinutes = (entry["duration"] as? Double) ?? Double(entry["duration"] as? Int ?? 0) + if durationMinutes == 0 { + return result + } + + let targetBottom = (entry["targetBottom"] as? Double) ?? Double(entry["targetBottom"] as? Int ?? 0) + let targetTop = (entry["targetTop"] as? Double) ?? Double(entry["targetTop"] as? Int ?? 0) + let targetValue = targetBottom > 0 ? targetBottom : targetTop + + guard targetValue > 0 else { + return result + } + + let title: String + if targetBottom > 0, targetTop > 0, abs(targetTop - targetBottom) >= 0.1 { + title = "\(formatBG(targetBottom)) - \(formatBG(targetTop))" + } else { + title = formatBG(targetValue) + } + + let reason = (entry["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + let subtitleParts = ["Temp Target", durationMinutes > 0 ? "\(Int(durationMinutes))m" : nil, reason?.isEmpty == false ? reason : nil].compactMap { $0 } + let subtitle = subtitleParts.joined(separator: " β€’ ") + + let treatment = Treatment( + id: "\(nsId)-temp-target", + type: .tempTarget, + date: date, + title: title, + subtitle: subtitle, + icon: "scope", + color: .green, + bgValue: 0 + ) + result.entries.append(treatment) + + default: + break + } + return result + } + /// Trio root meals keyed by the `fpuID` their FPU children carry. static func rootMealsByFPUID(_ treatments: some Sequence) -> [UUID: Treatment] { var roots: [UUID: Treatment] = [:] @@ -1391,7 +1584,7 @@ class TreatmentsViewModel: ObservableObject { guard let mainVC = getMainViewController() else { return ([], false, false) } for entry in entries { - guard let eventType = entry["eventType"] as? String, + guard entry["eventType"] is String, let createdAt = entry["created_at"] as? String, let date = NightscoutUtils.parseDate(createdAt) else { @@ -1409,182 +1602,10 @@ class TreatmentsViewModel: ObservableObject { // Mark this entry as processed processedNightscoutIds.insert(nsId) - switch eventType { - case "Carb Correction", "Meal Bolus": - let trioMeal = eventType == "Carb Correction" ? TrioMealTreatment(nightscoutEntry: entry, date: timestamp) : nil - let loopCarb = trioMeal == nil ? LoopCarbTreatment(nightscoutEntry: entry, date: timestamp) : nil - let carbs = entry["carbs"] as? Double ?? 0 - if carbs > 0 || trioMeal != nil { - let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) - let treatment = Treatment.carb( - nightscoutID: nsId, - date: timestamp, - carbs: carbs, - bgValue: actualBG, - trioMeal: trioMeal, - loopCarb: loopCarb - ) - treatments.append(treatment) - } - - // Also process bolus from Meal Bolus - if eventType == "Meal Bolus", - let insulin = entry["insulin"] as? Double, insulin > 0 - { - let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) - let treatment = Treatment( - id: "\(nsId)-bolus", - type: .bolusManual, - date: timestamp, - title: String(format: "%.2f U", insulin), - subtitle: "Bolus", - icon: "circle.fill", - color: .blue, - bgValue: actualBG - ) - treatments.append(treatment) - } - - case "Correction Bolus", "Bolus", "External Insulin": - if let insulin = entry["insulin"] as? Double, insulin > 0 { - let isAutomatic = entry["automatic"] as? Bool ?? false - let isSMB = entry["isSMB"] as? Bool ?? false - let typeString = (entry["type"] as? String)?.lowercased() - let isTrioSMBType = typeString == "smb" - let treatAsAutomatic = isAutomatic || isSMB || isTrioSMBType - let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) - - if treatAsAutomatic { - if isSMB || isTrioSMBType { - detectedSMB = true - } - if isAutomatic { - detectedAutomatic = true - } - let treatment = Treatment( - id: "\(nsId)-smb", - type: .bolusAutomatic, - date: timestamp, - title: String(format: "%.2f U", insulin), - subtitle: isSMB || isTrioSMBType ? "SMB" : "Automatic Bolus", - icon: "arrowtriangle.down.fill", - color: .blue, - bgValue: actualBG - ) - treatments.append(treatment) - } else { - let treatment = Treatment( - id: "\(nsId)-bolus", - type: .bolusManual, - date: timestamp, - title: String(format: "%.2f U", insulin), - subtitle: "Bolus", - icon: "circle.fill", - color: .blue, - bgValue: actualBG - ) - treatments.append(treatment) - } - } - - case "SMB": - if let insulin = entry["insulin"] as? Double, insulin > 0 { - detectedSMB = true - let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) - let treatment = Treatment( - id: "\(nsId)-smb", - type: .smb, - date: timestamp, - title: String(format: "%.2f U", insulin), - subtitle: "SMB", - icon: "arrowtriangle.down.fill", - color: .blue, - bgValue: actualBG - ) - treatments.append(treatment) - } - - case "Temp Basal": - if let rate = entry["rate"] as? Double { - let treatment = Treatment( - id: "\(nsId)-basal", - type: .tempBasal, - date: timestamp, - title: String(format: "%.2f U/hr", rate), - subtitle: "Temp Basal", - icon: "chart.xyaxis.line", - color: .blue, - bgValue: 0 - ) - treatments.append(treatment) - } - - case "Temporary Override", "Exercise": - let durationMinutes = (entry["duration"] as? Double) ?? Double(entry["duration"] as? Int ?? 0) - if durationMinutes == 0 { - continue - } - - let reason = (entry["notes"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - ?? (entry["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - ?? "" - - let subtitleParts = ["Override", durationMinutes > 0 ? "\(Int(durationMinutes))m" : nil].compactMap { $0 } - let subtitle = subtitleParts.joined(separator: " β€’ ") - let title = reason.isEmpty ? "Temporary Override" : reason - - let treatment = Treatment( - id: "\(nsId)-override", - type: .override, - date: timestamp, - title: title, - subtitle: subtitle, - icon: "slider.horizontal.3", - color: .purple, - bgValue: 0 - ) - treatments.append(treatment) - - case "Temporary Target": - let durationMinutes = (entry["duration"] as? Double) ?? Double(entry["duration"] as? Int ?? 0) - if durationMinutes == 0 { - continue - } - - let targetBottom = (entry["targetBottom"] as? Double) ?? Double(entry["targetBottom"] as? Int ?? 0) - let targetTop = (entry["targetTop"] as? Double) ?? Double(entry["targetTop"] as? Int ?? 0) - let targetValue = targetBottom > 0 ? targetBottom : targetTop - - guard targetValue > 0 else { - continue - } - - let title: String - if targetBottom > 0, targetTop > 0, abs(targetTop - targetBottom) >= 0.1 { - title = "\(formatBG(targetBottom)) - \(formatBG(targetTop))" - } else { - title = formatBG(targetValue) - } - - let reason = (entry["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) - let subtitleParts = ["Temp Target", durationMinutes > 0 ? "\(Int(durationMinutes))m" : nil, reason?.isEmpty == false ? reason : nil].compactMap { $0 } - let subtitle = subtitleParts.joined(separator: " β€’ ") - - let treatment = Treatment( - id: "\(nsId)-temp-target", - type: .tempTarget, - date: timestamp, - title: title, - subtitle: subtitle, - icon: "scope", - color: .green, - bgValue: 0 - ) - treatments.append(treatment) - - default: - break - } + let history = Treatment.historyEntries(from: entry, date: timestamp) { findNearestBG(at: timestamp, in: mainVC.bgData) } + treatments.append(contentsOf: history.entries) + detectedSMB = detectedSMB || history.sawSMB + detectedAutomatic = detectedAutomatic || history.sawAutomatic } // Sort by date descending (most recent first) diff --git a/Tests/TreatmentHistoryEntriesTests.swift b/Tests/TreatmentHistoryEntriesTests.swift new file mode 100644 index 000000000..faf84b39d --- /dev/null +++ b/Tests/TreatmentHistoryEntriesTests.swift @@ -0,0 +1,58 @@ +// LoopFollow +// TreatmentHistoryEntriesTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct TreatmentHistoryEntriesTests { + private typealias Entry = [String: AnyObject] + + private func history(_ fields: [String: Any]) -> TreatmentHistoryEntries { + var entry: Entry = ["_id": "ns1" as AnyObject] + for (key, value) in fields { + entry[key] = value as AnyObject + } + return Treatment.historyEntries(from: entry, date: 100) { 120 } + } + + @Test("a Meal Bolus yields a carb entry and a bolus entry") + func mealBolus() { + let result = history(["eventType": "Meal Bolus", "carbs": 30.0, "insulin": 2.5]) + #expect(result.entries.map(\.type) == [.carb, .bolusManual]) + #expect(result.carb?.id == "ns1-carb") + #expect(result.bolus?.id == "ns1-bolus") + } + + @Test("automatic and SMB boluses are listed as automatic") + func automaticBolus() { + let automatic = history(["eventType": "Correction Bolus", "insulin": 0.4, "automatic": true]) + #expect(automatic.bolus?.type == .bolusAutomatic) + #expect(automatic.sawAutomatic) + #expect(!automatic.sawSMB) + + let smb = history(["eventType": "Correction Bolus", "insulin": 0.4, "type": "SMB"]) + #expect(smb.bolus?.type == .bolusAutomatic) + #expect(smb.sawSMB) + #expect(!smb.sawAutomatic) + + let manual = history(["eventType": "Bolus", "insulin": 1.0]) + #expect(manual.bolus?.type == .bolusManual) + #expect(!manual.sawSMB && !manual.sawAutomatic) + } + + @Test("entries the Treatments list skips yield nothing") + func skipped() { + #expect(history(["eventType": "Carb Correction", "carbs": 0.0]).entries.isEmpty) + #expect(history(["eventType": "Bolus", "insulin": 0.0]).entries.isEmpty) + #expect(history(["eventType": "Temporary Override", "duration": 0.0]).entries.isEmpty) + #expect(history(["eventType": "Temporary Target", "duration": 30.0]).entries.isEmpty) + #expect(history(["eventType": "Note", "notes": "hello"]).entries.isEmpty) + } + + @Test("overrides and temp targets with a duration are listed") + func bands() { + #expect(history(["eventType": "Temporary Override", "duration": 60.0, "reason": "Run"]).entries.first?.type == .override) + #expect(history(["eventType": "Temporary Target", "duration": 30.0, "targetBottom": 140.0, "targetTop": 140.0]).entries.first?.type == .tempTarget) + } +} From 9b3a7d587e0e9f6506cb76ff17510301aa75e05a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 17 Oct 2026 19:31:28 +0000 Subject: [PATCH 39/84] CI: Bump dev version to 7.1.12 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 54d7699b8..7926b38f5 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.11 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.12 From b89bee98cbfcfc2fdc7616b5c46bdd27a1a6bd16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 18 Oct 2026 01:39:27 +0200 Subject: [PATCH 40/84] Show CGM sensor states from Nightscout notes (#763) Loop and Trio can upload a note like "CGM: temporarySensorIssue" when the Dexcom has no reliable reading. These notes get their own marker on the graph, placed at the last reading before the note. While no newer reading has arrived, the state is shown under the BG on the main screen, the snoozer and the Live Activity, and is added to the Missed Reading and Not Looping notifications. Alarm behavior is unchanged. --- LoopFollow/Alarm/Alarm.swift | 10 +- LoopFollow/Alarm/AlarmManager.swift | 5 +- LoopFollow/Charts/BGChartModel.swift | 12 ++ LoopFollow/Charts/BGChartView.swift | 12 +- LoopFollow/Controllers/NightScout.swift | 2 + .../Controllers/Nightscout/BGData.swift | 1 + .../Controllers/Nightscout/Treatments.swift | 2 +- .../Nightscout/Treatments/Notes.swift | 37 +++++- LoopFollow/Helpers/CGMSensorState.swift | 93 +++++++++++++++ LoopFollow/LiveActivity/APNSClient.swift | 1 + .../LiveActivity/GlucoseSnapshotBuilder.swift | 4 + .../StorageCurrentGlucoseStateProvider.swift | 4 + LoopFollow/Snoozer/SnoozerView.swift | 11 ++ LoopFollow/Storage/Observable.swift | 3 + LoopFollow/Task/TreatmentsTask.swift | 4 + .../ViewControllers/BGDisplayView.swift | 9 ++ .../ViewControllers/MainViewController.swift | 1 + .../LoopFollowLiveActivity.swift | 51 +++++++-- Shared/GlucoseSnapshot.swift | 11 +- Tests/CGMSensorStateTests.swift | 106 ++++++++++++++++++ 20 files changed, 361 insertions(+), 18 deletions(-) create mode 100644 LoopFollow/Helpers/CGMSensorState.swift create mode 100644 Tests/CGMSensorStateTests.swift diff --git a/LoopFollow/Alarm/Alarm.swift b/LoopFollow/Alarm/Alarm.swift index f429aa04a..ee54034f4 100644 --- a/LoopFollow/Alarm/Alarm.swift +++ b/LoopFollow/Alarm/Alarm.swift @@ -218,6 +218,14 @@ struct Alarm: Identifiable, Codable, Equatable { /// CGM sensor lifetime in days (e.g. 10 for Dexcom G6, 15 for G7 15-day) var sensorLifetimeDays: Int? + /// Extra notification text: the CGM state behind a gap in readings. + private var notificationBody: String? { + guard type == .missedReading || type == .notLooping, + let state = Observable.shared.cgmSensorState.value + else { return nil } + return "CGM reported: \(state.summary)" + } + /// Function for when the alarm is triggered. /// If this alarm, all alarms is disabled or snoozed, then should not be called. This or all alarmd could be muted, then this function will just generate a notification. func trigger(config: AlarmConfiguration, now: Date) { @@ -277,7 +285,7 @@ struct Alarm: Identifiable, Codable, Equatable { } }() - AlarmManager.shared.sendNotification(title: type.rawValue, actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze") + AlarmManager.shared.sendNotification(title: type.rawValue, body: notificationBody, actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze") if playSound { AlarmSound.setSoundFile(soundFile) diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index 5ed8afafa..b0ee65912 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -187,7 +187,7 @@ class AlarmManager { UNUserNotificationCenter.current().removeAllPendingNotificationRequests() } - func sendNotification(title: String, actionTitle: String? = nil) { + func sendNotification(title: String, body: String? = nil, actionTitle: String? = nil) { UNUserNotificationCenter.current().removeAllPendingNotificationRequests() let content = UNMutableNotificationContent() @@ -195,6 +195,9 @@ class AlarmManager { content.subtitle += Observable.shared.bgText.value + " " content.subtitle += Observable.shared.directionText.value + " " content.subtitle += Observable.shared.deltaText.value + if let body { + content.body = body + } content.categoryIdentifier = "category" content.sound = .default diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index f0bf42859..ab6deeb67 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -145,6 +145,7 @@ final class BGChartModel: ObservableObject { @Published var resumes: [TreatmentPoint] = [] @Published var sensorStarts: [TreatmentPoint] = [] @Published var notes: [TreatmentPoint] = [] + @Published var sensorStates: [TreatmentPoint] = [] /// Device status history panes under the main chart, in display order. @Published var historyPanes: [BGChartHistory.Pane] = [] @@ -531,6 +532,17 @@ final class BGChartModel: ObservableObject { ) } + sensorStates = (showOtherTreatments ? vc.cgmSensorStates : []).compactMap { state in + guard let sgv = CGMSensorState.anchorSGV(at: state.date.timeIntervalSince1970, readings: vc.bgData) else { return nil } + return TreatmentPoint( + date: state.date, + value: Double(sgv), + sgv: Double(sgv), + label: state.name, + pillText: "CGM: \(state.displayName)\n\(pillTimeString(for: state.date))" + ) + } + basalScheduled = (showBasal ? vc.basalScheduleData : []).map { ScheduledBasalPoint(date: Date(timeIntervalSince1970: $0.date), rate: $0.basalRate) } diff --git a/LoopFollow/Charts/BGChartView.swift b/LoopFollow/Charts/BGChartView.swift index b35663863..e2949839a 100644 --- a/LoopFollow/Charts/BGChartView.swift +++ b/LoopFollow/Charts/BGChartView.swift @@ -806,7 +806,7 @@ private struct MainBGChart: View { /// lookup and the tap hit test. private func forEachTreatmentAnchor(_ body: (BGChartModel.TreatmentPoint) -> Void) { for group in [model.boluses, model.carbs, model.smbs, model.bgChecks, - model.notes, model.suspends, model.resumes, model.sensorStarts] + model.notes, model.sensorStates, model.suspends, model.resumes, model.sensorStarts] { for t in group { body(t) @@ -1676,6 +1676,16 @@ private struct BGChartCanvas: View, Equatable { .symbolSize(isSmall ? 22 : 54) .foregroundStyle(Color.gray.opacity(0.75)) } + + ForEach(windowed(model.sensorStates) { $0.drawnDate }) { pt in + PointMark( + x: .value("time", pt.drawnDate), + y: .value("sgv", pt.sgv) + ) + .symbol(.triangle) + .symbolSize(isSmall ? 22 : 54) + .foregroundStyle(Color.orange.opacity(0.85)) + } } @ChartContentBuilder diff --git a/LoopFollow/Controllers/NightScout.swift b/LoopFollow/Controllers/NightScout.swift index 2dcfe5a7e..1b9b438c7 100644 --- a/LoopFollow/Controllers/NightScout.swift +++ b/LoopFollow/Controllers/NightScout.swift @@ -105,6 +105,8 @@ extension MainViewController { func clearOldNotes() { noteGraphData.removeAll() + cgmSensorStates.removeAll() updateNotes() + updateCGMSensorState() } } diff --git a/LoopFollow/Controllers/Nightscout/BGData.swift b/LoopFollow/Controllers/Nightscout/BGData.swift index a568746c7..52e3555be 100644 --- a/LoopFollow/Controllers/Nightscout/BGData.swift +++ b/LoopFollow/Controllers/Nightscout/BGData.swift @@ -279,6 +279,7 @@ extension MainViewController { self.updateBGGraph() self.updateStats() + self.updateCGMSensorState() let latestEntryIndex = entries.count - 1 let latestBG = entries[latestEntryIndex].sgv diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index 14e9e391b..430751bc4 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -214,7 +214,7 @@ extension MainViewController { if note.count > 0 { processNotes(entries: note) } else { - if noteGraphData.count > 0 { + if noteGraphData.count > 0 || cgmSensorStates.count > 0 { clearOldNotes() } } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift b/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift index 9615f53ce..93d9878bd 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift @@ -8,6 +8,7 @@ extension MainViewController { func processNotes(entries: [[String: AnyObject]]) { // because it's a small array, we're going to destroy and reload every time. noteGraphData.removeAll() + cgmSensorStates.removeAll() var lastFoundIndex = 0 for currentEntry in entries.reversed() { @@ -15,15 +16,21 @@ extension MainViewController { if let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry) { let dateTimeStamp = parsedDate.timeIntervalSince1970 - let sgv = findNearestBGbyTime(needle: dateTimeStamp, haystack: bgData, startingIndex: lastFoundIndex) - lastFoundIndex = sgv.foundIndex - guard let thisNote = currentEntry["notes"] as? String else { continue } + guard let thisNote = currentEntry["notes"] as? String, + dateTimeStamp < (dateTimeUtils.getNowTimeIntervalUTC() + (60 * 60)) + else { continue } - if dateTimeStamp < (dateTimeUtils.getNowTimeIntervalUTC() + (60 * 60)) { - let dot = DataStructs.noteStruct(date: Double(dateTimeStamp), sgv: Int(sgv.sgv), note: thisNote) - noteGraphData.append(dot) + if let state = CGMSensorState(note: thisNote, date: parsedDate) { + cgmSensorStates.append(state) + continue } + + let sgv = findNearestBGbyTime(needle: dateTimeStamp, haystack: bgData, startingIndex: lastFoundIndex) + lastFoundIndex = sgv.foundIndex + + let dot = DataStructs.noteStruct(date: Double(dateTimeStamp), sgv: Int(sgv.sgv), note: thisNote) + noteGraphData.append(dot) } else { print("Failed to parse date") } @@ -32,5 +39,23 @@ extension MainViewController { if Storage.shared.graphOtherTreatments.value { updateNotes() } + updateCGMSensorState() + } + + /// Publishes the CGM state reported after the newest reading, if any. + func updateCGMSensorState() { + let latestBGDate = bgData.last.map { Date(timeIntervalSince1970: $0.date) } + let active = CGMSensorState.active(in: cgmSensorStates, latestBGDate: latestBGDate) + guard active != Observable.shared.cgmSensorState.value else { return } + + Observable.shared.cgmSensorState.value = active + if let active { + LogManager.shared.log(category: .nightscout, message: "CGM sensor state: \(active.name) at \(active.date)") + } else { + LogManager.shared.log(category: .nightscout, message: "CGM sensor state cleared") + } + #if !targetEnvironment(macCatalyst) + LiveActivityManager.shared.refreshFromCurrentState(reason: "cgmSensorState") + #endif } } diff --git a/LoopFollow/Helpers/CGMSensorState.swift b/LoopFollow/Helpers/CGMSensorState.swift new file mode 100644 index 000000000..5c6f33cf7 --- /dev/null +++ b/LoopFollow/Helpers/CGMSensorState.swift @@ -0,0 +1,93 @@ +// LoopFollow +// CGMSensorState.swift + +import Foundation + +/// A CGM state without reliable glucose, as uploaded by Loop or Trio in a +/// Nightscout note of the form "CGM: ". The state is the CGM kit's own +/// name, for example "temporarySensorIssue", "warmup" or ".unknown(23)". +struct CGMSensorState: Equatable { + /// The kit's name for the state. + let name: String + let date: Date + + init(name: String, date: Date) { + self.name = name + self.date = date + } + + /// Parses a note text; nil when the note is not a sensor-state note. + init?(note: String, date: Date) { + let prefix = "CGM: " + guard note.hasPrefix(prefix) else { return nil } + let name = String(note.dropFirst(prefix.count)) + guard !name.isEmpty, !name.contains(where: \.isWhitespace) else { return nil } + self.init(name: name, date: date) + } + + /// The state in plain words, e.g. "Temporary sensor issue". + var displayName: String { + Self.displayName(for: name) + } + + /// The state and its time, e.g. "Temporary sensor issue at 22:33". + var summary: String { + "\(displayName) at \(date.formatted(date: .omitted, time: .shortened))" + } + + /// The newest state reported after the newest reading. A reading after a + /// state means the CGM delivers glucose again, so that state is over. + static func active(in states: [CGMSensorState], latestBGDate: Date?) -> CGMSensorState? { + guard let newest = states.max(by: { $0.date < $1.date }) else { return nil } + if let latestBGDate, newest.date <= latestBGDate { + return nil + } + return newest + } + + /// Glucose to draw a state's marker at: the last reading at or before the + /// state, else the first reading after it. `readings` are sorted by date. + static func anchorSGV(at date: TimeInterval, readings: [ShareGlucoseData]) -> Int? { + if let before = readings.last(where: { $0.date <= date }) { + return before.sgv + } + return readings.first?.sgv + } + + static func displayName(for name: String) -> String { + if name.hasPrefix(".unknown("), name.hasSuffix(")") { + let code = name.dropFirst(".unknown(".count).dropLast() + return "Unknown state (\(code))" + } + if name == "questionMarks" { + return "Question marks (???)" + } + + // G7SensorKit spells some names "Dueto". + let identifier = name.replacingOccurrences(of: "Dueto", with: "DueTo") + + var words: [String] = [] + var current = "" + var previous: Character? + for character in identifier { + if let previous { + let startsWord = (character.isUppercase && (previous.isLowercase || previous.isNumber)) + || (character.isNumber && previous.isLetter) + if startsWord { + words.append(current) + current = "" + } + } + current.append(character) + previous = character + } + words.append(current) + + // Acronyms such as "BGs" keep their case. + let normalized = words.map { word -> String in + word.dropFirst().contains(where: \.isUppercase) ? word : word.lowercased() + } + guard let first = normalized.first, !first.isEmpty else { return name } + return ([first.prefix(1).uppercased() + first.dropFirst()] + normalized.dropFirst()).joined(separator: " ") + } +} diff --git a/LoopFollow/LiveActivity/APNSClient.swift b/LoopFollow/LiveActivity/APNSClient.swift index ff20144c0..ca8dd5261 100644 --- a/LoopFollow/LiveActivity/APNSClient.swift +++ b/LoopFollow/LiveActivity/APNSClient.swift @@ -241,6 +241,7 @@ class APNSClient { ] snapshotDict["isNotLooping"] = snapshot.isNotLooping + if let cgmSensorState = snapshot.cgmSensorState { snapshotDict["cgmSensorState"] = cgmSensorState } snapshotDict["showRenewalOverlay"] = snapshot.showRenewalOverlay if let iob = snapshot.iob { snapshotDict["iob"] = iob } if let cob = snapshot.cob { snapshotDict["cob"] = cob } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index 61dd92ab7..3010a98c3 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -101,6 +101,9 @@ protocol CurrentGlucoseStateProviding { /// True when LoopFollow detects the loop has not reported in 15+ minutes. var isNotLooping: Bool { get } + /// CGM state reported after the newest reading, as display text. + var cgmSensorState: String? { get } + // MARK: - Renewal /// True when the Live Activity is within renewalWarning seconds of its deadline. @@ -173,6 +176,7 @@ enum GlucoseSnapshotBuilder { maxBgMgdl: provider.maxBgMgdl, unit: preferredUnit, isNotLooping: provider.isNotLooping, + cgmSensorState: provider.cgmSensorState, showRenewalOverlay: provider.showRenewalOverlay, ) } diff --git a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift index 76b3664a4..30337ac12 100644 --- a/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift +++ b/LoopFollow/LiveActivity/StorageCurrentGlucoseStateProvider.swift @@ -141,6 +141,10 @@ struct StorageCurrentGlucoseStateProvider: CurrentGlucoseStateProviding { return Date().timeIntervalSince1970 - lastLoopTime >= 15 * 60 } + var cgmSensorState: String? { + Observable.shared.cgmSensorState.value?.summary + } + // MARK: - Renewal var showRenewalOverlay: Bool { diff --git a/LoopFollow/Snoozer/SnoozerView.swift b/LoopFollow/Snoozer/SnoozerView.swift index b95310460..df65c67cf 100644 --- a/LoopFollow/Snoozer/SnoozerView.swift +++ b/LoopFollow/Snoozer/SnoozerView.swift @@ -13,6 +13,7 @@ struct SnoozerView: View { @ObservedObject var directionText = Observable.shared.directionText @ObservedObject var deltaText = Observable.shared.deltaText @ObservedObject var bgStale = Observable.shared.bgStale + @ObservedObject var cgmSensorState = Observable.shared.cgmSensorState @ObservedObject var bg = Observable.shared.bg @ObservedObject var snoozerEmoji = Storage.shared.snoozerEmoji @@ -242,6 +243,16 @@ struct SnoozerView: View { .minimumScaleFactor(minScale) .foregroundColor(.white) .frame(maxWidth: .infinity, maxHeight: ageMaxH) + + if let state = cgmSensorState.value { + Text("CGM: \(state.summary)") + .font(.system(size: 30, weight: .semibold)) + .minimumScaleFactor(0.5) + .lineLimit(2) + .multilineTextAlignment(.center) + .foregroundColor(.orange) + .frame(maxWidth: .infinity) + } } .padding(.top, topPad) .padding(.horizontal, 16) diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 07877f8a8..645703b6a 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -57,6 +57,9 @@ class Observable { var isNotLooping = ObservableValue(default: false) + /// CGM state reported after the newest reading; nil while readings flow. + var cgmSensorState = ObservableValue(default: nil) + /// Selected tab index used by SwiftUI TabView β€” set from MainViewController to switch tabs var selectedTabIndex = ObservableValue(default: 0) diff --git a/LoopFollow/Task/TreatmentsTask.swift b/LoopFollow/Task/TreatmentsTask.swift index 4e7aa0bc9..bb5a036a8 100644 --- a/LoopFollow/Task/TreatmentsTask.swift +++ b/LoopFollow/Task/TreatmentsTask.swift @@ -15,6 +15,10 @@ extension MainViewController { func treatmentsTaskAction() { // If Nightscout not enabled, wait 60s and try again guard IsNightscoutEnabled(), Storage.shared.downloadTreatments.value else { + if !cgmSensorStates.isEmpty { + cgmSensorStates.removeAll() + updateCGMSensorState() + } TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date().addingTimeInterval(60)) return } diff --git a/LoopFollow/ViewControllers/BGDisplayView.swift b/LoopFollow/ViewControllers/BGDisplayView.swift index 11bf3d16b..036a92e56 100644 --- a/LoopFollow/ViewControllers/BGDisplayView.swift +++ b/LoopFollow/ViewControllers/BGDisplayView.swift @@ -16,6 +16,7 @@ struct BGDisplayView: View { @ObservedObject var predictionText = Observable.shared.predictionText @ObservedObject var predictionColor = Observable.shared.predictionColor @ObservedObject var isNotLooping = Observable.shared.isNotLooping + @ObservedObject var cgmSensorState = Observable.shared.cgmSensorState @ObservedObject var retro = RetroSelection.shared var onRefresh: (() -> Void)? @@ -64,6 +65,14 @@ struct BGDisplayView: View { Text(minAgoText.value) .font(.system(size: 17)) + if let state = cgmSensorState.value { + Text("CGM: \(state.summary)") + .font(.system(size: 17, weight: .semibold)) + .foregroundColor(.orange) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + if isNotLooping.value { Text(loopStatusText.value) .font(.system(size: 18, weight: .bold)) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 690a1a627..af5181d66 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -98,6 +98,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { var resumeGraphData: [DataStructs.timestampOnlyStruct] = [] var sensorStartGraphData: [DataStructs.timestampOnlyStruct] = [] var noteGraphData: [DataStructs.noteStruct] = [] + var cgmSensorStates: [CGMSensorState] = [] var deviceBatteryData: [DataStructs.batteryStruct] = [] var lastCalDate: Double = 0 var latestLoopStatusString = "" diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 6414882d7..9f6ca7c94 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -236,6 +236,16 @@ private struct LockScreenLiveActivityView: View { ActiveAdjustmentsView(snapshot: s) + if let sensorState = s.cgmSensorState { + Text("CGM: \(sensorState)") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(2) + .minimumScaleFactor(0.8) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, alignment: .center) + } + Text(LAAppGroupSettings.showDisplayName() ? "\(LAAppGroupSettings.displayName()) β€” \(LAFormat.updated(s))" : "Last Update: \(LAFormat.updated(s))") @@ -259,10 +269,22 @@ private struct LockScreenLiveActivityView: View { RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color(uiColor: UIColor.systemRed).opacity(0.85)) - Text("Not Looping") - .font(.system(size: 20, weight: .heavy, design: .rounded)) - .foregroundStyle(.white) - .tracking(1.5) + VStack(spacing: 4) { + Text("Not Looping") + .font(.system(size: 20, weight: .heavy, design: .rounded)) + .foregroundStyle(.white) + .tracking(1.5) + + if let sensorState = state.snapshot.cgmSensorState { + Text("CGM: \(sensorState)") + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(2) + .minimumScaleFactor(0.8) + .multilineTextAlignment(.center) + } + } + .padding(.horizontal, 12) } } } @@ -506,16 +528,31 @@ private struct DynamicIslandTrailingView: View { private struct DynamicIslandBottomView: View { let snapshot: GlucoseSnapshot - var body: some View { - if snapshot.isNotLooping { - Text("Loop has not reported in 15+ minutes") + @ViewBuilder + private var sensorStateText: some View { + if let sensorState = snapshot.cgmSensorState { + Text("CGM: \(sensorState)") .font(.system(size: 12, weight: .semibold, design: .rounded)) .foregroundStyle(.white.opacity(0.92)) .lineLimit(1) .minimumScaleFactor(0.75) + } + } + + var body: some View { + if snapshot.isNotLooping { + VStack(spacing: 2) { + Text("Loop has not reported in 15+ minutes") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.92)) + .lineLimit(1) + .minimumScaleFactor(0.75) + sensorStateText + } } else { VStack(spacing: 2) { ActiveAdjustmentsView(snapshot: snapshot) + sensorStateText Text("Updated at: \(LAFormat.updated(snapshot))") .font(.system(size: 13, weight: .semibold, design: .rounded)) .foregroundStyle(.white.opacity(0.92)) diff --git a/Shared/GlucoseSnapshot.swift b/Shared/GlucoseSnapshot.swift index 6800ccd3b..b2a015d17 100644 --- a/Shared/GlucoseSnapshot.swift +++ b/Shared/GlucoseSnapshot.swift @@ -128,6 +128,10 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { /// True when LoopFollow detects the loop has not reported in 15+ minutes (Nightscout only). let isNotLooping: Bool + /// CGM state reported after the newest reading, e.g. "Temporary sensor + /// issue at 22:33" (nil while readings flow). + let cgmSensorState: String? + // MARK: - Renewal /// True when the Live Activity is within renewalWarning seconds of its renewal deadline. @@ -168,6 +172,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { maxBgMgdl: Double? = nil, unit: Unit, isNotLooping: Bool, + cgmSensorState: String? = nil, showRenewalOverlay: Bool = false, ) { self.glucose = glucose @@ -201,6 +206,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { self.maxBgMgdl = maxBgMgdl self.unit = unit self.isNotLooping = isNotLooping + self.cgmSensorState = cgmSensorState self.showRenewalOverlay = showRenewalOverlay } @@ -247,6 +253,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { maxBgMgdl: maxBgMgdl, unit: unit, isNotLooping: isNotLooping, + cgmSensorState: cgmSensorState, showRenewalOverlay: value, ) } @@ -286,6 +293,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { try container.encodeIfPresent(maxBgMgdl, forKey: .maxBgMgdl) try container.encode(unit, forKey: .unit) try container.encode(isNotLooping, forKey: .isNotLooping) + try container.encodeIfPresent(cgmSensorState, forKey: .cgmSensorState) try container.encode(showRenewalOverlay, forKey: .showRenewalOverlay) } @@ -322,6 +330,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { maxBgMgdl = try container.decodeIfPresent(Double.self, forKey: .maxBgMgdl) unit = try container.decode(Unit.self, forKey: .unit) isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false + cgmSensorState = try container.decodeIfPresent(String.self, forKey: .cgmSensorState) showRenewalOverlay = try container.decodeIfPresent(Bool.self, forKey: .showRenewalOverlay) ?? false } @@ -332,7 +341,7 @@ struct GlucoseSnapshot: Codable, Equatable, Hashable { case recBolus, battery, pumpBattery, basalRate, pumpReservoirU case autosens, tdd, targetLowMgdl, targetHighMgdl, isfMgdlPerU, carbRatio, carbsToday case profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl, maxBgMgdl - case unit, isNotLooping, showRenewalOverlay + case unit, isNotLooping, cgmSensorState, showRenewalOverlay } } diff --git a/Tests/CGMSensorStateTests.swift b/Tests/CGMSensorStateTests.swift new file mode 100644 index 000000000..cc4cf59ed --- /dev/null +++ b/Tests/CGMSensorStateTests.swift @@ -0,0 +1,106 @@ +// LoopFollow +// CGMSensorStateTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct CGMSensorStateTests { + private let t0 = Date(timeIntervalSince1970: 1_790_000_000) + + private func state(_ name: String, minutes: Double) -> CGMSensorState { + CGMSensorState(name: name, date: t0.addingTimeInterval(minutes * 60)) + } + + private func reading(_ sgv: Int, minutes: Double) -> ShareGlucoseData { + ShareGlucoseData(sgv: sgv, date: t0.addingTimeInterval(minutes * 60).timeIntervalSince1970, direction: nil) + } + + // MARK: - Parsing + + @Test("Sensor-state notes parse to the kit's state name") + func parsesSensorStateNotes() { + #expect(CGMSensorState(note: "CGM: temporarySensorIssue", date: t0)?.name == "temporarySensorIssue") + #expect(CGMSensorState(note: "CGM: warmup", date: t0)?.name == "warmup") + #expect(CGMSensorState(note: "CGM: .unknown(23)", date: t0)?.name == ".unknown(23)") + } + + @Test("Other notes are not sensor states") + func ignoresOtherNotes() { + #expect(CGMSensorState(note: "Pizza", date: t0) == nil) + #expect(CGMSensorState(note: "CGM: ", date: t0) == nil) + #expect(CGMSensorState(note: "CGM: sensor was wet after swim", date: t0) == nil) + #expect(CGMSensorState(note: "cgm: warmup", date: t0) == nil) + } + + // MARK: - Display names + + @Test("Kit names read as plain words") + func displayNames() { + #expect(CGMSensorState.displayName(for: "temporarySensorIssue") == "Temporary sensor issue") + #expect(CGMSensorState.displayName(for: "warmup") == "Warmup") + #expect(CGMSensorState.displayName(for: "sensorFailedDuetoCountsAberration") == "Sensor failed due to counts aberration") + #expect(CGMSensorState.displayName(for: "firstOfTwoBGsNeeded") == "First of two BGs needed") + #expect(CGMSensorState.displayName(for: "calibrationError8") == "Calibration error 8") + #expect(CGMSensorState.displayName(for: "sensorFailure11") == "Sensor failure 11") + #expect(CGMSensorState.displayName(for: "needCalibration7") == "Need calibration 7") + #expect(CGMSensorState.displayName(for: "questionMarks") == "Question marks (???)") + #expect(CGMSensorState.displayName(for: ".unknown(23)") == "Unknown state (23)") + } + + // MARK: - Active state + + @Test("A state reported after the newest reading is active") + func stateAfterReadingIsActive() { + let issue = state("temporarySensorIssue", minutes: 5) + #expect(CGMSensorState.active(in: [issue], latestBGDate: t0) == issue) + } + + @Test("A reading after the state ends it") + func readingAfterStateEndsIt() { + let issue = state("temporarySensorIssue", minutes: 5) + #expect(CGMSensorState.active(in: [issue], latestBGDate: t0.addingTimeInterval(20 * 60)) == nil) + } + + @Test("A note stamped at the newest reading's time is not active") + func noteAtReadingTimeIsNotActive() { + let issue = state("temporarySensorIssue", minutes: 0) + #expect(CGMSensorState.active(in: [issue], latestBGDate: t0) == nil) + } + + @Test("The newest state wins") + func newestStateWins() { + let warmup = state("warmup", minutes: 1) + let failed = state("sensorFailed", minutes: 6) + let duplicate = state("warmup", minutes: 2) + #expect(CGMSensorState.active(in: [failed, warmup, duplicate], latestBGDate: t0) == failed) + } + + @Test("Without readings the newest state is active") + func noReadings() { + let failed = state("sensorFailed", minutes: 1) + #expect(CGMSensorState.active(in: [failed], latestBGDate: nil) == failed) + #expect(CGMSensorState.active(in: [], latestBGDate: nil) == nil) + } + + // MARK: - Chart anchor + + @Test("The marker sits at the last reading before the state") + func anchorsAtLastReadingBefore() { + let readings = [reading(80, minutes: -10), reading(47, minutes: -5), reading(55, minutes: 15)] + #expect(CGMSensorState.anchorSGV(at: t0.timeIntervalSince1970, readings: readings) == 47) + } + + @Test("A state newer than every reading sits at the newest reading") + func anchorsAfterNewestReading() { + let readings = [reading(80, minutes: -10), reading(47, minutes: -5)] + #expect(CGMSensorState.anchorSGV(at: t0.timeIntervalSince1970, readings: readings) == 47) + } + + @Test("A state older than every reading sits at the first reading") + func anchorsBeforeFirstReading() { + let readings = [reading(120, minutes: 10)] + #expect(CGMSensorState.anchorSGV(at: t0.timeIntervalSince1970, readings: readings) == 120) + #expect(CGMSensorState.anchorSGV(at: t0.timeIntervalSince1970, readings: []) == nil) + } +} From d9899afb68a3dd863f46a05b5ba334b23a56fc01 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 17 Oct 2026 23:39:35 +0000 Subject: [PATCH 41/84] CI: Bump dev version to 7.1.13 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 7926b38f5..141b0a55c 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.12 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.13 From b9d76a8c720f5710b8e6492942f78522bfade31c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 18 Oct 2026 01:48:15 +0200 Subject: [PATCH 42/84] Create the Live Activity locally when the app is in the foreground (#734) * Create the Live Activity locally when the app is in the foreground Every Live Activity creation went through an APNs push-to-start, which can show a banner or bump the Dynamic Island even when the app is open. Creation now uses Activity.request directly while the app is foreground active, and a healthy Live Activity is replaced the same way when the app is used with less than 4 hours left of its 8 hour lifetime, so the background push-to-start renewal is rarely needed. Push-to-start remains the creation path in the background and APNs setup is still required. Creation during app launch is deferred until the app is fully active, which fixes a race where a push-to-start fired during launch could briefly produce two activities. * Harden local Live Activity creation --- .../LiveActivity/LiveActivityManager.swift | 193 +++++++++++++++--- .../ViewControllers/MainViewController.swift | 6 +- 2 files changed, 168 insertions(+), 31 deletions(-) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index e8eb43de3..464de9994 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -10,8 +10,10 @@ import os import UIKit import UserNotifications -// Live Activity manager for LoopFollow. Every LA creation (start, renewal, -// restart) and update goes through APNs push-to-start. +// Live Activity manager for LoopFollow. LA creation prefers a local +// Activity.request() while the app is foreground-active (no APNs round-trip, +// no banner/Dynamic Island presentation); APNs push-to-start is the creation +// path in the background. Updates go over APNs self-push. final class LiveActivityManager { static let shared = LiveActivityManager() @@ -406,6 +408,11 @@ final class LiveActivityManager { static let renewalThreshold: TimeInterval = 7.5 * 3600 static let renewalWarning: TimeInterval = 30 * 60 + /// Remaining time on `laRenewBy` below which a foreground entry proactively + /// replaces the healthy LA with a locally created one, resetting the 8-hour + /// clock without an APNs push-to-start. Local-only by design: if the local + /// request fails, the existing LA still has hours left, so no fallback fires. + static let opportunisticRenewalThreshold: TimeInterval = 4 * 3600 static let extensionLivenessGrace: TimeInterval = 15 * 60 /// Base backoff after a 429 for push-to-start; doubled on each subsequent 429, @@ -493,9 +500,9 @@ final class LiveActivityManager { let startReason = nextStartReasonOverride ?? "user-start" nextStartReasonOverride = nil - // Push-to-start is used for every creation path. If an activity is - // already running and not stale we adopt/reuse it (covers warm starts - // where the LA survived a relaunch); only truly new starts dispatch APNs. + // If an activity is already running and not stale we adopt/reuse it + // (covers warm starts where the LA survived a relaunch); only truly new + // starts go through createActivity (local when foreground, APNs otherwise). if let existing = Activity.activities.first { let renewBy = Storage.shared.laRenewBy.value let now = Date().timeIntervalSince1970 @@ -503,18 +510,30 @@ final class LiveActivityManager { let inRenewalWindow = renewBy > 0 && now >= renewBy - LiveActivityManager.renewalWarning let needsRestart = Storage.shared.laRenewalFailed.value || inRenewalWindow || staleDatePassed if !needsRestart { + // Opportunistic renewal: the LA is healthy but in the back half + // of its 8-hour lifetime and the app is open β€” replace it locally + // now so the background push-to-start renewal (and its banner) is + // not needed later. On failure the existing LA is kept. + let remaining = renewBy - now + if renewBy > 0, remaining < LiveActivityManager.opportunisticRenewalThreshold, + isAppVisibleForLiveActivityStart(), !isAwaitingPushToStartAdoption + { + if attemptLocalCreate(reason: "opportunistic-renew", oldActivity: existing) { + return + } + } bind(to: existing, logReason: "reuse") Storage.shared.laRenewalFailed.value = false return } LogManager.shared.log( category: .general, - message: "[LA] existing activity is stale on startIfNeeded β€” push-to-start replace (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))" + message: "[LA] existing activity is stale on startIfNeeded β€” replacing (staleDatePassed=\(staleDatePassed), inRenewalWindow=\(inRenewalWindow))" ) - attemptPushToStartCreate(reason: startReason, oldActivity: existing) + createActivity(reason: startReason, oldActivity: existing) return } - attemptPushToStartCreate(reason: startReason, oldActivity: nil) + createActivity(reason: startReason, oldActivity: nil) } /// Called from applicationWillTerminate. Ends the LA synchronously (blocking @@ -656,18 +675,148 @@ final class LiveActivityManager { let overdueBy = Date().timeIntervalSince1970 - renewBy LogManager.shared.log(category: .general, message: "[LA] renewal deadline passed by \(Int(overdueBy))s, requesting new LA") - // Renewal goes through push-to-start. The dispatch hops to MainActor - // and returns immediately; adoption (or failure) lands in the observer. + // Renewal goes through createActivity: local if the app happens to be + // foreground-active at the deadline, push-to-start otherwise. The + // dispatch hops to MainActor and returns immediately; push-to-start + // adoption (or failure) lands in the observer. // Return true so performRefresh stops processing this tick. Task { @MainActor [weak self] in - self?.attemptPushToStartCreate(reason: "renew", oldActivity: oldActivity, snapshot: snapshot) + self?.createActivity(reason: "renew", oldActivity: oldActivity, snapshot: snapshot) } return true } + // MARK: - Creation dispatch + + /// Single entry point for every LA creation (initial start, renewal, + /// forced restart, expired-token recovery). Prefers a local + /// Activity.request() while the app is foreground-active and falls back to + /// APNs push-to-start, the only creation transport while backgrounded. + @MainActor + private func createActivity( + reason: String, + oldActivity: Activity?, + snapshot: GlucoseSnapshot? = nil + ) { + if isAwaitingPushToStartAdoption { + LogManager.shared.log( + category: .general, + message: "[LA] create (\(reason)): a push-to-start is awaiting adoption β€” not creating" + ) + return + } + if isAppVisibleForLiveActivityStart() { + if attemptLocalCreate(reason: reason, oldActivity: oldActivity, snapshot: snapshot) { + return + } + } + attemptPushToStartCreate(reason: reason, oldActivity: oldActivity, snapshot: snapshot) + } + + /// A push-to-start accepted by APNs this recently is still expected to + /// arrive through activityUpdates; creating meanwhile would replace the LA twice. + static let pushToStartAdoptionWindow: TimeInterval = 30 + + private var isAwaitingPushToStartAdoption: Bool { + guard let sentAt = lastPushToStartSuccessAt else { return false } + return Date().timeIntervalSince(sentAt) < LiveActivityManager.pushToStartAdoptionWindow + } + + /// Foreground-only local creation via Activity.request(pushType: .token). + /// Returns false when creation is not possible; the caller decides whether + /// to fall back to push-to-start. APNs credentials stay a hard prerequisite + /// even here β€” background renewal and updates depend on them. + @MainActor + private func attemptLocalCreate( + reason: String, + oldActivity: Activity?, + snapshot: GlucoseSnapshot? = nil + ) -> Bool { + let keyId = Storage.shared.lfKeyId.value + let apnsKey = Storage.shared.lfApnsKey.value + guard APNsCredentialValidator.isFullyConfigured(keyId: keyId, apnsKey: apnsKey) else { + LogManager.shared.log( + category: .general, + message: "[LA] local create (\(reason)) blocked β€” APNs credentials missing" + ) + return false + } + + let workingSnapshot = snapshot ?? buildWorkingSnapshot() + seq += 1 + let nextSeq = seq + let state = GlucoseLiveActivityAttributes.ContentState( + snapshot: workingSnapshot.withRenewalOverlay(false), + seq: nextSeq, + reason: reason, + producedAt: Date(), + ) + let staleDate = Date().addingTimeInterval(LiveActivityManager.renewalThreshold) + let content = ActivityContent( + state: state, + staleDate: staleDate, + relevanceScore: 100.0, + ) + + do { + let activity = try Activity.request( + attributes: GlucoseLiveActivityAttributes(title: "LoopFollow"), + content: content, + pushType: .token, + ) + LogManager.shared.log( + category: .general, + message: "[LA] local create (\(reason)) succeeded id=\(activity.id) seq=\(nextSeq)" + ) + Storage.shared.laRenewBy.value = staleDate.timeIntervalSince1970 + Storage.shared.laRenewalFailed.value = false + cancelRenewalFailedNotification() + pendingForegroundRestart = false + // The new activity's token arrives through its own observation; + // the old token must not be used for updates meanwhile. + pushToken = nil + lastPushToStartSuccessAt = nil + pushToStartSendsWithoutAdoption = 0 + Storage.shared.laPushToStartBackoff.value = 0 + // Bind before ending the old activity so its .dismissed is never + // misclassified as a user swipe. + bind(to: activity, logReason: "local-create-\(reason)") + if let oldActivity, oldActivity.id != activity.id { + Task { + await oldActivity.end(nil, dismissalPolicy: .immediate) + } + } + return true + } catch { + LogManager.shared.log( + category: .general, + message: "[LA] local create (\(reason)) failed: \(error.localizedDescription)" + ) + return false + } + } + + /// Snapshot fallback chain for creation paths that were not handed one. + private func buildWorkingSnapshot() -> GlucoseSnapshot { + let provider = StorageCurrentGlucoseStateProvider() + return GlucoseSnapshotBuilder.build(from: provider) + ?? GlucoseSnapshotStore.shared.load() + ?? GlucoseSnapshot( + glucose: 0, + delta: 0, + trend: .unknown, + updatedAt: Date(), + iob: nil, + cob: nil, + projected: nil, + unit: .mgdl, + isNotLooping: false, + ) + } + // MARK: - Push-to-start - /// Single creation path. Handles initial start, renewal, and forced restart. + /// Background creation path (also the fallback when a local create fails). /// Verifies token + APNs credentials, applies backoff, then dispatches the /// APNs push-to-start call. The old activity is only ended after a confirmed /// successful send, preserving it if the send fails. Adoption is delivered @@ -705,23 +854,7 @@ final class LiveActivityManager { } // Build snapshot if caller didn't supply one (initial start path). - let workingSnapshot: GlucoseSnapshot = { - if let snapshot { return snapshot } - let provider = StorageCurrentGlucoseStateProvider() - return GlucoseSnapshotBuilder.build(from: provider) - ?? GlucoseSnapshotStore.shared.load() - ?? GlucoseSnapshot( - glucose: 0, - delta: 0, - trend: .unknown, - updatedAt: Date(), - iob: nil, - cob: nil, - projected: nil, - unit: .mgdl, - isNotLooping: false, - ) - }() + let workingSnapshot = snapshot ?? buildWorkingSnapshot() Task { [weak self] in guard let self else { return } @@ -1123,7 +1256,7 @@ final class LiveActivityManager { // endingForRestart and turning the eventual iOS dismissal into a misclassified // user swipe. Drive the restart synchronously instead. Task { @MainActor [weak self] in - self?.attemptPushToStartCreate(reason: "expired-token", oldActivity: nil) + self?.createActivity(reason: "expired-token", oldActivity: nil) } } diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index af5181d66..5c6fb7e4f 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -813,7 +813,11 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { } #if !targetEnvironment(macCatalyst) - LiveActivityManager.shared.startFromCurrentState() + // Creation at launch belongs to didBecomeActive; this covers + // returning to the tab while the app is already active. + if UIApplication.shared.applicationState == .active { + LiveActivityManager.shared.startFromCurrentState() + } #endif } From 1bd38ba8905ca38f55e93bccd11ec4a4b39b545d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 17 Oct 2026 23:48:23 +0000 Subject: [PATCH 43/84] CI: Bump dev version to 7.1.14 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 141b0a55c..cb493e3b2 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.13 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.14 From f16bafa6f8284be264b9aa47faabce4b5c276f9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 18 Oct 2026 01:54:55 +0200 Subject: [PATCH 44/84] Look ahead the full predictive low window (#751) * Look ahead the full predictive low window The forecast series starts at the current glucose value rather than the first future point, so iterating ceil(predictiveMinutes / 5) entries stopped one step short of the requested horizon. A 20-minute setting only reached 15 minutes ahead, and a 15-minute one reached 10. At every setting the dial behaved as though it were one notch lower. Take one more point so the setting matches its label, and raise the alarm forecast cap from 12 points to 13 so the 60-minute maximum is reachable instead of capping at 55. loopHorizonReachesRequestedMinutes is the regression test: the dip sits at index 4, so a 20-minute look-ahead has to reach it and a 15-minute one must not. The other added cases cover bounds safety when the horizon exceeds the published series and pin the existing behaviour of following the longest forecast rather than the first to run out. * Share the horizon arithmetic and harden the look-ahead tests LowBGCondition owns the horizon arithmetic: forecastPoints(forMinutes:) converts a look-ahead in minutes into a point count, and maxPredictiveMinutes is the longest look-ahead the alarm editor offers. Both alarmForecastPointCap and the editor's Predictive stepper range derive from these, keeping the formula, the cap, and the UI bound in step. trioShortForecastKeepsHorizon keeps every combined point before index 5 above the threshold, so its assertion holds only when the 25-minute look-ahead reaches the full horizon. loopSinglePointForecast pins both sides of the current-value convention: a lone high point stays silent and a lone low point fires. --------- Co-authored-by: Justin Maier --- .../Alarm/AlarmCondition/LowBGCondition.swift | 21 ++++--- .../Editors/LowBgAlarmEditor.swift | 2 +- LoopFollow/Task/AlarmTask.swift | 9 +-- .../AlarmConditions/LowBGConditionTests.swift | 59 ++++++++++++++++++- .../AlarmConditions/LowestForecastTests.swift | 16 ++++- 5 files changed, 90 insertions(+), 17 deletions(-) diff --git a/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift b/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift index cb4610f74..2d48fe25f 100644 --- a/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift @@ -8,6 +8,17 @@ import Foundation /// β€’ any predicted BG within `predictiveMinutes` is ≀ `belowBG`. struct LowBGCondition: AlarmCondition { static let type: AlarmType = .low + + /// Longest predictive look-ahead offered by the alarm editor, in minutes. + static let maxPredictiveMinutes = 60 + + /// Number of forecast points (5-minute spacing) needed to look `minutes` + /// ahead: the first point is the current value, so the horizon takes + /// ceil(minutes / 5) points beyond it. + static func forecastPoints(forMinutes minutes: Int) -> Int { + Int(ceil(Double(minutes) / 5.0)) + 1 + } + init() {} /// `belowBG` is this alarm's trigger threshold, not an activation limit: @@ -34,15 +45,9 @@ struct LowBGCondition: AlarmCondition { predictiveMinutes > 0, !data.predictionData.isEmpty { - let lookAhead = min( - data.predictionData.count, - Int(ceil(Double(predictiveMinutes) / 5.0)) - ) + let points = Self.forecastPoints(forMinutes: predictiveMinutes) - for i in 0 ..< lookAhead where isLow(data.predictionData[i]) { - predictiveTrigger = true - break - } + predictiveTrigger = data.predictionData.prefix(points).contains(where: isLow) } // ──────────────────────────────── diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift index a21db2239..a71f7f60b 100644 --- a/LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift +++ b/LoopFollow/Alarm/AlarmEditing/Editors/LowBgAlarmEditor.swift @@ -37,7 +37,7 @@ struct LowBgAlarmEditor: View { + "if any future value is at or below the threshold, " + "you’ll be warned early. Set 0 to disable.", title: "Predictive", - range: 0 ... 60, + range: 0 ... Double(LowBGCondition.maxPredictiveMinutes), step: 5, unitLabel: alarm.type.snoozeTimeUnit.label, value: $alarm.predictiveMinutes diff --git a/LoopFollow/Task/AlarmTask.swift b/LoopFollow/Task/AlarmTask.swift index c665c738d..6c6b341ae 100644 --- a/LoopFollow/Task/AlarmTask.swift +++ b/LoopFollow/Task/AlarmTask.swift @@ -94,9 +94,9 @@ extension MainViewController { ) } - /// Maximum number of forward points (5-minute spacing) the low alarm looks at: - /// 12 points = 60 minutes, matching the predictive look-ahead's upper bound. - static let alarmForecastPointCap = 12 + /// Maximum number of points (5-minute spacing) the low alarm looks at: + /// enough to reach the longest predictive look-ahead the editor offers. + static let alarmForecastPointCap = LowBGCondition.forecastPoints(forMinutes: LowBGCondition.maxPredictiveMinutes) /// Collapses several forecasts into a single series by taking the **lowest** /// value at each point in time, oldest .. newest at 5-minute spacing. @@ -104,7 +104,8 @@ extension MainViewController { /// Trio/OpenAPS reports four forecasts (ZT, IOB, COB, UAM) rather than the /// single one Loop provides, so this lets the predictive-low alarm fire if /// *any* forecast dips to or below the threshold. Empty forecasts are ignored, - /// and each point uses whichever forecasts still extend that far. + /// and each point uses whichever forecasts still extend that far, so one short + /// forecast does not shorten the look-ahead. static func lowestForecast( forecasts: [[Double]], start: TimeInterval, diff --git a/Tests/AlarmConditions/LowBGConditionTests.swift b/Tests/AlarmConditions/LowBGConditionTests.swift index 287799d82..c7dad6bec 100644 --- a/Tests/AlarmConditions/LowBGConditionTests.swift +++ b/Tests/AlarmConditions/LowBGConditionTests.swift @@ -34,7 +34,7 @@ struct LowBGConditionTests { @Test("#loop β€” predictive low within window fires") func loopPredictiveLowFires() { let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 30, persistentMinutes: 15) - // ceil(30/5) = 6 points looked at; index 5 dips to 75 + // 30 minutes ahead is index 6; the dip to 75 sits at index 5 let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120, 110, 100, 90, 85, 75])) #expect(cond.evaluate(alarm: alarm, data: data, now: Date())) @@ -43,12 +43,48 @@ struct LowBGConditionTests { @Test("#loop β€” forecast low beyond window does not fire") func loopPredictiveLowBeyondWindow() { let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 15, persistentMinutes: 15) - // ceil(15/5) = 3 points looked at (120, 110, 100); the low only appears later + // 15 minutes ahead is index 3, so 120, 110, 100 and 90; the low only appears later let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120, 110, 100, 90, 85, 75])) #expect(!cond.evaluate(alarm: alarm, data: data, now: Date())) } + @Test("#loop β€” the look-ahead reaches the full requested horizon") + func loopHorizonReachesRequestedMinutes() { + // The dip sits exactly 20 minutes out, at index 4. A 20-minute + // look-ahead must reach it; a 15-minute one must stop short. + let forecast = pred([118, 106, 95, 85, 70, 62]) + let data = AlarmData.withGlucose(readings: recentHigh, prediction: forecast) + + let twenty = Alarm.low(belowBG: 80, predictiveMinutes: 20, persistentMinutes: 15) + let fifteen = Alarm.low(belowBG: 80, predictiveMinutes: 15, persistentMinutes: 15) + + #expect(cond.evaluate(alarm: twenty, data: data, now: Date())) + #expect(!cond.evaluate(alarm: fifteen, data: data, now: Date())) + } + + @Test("#loop β€” a horizon longer than the forecast uses what is published") + func loopHorizonBeyondSeriesLength() { + // 60 minutes asks for 13 points but only 3 exist. The look-ahead must + // stay in bounds and still see the low at the end of what is published. + let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 60, persistentMinutes: 15) + let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120, 100, 75])) + + #expect(cond.evaluate(alarm: alarm, data: data, now: Date())) + } + + @Test("#loop β€” a single forecast point is the current value only") + func loopSinglePointForecast() { + // Index 0 is the current value: a lone point is examined, so it fires + // exactly when it is at or below the threshold. + let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 30, persistentMinutes: 15) + let high = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120])) + let low = AlarmData.withGlucose(readings: recentHigh, prediction: pred([75])) + + #expect(!cond.evaluate(alarm: alarm, data: high, now: Date())) + #expect(cond.evaluate(alarm: alarm, data: low, now: Date())) + } + @Test("#loop β€” forecast staying above threshold does not fire") func loopForecastAboveThreshold() { let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 60, persistentMinutes: 15) @@ -99,6 +135,25 @@ struct LowBGConditionTests { #expect(!cond.evaluate(alarm: alarm, data: data, now: Date())) } + @Test("#trio β€” a forecast running short does not shorten the look-ahead") + func trioShortForecastKeepsHorizon() { + // ZT stops after three points while IOB keeps falling to 69 at index 5. + // Every earlier combined point stays above the threshold, so the alarm + // fires only if the 25-minute look-ahead reaches index 5. + let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 25, persistentMinutes: 15) + let forecasts: [[Double]] = [ + [118, 115, 113], // ZT + [118, 106, 95, 85, 82, 69], // IOB + [118, 116, 114, 113, 112, 111], // COB + [118, 112, 108, 105, 103, 101], // UAM + ] + let combined = MainViewController.lowestForecast(forecasts: forecasts, start: Date().timeIntervalSince1970) + let data = AlarmData.withGlucose(readings: recentHigh, prediction: combined) + + #expect(combined.count == 6) + #expect(cond.evaluate(alarm: alarm, data: data, now: Date())) + } + @Test("#trio β€” deep forecast below display floor still fires (not masked)") func trioDeepLowNotMasked() { let alarm = Alarm.low(belowBG: 70, predictiveMinutes: 30, persistentMinutes: 15) diff --git a/Tests/AlarmConditions/LowestForecastTests.swift b/Tests/AlarmConditions/LowestForecastTests.swift index 3e821aa7d..acd6662fe 100644 --- a/Tests/AlarmConditions/LowestForecastTests.swift +++ b/Tests/AlarmConditions/LowestForecastTests.swift @@ -69,13 +69,25 @@ struct LowestForecastTests { #expect(result.count == 12) } - @Test("#default cap is 12 points") + @Test("#the default cap reaches 60 minutes ahead") func defaultCap() { let long = Array(repeating: 100.0, count: 30) let result = MainViewController.lowestForecast(forecasts: [long], start: start) #expect(result.count == MainViewController.alarmForecastPointCap) - #expect(result.count == 12) + // The first point is the current value, so the last one is 60 minutes out. + #expect(result.last?.date.timeIntervalSince1970 == start + 3600) + } + + @Test("#a short forecast does not cap the rest") + func shortForecastDoesNotCapTheRest() { + // Which forecast runs shortest varies from cycle to cycle; the series + // follows the longest one. + let short = Array(repeating: 100.0, count: 8) + let long = Array(repeating: 100.0, count: 20) + let result = MainViewController.lowestForecast(forecasts: [short, long], start: start) + + #expect(result.count == MainViewController.alarmForecastPointCap) } // MARK: - Timestamps & rounding From 8b105a187dba43d203ff97c3a7fd4c1b9b7fd63b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 17 Oct 2026 23:55:05 +0000 Subject: [PATCH 45/84] CI: Bump dev version to 7.1.15 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index cb493e3b2..622edc4e6 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.14 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.15 From e93ed446cd018bdb0c40e5b7889d43711925baa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 18 Oct 2026 02:02:58 +0200 Subject: [PATCH 46/84] Coalesce Live Activity refreshes so updates aren't postponed by frequent polling (#771) * Coalesce Live Activity refreshes into one call per 20 s window The first refresh request schedules the update 20 s later, and requests that arrive while it is pending join it without moving the deadline. When BG or device status is polled every 5 or 10 s, the snapshot is still saved and the Live Activity updated within 20 s. * Add blank line after refreshCoalescer declaration --- .../LiveActivity/LiveActivityManager.swift | 17 ++-- .../LiveActivity/RefreshCoalescer.swift | 54 +++++++++++ Tests/RefreshCoalescerTests.swift | 96 +++++++++++++++++++ 3 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 LoopFollow/LiveActivity/RefreshCoalescer.swift create mode 100644 Tests/RefreshCoalescerTests.swift diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 464de9994..82987e79b 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -178,8 +178,7 @@ final class LiveActivityManager { @objc private func handleWillResignActive() { guard Storage.shared.laEnabled.value, let activity = current else { return } - refreshWorkItem?.cancel() - refreshWorkItem = nil + refreshCoalescer.cancel() let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return } @@ -441,7 +440,10 @@ final class LiveActivityManager { private var lastUpdateTime: Date? private var pushToken: String? private var tokenObservationTask: Task? - private var refreshWorkItem: DispatchWorkItem? + private lazy var refreshCoalescer = RefreshCoalescer(delay: 20) { [weak self] reason in + self?.performRefresh(reason: reason) + } + /// Set when the user manually swipes away the LA. Blocks auto-restart until /// an explicit user action (Restart button, App Intent) clears it. /// In-memory only β€” resets to false on app relaunch, so a kill + relaunch @@ -653,12 +655,11 @@ final class LiveActivityManager { func refreshFromCurrentState(reason: String) { // No LA guard here β€” Watch and store must update regardless of LA state. // LA-specific gating (laEnabled, dismissedByUser) is applied inside performRefresh. - refreshWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in - self?.performRefresh(reason: reason) + guard Thread.isMainThread else { + DispatchQueue.main.async { self.refreshFromCurrentState(reason: reason) } + return } - refreshWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 20.0, execute: workItem) + refreshCoalescer.request(reason: reason) } // MARK: - Renewal diff --git a/LoopFollow/LiveActivity/RefreshCoalescer.swift b/LoopFollow/LiveActivity/RefreshCoalescer.swift new file mode 100644 index 000000000..85af21c95 --- /dev/null +++ b/LoopFollow/LiveActivity/RefreshCoalescer.swift @@ -0,0 +1,54 @@ +// LoopFollow +// RefreshCoalescer.swift + +import Foundation + +/// Coalesces refresh requests into one trailing call per window. +/// +/// The first request schedules the call; requests that arrive while it is +/// pending join it without moving its deadline. A steady stream of requests +/// therefore flushes once per window. Not thread-safe: use from one queue. +final class RefreshCoalescer { + typealias Scheduler = (TimeInterval, @escaping () -> Void) -> Void + + private let delay: TimeInterval + private let scheduler: Scheduler + private let action: (String) -> Void + private var pendingReasons: [String] = [] + private var generation = 0 + + init( + delay: TimeInterval, + scheduler: @escaping Scheduler = { delay, work in + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + }, + action: @escaping (String) -> Void + ) { + self.delay = delay + self.scheduler = scheduler + self.action = action + } + + var isPending: Bool { !pendingReasons.isEmpty } + + func request(reason: String) { + let wasPending = isPending + if !pendingReasons.contains(reason) { + pendingReasons.append(reason) + } + guard !wasPending else { return } + + let scheduledGeneration = generation + scheduler(delay) { [weak self] in + guard let self, self.generation == scheduledGeneration else { return } + let reason = self.pendingReasons.joined(separator: ",") + self.cancel() + self.action(reason) + } + } + + func cancel() { + pendingReasons.removeAll() + generation += 1 + } +} diff --git a/Tests/RefreshCoalescerTests.swift b/Tests/RefreshCoalescerTests.swift new file mode 100644 index 000000000..046287b08 --- /dev/null +++ b/Tests/RefreshCoalescerTests.swift @@ -0,0 +1,96 @@ +// LoopFollow +// RefreshCoalescerTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct RefreshCoalescerTests { + final class ManualScheduler { + var scheduled: [(delay: TimeInterval, work: () -> Void)] = [] + + func schedule(_ delay: TimeInterval, _ work: @escaping () -> Void) { + scheduled.append((delay, work)) + } + + func runAll() { + let pending = scheduled + scheduled.removeAll() + pending.forEach { $0.work() } + } + } + + @Test("first request schedules one call after the delay") + func firstRequestSchedules() { + let scheduler = ManualScheduler() + var fired: [String] = [] + let coalescer = RefreshCoalescer(delay: 20, scheduler: scheduler.schedule) { fired.append($0) } + + coalescer.request(reason: "bg") + + #expect(scheduler.scheduled.count == 1) + #expect(scheduler.scheduled.first?.delay == 20) + #expect(fired.isEmpty) + scheduler.runAll() + #expect(fired == ["bg"]) + #expect(!coalescer.isPending) + } + + @Test("repeated requests while pending keep the original deadline") + func repeatedRequestsDoNotReschedule() { + let scheduler = ManualScheduler() + var fired: [String] = [] + let coalescer = RefreshCoalescer(delay: 20, scheduler: scheduler.schedule) { fired.append($0) } + + for _ in 0 ..< 10 { + coalescer.request(reason: "bg") + } + coalescer.request(reason: "overrideChanged") + + #expect(scheduler.scheduled.count == 1) + scheduler.runAll() + #expect(fired == ["bg,overrideChanged"]) + } + + @Test("a request after a flush opens a new window") + func requestAfterFlushSchedulesAgain() { + let scheduler = ManualScheduler() + var fired: [String] = [] + let coalescer = RefreshCoalescer(delay: 20, scheduler: scheduler.schedule) { fired.append($0) } + + coalescer.request(reason: "bg") + scheduler.runAll() + coalescer.request(reason: "bg") + + #expect(scheduler.scheduled.count == 1) + scheduler.runAll() + #expect(fired == ["bg", "bg"]) + } + + @Test("cancel drops the pending call") + func cancelDropsPending() { + let scheduler = ManualScheduler() + var fired: [String] = [] + let coalescer = RefreshCoalescer(delay: 20, scheduler: scheduler.schedule) { fired.append($0) } + + coalescer.request(reason: "bg") + coalescer.cancel() + #expect(!coalescer.isPending) + scheduler.runAll() + #expect(fired.isEmpty) + } + + @Test("a request after cancel fires only once") + func requestAfterCancelFiresOnce() { + let scheduler = ManualScheduler() + var fired: [String] = [] + let coalescer = RefreshCoalescer(delay: 20, scheduler: scheduler.schedule) { fired.append($0) } + + coalescer.request(reason: "bg") + coalescer.cancel() + coalescer.request(reason: "notLooping") + scheduler.runAll() + + #expect(fired == ["notLooping"]) + } +} From 7f6add8e9ce3608837fe03d463177e42c5fb4fba Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 18 Oct 2026 00:03:07 +0000 Subject: [PATCH 47/84] CI: Bump dev version to 7.1.16 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 622edc4e6..425721b0a 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.15 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.16 From 9b9124cdc4ecc9e7c5bee7418355737f642cb062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 23 Oct 2026 23:59:19 +0200 Subject: [PATCH 48/84] Log app exit reasons and memory use (#788) MetricKit exit counts and crash diagnostics are written to the log when iOS delivers them. The app also logs when it is about to terminate, and the background log line includes the memory footprint and the memory left before the limit. --- LoopFollow/Application/AppDelegate.swift | 2 + LoopFollow/Helpers/ExitDiagnostics.swift | 89 +++++++++++++++++++ .../ViewControllers/MainViewController.swift | 2 +- 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 LoopFollow/Helpers/ExitDiagnostics.swift diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 3cc65b736..dd67f8801 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -11,6 +11,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { LogManager.shared.log(category: .general, message: "App started") LogManager.shared.cleanupOldLogs() + ExitDiagnostics.shared.start() // Notification and calendar permissions are no longer requested here. // They're deferred to the moment the user opts into the feature that @@ -109,6 +110,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } func applicationWillTerminate(_: UIApplication) { + LogManager.shared.log(category: .general, message: "App will terminate (\(ExitDiagnostics.memoryDescription()))") #if !targetEnvironment(macCatalyst) LiveActivityManager.shared.endOnTerminate() #endif diff --git a/LoopFollow/Helpers/ExitDiagnostics.swift b/LoopFollow/Helpers/ExitDiagnostics.swift new file mode 100644 index 000000000..8664ef084 --- /dev/null +++ b/LoopFollow/Helpers/ExitDiagnostics.swift @@ -0,0 +1,89 @@ +// LoopFollow +// ExitDiagnostics.swift + +import Foundation +import MetricKit +import os + +/// Logs MetricKit app exit counts and crash diagnostics. iOS delivers these +/// at most once a day, so a log shared the day after a termination shows why +/// the process ended. +final class ExitDiagnostics: NSObject, MXMetricManagerSubscriber { + static let shared = ExitDiagnostics() + + func start() { + MXMetricManager.shared.add(self) + } + + func didReceive(_ payloads: [MXMetricPayload]) { + for payload in payloads { + guard let exits = payload.applicationExitMetrics else { continue } + let range = "\(payload.timeStampBegin) – \(payload.timeStampEnd)" + + let bg = exits.backgroundExitData + let background = Self.describe([ + ("normal", bg.cumulativeNormalAppExitCount), + ("memoryLimit", bg.cumulativeMemoryResourceLimitExitCount), + ("memoryPressure", bg.cumulativeMemoryPressureExitCount), + ("cpuLimit", bg.cumulativeCPUResourceLimitExitCount), + ("badAccess", bg.cumulativeBadAccessExitCount), + ("illegalInstruction", bg.cumulativeIllegalInstructionExitCount), + ("abnormal", bg.cumulativeAbnormalExitCount), + ("watchdog", bg.cumulativeAppWatchdogExitCount), + ("lockedFile", bg.cumulativeSuspendedWithLockedFileExitCount), + ("taskAssertionTimeout", bg.cumulativeBackgroundTaskAssertionTimeoutExitCount), + ]) + + let fg = exits.foregroundExitData + let foreground = Self.describe([ + ("normal", fg.cumulativeNormalAppExitCount), + ("memoryLimit", fg.cumulativeMemoryResourceLimitExitCount), + ("badAccess", fg.cumulativeBadAccessExitCount), + ("illegalInstruction", fg.cumulativeIllegalInstructionExitCount), + ("abnormal", fg.cumulativeAbnormalExitCount), + ("watchdog", fg.cumulativeAppWatchdogExitCount), + ]) + + LogManager.shared.log(category: .general, message: "App exits \(range): background [\(background)], foreground [\(foreground)]") + } + } + + func didReceive(_ payloads: [MXDiagnosticPayload]) { + for payload in payloads { + for crash in payload.crashDiagnostics ?? [] { + let signal = crash.signal.map { "\($0)" } ?? "-" + let exceptionType = crash.exceptionType.map { "\($0)" } ?? "-" + let exceptionCode = crash.exceptionCode.map { "\($0)" } ?? "-" + let reason = crash.terminationReason ?? "-" + LogManager.shared.log( + category: .general, + message: "Crash reported for \(payload.timeStampBegin) – \(payload.timeStampEnd): version=\(crash.applicationVersion), signal=\(signal), exceptionType=\(exceptionType), exceptionCode=\(exceptionCode), terminationReason=\(reason)" + ) + } + } + } + + /// Current memory footprint and the headroom left before iOS terminates the + /// process for exceeding its memory limit. + static func memoryDescription() -> String { + var info = task_vm_info_data_t() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count) + } + } + let footprint = result == KERN_SUCCESS ? "\(info.phys_footprint / 1_048_576) MB" : "unknown" + #if targetEnvironment(macCatalyst) + return "memoryFootprint=\(footprint)" + #else + let available = os_proc_available_memory() / 1_048_576 + return "memoryFootprint=\(footprint), memoryAvailable=\(available) MB" + #endif + } + + private static func describe(_ counts: [(String, Int)]) -> String { + let nonZero = counts.filter { $0.1 > 0 } + return nonZero.isEmpty ? "none" : nonZero.map { "\($0.0)=\($0.1)" }.joined(separator: ", ") + } +} diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 5c6fb7e4f..70d25543c 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -590,7 +590,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { @objc func appMovedToBackground() { LogManager.shared.log( category: .general, - message: "App moved to background (refreshType=\(Storage.shared.backgroundRefreshType.value.rawValue), lowPowerMode=\(ProcessInfo.processInfo.isLowPowerModeEnabled), backgroundRefreshStatus=\(Self.describe(UIApplication.shared.backgroundRefreshStatus)))" + message: "App moved to background (refreshType=\(Storage.shared.backgroundRefreshType.value.rawValue), lowPowerMode=\(ProcessInfo.processInfo.isLowPowerModeEnabled), backgroundRefreshStatus=\(Self.describe(UIApplication.shared.backgroundRefreshStatus)), \(ExitDiagnostics.memoryDescription()))" ) // Allow screen to turn off From b077f0b9773a1f2550248852453473d6635f15fd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 21:59:31 +0000 Subject: [PATCH 49/84] CI: Bump dev version to 7.1.17 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 425721b0a..af6568b0f 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.16 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.17 From 112467da40abf321f43651f7b77c95ff49f97b73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 24 Oct 2026 00:11:20 +0200 Subject: [PATCH 50/84] Keep the guardrail alert visible on the carb and meal edit screens (#786) Tapping Update while the carb field is focused ran the guardrail check and then the form validation right after it. The guardrail alert reset the field to 0, so the validation replaced it with the empty-amount alert, or for a Trio meal with fat or protein, with the confirmation showing 0 g carbs. The validation now skips when an alert is already showing. --- LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift | 1 + LoopFollow/Remote/TRC/TRCMealEditView.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift b/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift index 2fd82cabc..fdf10c8f8 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift @@ -160,6 +160,7 @@ struct LoopCarbEditSheet: View { } private func validateAndConfirm() { + guard alertType == nil else { return } guard carbsAmount.doubleValue(for: .gram()) > 0 else { alertType = .validation("Enter a carb amount, or use Delete carbs to remove the entry.") return diff --git a/LoopFollow/Remote/TRC/TRCMealEditView.swift b/LoopFollow/Remote/TRC/TRCMealEditView.swift index 06cb8fa67..9da718162 100644 --- a/LoopFollow/Remote/TRC/TRCMealEditView.swift +++ b/LoopFollow/Remote/TRC/TRCMealEditView.swift @@ -90,6 +90,7 @@ struct TRCMealEditView: View { fatFocused = false proteinFocused = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + guard alertType == nil else { return } guard carbsValue > 0 || fatValue > 0 || proteinValue > 0 else { alertType = .validation("Enter at least one of carbs, fat, or protein. Use Delete meal to remove the meal.") return From 20cb3b8781d69961bd930a048d1768ef3fe5028a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 22:11:29 +0000 Subject: [PATCH 51/84] CI: Bump dev version to 7.1.18 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index af6568b0f..becf7c21f 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.17 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.18 From d433ff3799c3772e5f791fc20b890e2be07c3027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 24 Oct 2026 00:26:44 +0200 Subject: [PATCH 52/84] Label Loop automatic boluses as Auto Bolus in the chart (#787) The chart popup for an automatic bolus says SMB for every system. Loop does not use SMBs, so for Loop it now says Auto Bolus and other systems keep SMB. --- LoopFollow/Charts/BGChartModel.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index ab6deeb67..a9b8815d7 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -492,6 +492,7 @@ final class BGChartModel: ObservableObject { detail: $0.detail ) }, minGap: Spread.carbGap, maxShift: Spread.carbShift) + let automaticBolusTitle = isLoop ? "Auto Bolus" : "SMB" let smbPoints = (showBolus ? vc.smbData : []).map { let dose = self.formatDose($0.value) return TreatmentPoint( @@ -499,7 +500,7 @@ final class BGChartModel: ObservableObject { value: $0.value, sgv: Double($0.sgv), label: dose, - pillText: "SMB\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))", + pillText: "\(automaticBolusTitle)\n\(dose)U\n\(pillTimeString(for: Date(timeIntervalSince1970: $0.date)))", detail: $0.detail ) } From 0e68dde65215a8fb07a43be06d5ee1a1512a412f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 22:26:55 +0000 Subject: [PATCH 53/84] CI: Bump dev version to 7.1.19 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index becf7c21f..1561d6403 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.18 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.19 From 66e3f9e3c96b4ab7792e9ab45893b3398e78221f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 24 Oct 2026 00:32:03 +0200 Subject: [PATCH 54/84] Show Loop IOB and COB on failed loops and hold the pump battery (#784) Loop uploads IOB and COB in records where the loop failed, so they are shown for those records too. The pump battery row keeps the last reading for up to an hour when a record leaves it out. Co-authored-by: Phil A <76601115+MtlPhil@users.noreply.github.com> --- .../Controllers/Nightscout/DeviceStatus.swift | 8 +++++ .../Nightscout/DeviceStatusLoop.swift | 30 +++++++++---------- .../ViewControllers/MainViewController.swift | 1 + 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index ed672fa37..a4741037b 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -146,6 +146,14 @@ extension MainViewController { { infoManager.updateInfoData(type: .pumpBattery, value: String(format: "%.0f", pumpBatteryPercent) + "%", numericValue: pumpBatteryPercent) Observable.shared.pumpBatteryLevel.value = pumpBatteryPercent + latestPumpBatteryDate = Date() + } else if let lastPercent = Observable.shared.pumpBatteryLevel.value, + let lastDate = latestPumpBatteryDate, + Date().timeIntervalSince(lastDate) <= 60 * 60 + { + // Some records leave out the pump battery, and the level changes + // slowly, so the last reading is shown for up to an hour. + infoManager.updateInfoData(type: .pumpBattery, value: String(format: "%.0f", lastPercent) + "%", numericValue: lastPercent) } if let uploader = lastDeviceStatus?["uploader"] as? [String: AnyObject], diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index 888c24a3c..8414b2457 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -17,6 +17,21 @@ extension MainViewController { let previousLastLoopTime = Observable.shared.previousAlertLastLoopTime.value ?? 0 let lastLoopTime = Observable.shared.alertLastLoopTime.value ?? 0 + // Loop reports IOB and COB even when the loop failed. + if let insulinMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") { + infoManager.updateInfoData(type: .iob, value: insulinMetric) + latestIOB = insulinMetric + Observable.shared.iobText.value = insulinMetric.formattedValue() + } + + if let cobMetric = CarbMetric(from: lastLoopRecord["cob"], key: "cob") { + infoManager.updateInfoData(type: .cob, value: cobMetric) + latestCOB = cobMetric + } + + Storage.shared.lastIOB.value = latestIOB?.value + Storage.shared.lastCOB.value = latestCOB?.value + if lastLoopRecord["failureReason"] != nil { Observable.shared.loopStatusText.value = "X" latestLoopStatusString = "X" @@ -52,19 +67,6 @@ extension MainViewController { Storage.shared.lastTargetLowMgdl.value = profileTargetLow?.doubleValue(for: .milligramsPerDeciliter) Storage.shared.lastTargetHighMgdl.value = profileTargetHigh?.doubleValue(for: .milligramsPerDeciliter) - // IOB - if let insulinMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") { - infoManager.updateInfoData(type: .iob, value: insulinMetric) - latestIOB = insulinMetric - Observable.shared.iobText.value = insulinMetric.formattedValue() - } - - // COB - if let cobMetric = CarbMetric(from: lastLoopRecord["cob"], key: "cob") { - infoManager.updateInfoData(type: .cob, value: cobMetric) - latestCOB = cobMetric - } - if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject], let prediction = predictdata["values"] as? [Double], let lastPrediction = prediction.last @@ -132,8 +134,6 @@ extension MainViewController { } // Live Activity storage - Storage.shared.lastIOB.value = latestIOB?.value - Storage.shared.lastCOB.value = latestCOB?.value if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject], let values = predictdata["values"] as? [Double] { diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 70d25543c..d788abfeb 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -105,6 +105,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { var latestCOB: CarbMetric? var latestBasal = "" var latestPumpVolume: Double? + var latestPumpBatteryDate: Date? var latestIOB: InsulinMetric? var lastOverrideStartTime: TimeInterval = 0 var lastOverrideEndTime: TimeInterval = 0 From 98018de1727900bf080914bd019bfef01eed0cb9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 23 Oct 2026 22:32:12 +0000 Subject: [PATCH 55/84] CI: Bump dev version to 7.1.20 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 1561d6403..bc667a2f2 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.19 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.20 From 5f237c4aebea26960889a6374c5876f8e50bbb96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 26 Oct 2026 15:11:07 +0100 Subject: [PATCH 56/84] Add an Apple Watch app (#785) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add an Apple Watch app fed by the phone, with remote commands for Loop and Trio The phone sends glucose, chart data and remote settings to the watch over WatchConnectivity; the watch holds no credentials. Commands from the watch are validated and sent by the phone through the existing Loop APNS and Trio Remote Control paths, gated by a new Remote setting. Includes WidgetKit complications, watch targets in fastlane and tests for the watch protocol. * Put the watch Remote page before Info, allow watch commands by default, and limit complication reloads to new readings Remote tiles are disabled while the iPhone is out of reach. * Make the watch app glanceable: one large BG, actions in the bottom toolbar, quick picks The glucose page shows BG with trend and delta, IOB and age in the top bar, a two-hour sparkline, and Override, Meal or Bolus, and Temp Target in the bottom toolbar. The full chart has its own page. Bolus offers the AID's recommendation and the phone's Quick-Pick boluses before a custom amount; Meal shows Quick-Pick meals and carbs, with fat, protein, bolus and time under More. A successful send shows Sent, notes that Loop or Trio confirms with a notification, and returns to the glucose page. * Point browser builders to the docs when the watch identifiers are not set up * Keep watch Info values clear of the page indicator and widen the Meal buttons * Check that the watch identifiers exist before match, since its read-only failure does not name the missing profile * Mirror the phone's info table on the watch: same rows, order and coloring * Share the APNs, JWT, TRC encryption and Loop payload code between the phone and the watch * Send credentials to the watch when they change, and share command validation between the phone and the watch * Let the watch fetch from Nightscout or Dexcom and send commands directly while the phone is out of reach * Show the phone's statistics on the watch and mark data fetched by the watch itself * Scrub the watch chart with the Digital Crown and pick the time span from the toolbar * Test watch command validation, signing key choice, devicestatus parsing and Loop override payloads * Schedule the watch background refresh once the app is running * Deliver credentials to the watch by message as well as by transfer, and send directly when the phone is unreachable before delivery * Convert Nightscout dates on the watch and give up waiting for the phone after 30 seconds * Leave out watch chart hour labels too close to the edges to fit * Show only the reading age on the watch glucose page, keep the toolbar buttons the same size, and let Trio meals carry a bolus as on the phone The crown symbol for setting an amount follows the wrist the watch is worn on. * Let the crown scroll the watch meal screen until a field is tapped * Show Other Amount on the watch bolus list as plain text * Fit the watch statistics page on one screen on every watch size and wrap the waiting-for-iPhone title * Watch: centered glucose page, working chart scrubbing, and overrides (#789) * Center the watch BG with IOB and COB icons, and fix crown scrubbing on the chart page The trend arrow no longer shifts the BG digits or the delta off center, and IOB and COB share one line under the delta with syringe and fork-and-knife icons. On the chart page, crown focus is now requested after the page becomes focusable, so the crown scrubs instead of paging. A crown button in the toolbar shows the feature and toggles it. * Show BG, carbs and insulin under the watch chart while scrubbing The readout moves below the chart so it no longer collides with the toolbar buttons, and shows a range-colored dot with the BG plus any carbs and boluses that fall within the marker's five-minute crown step. * Show overrides on the watch: a band on the chart and the running one on the glucose page The phone now sends the override periods of the last 24 hours with their name and insulin needs, taking the percentage from the matching preset when Nightscout does not carry it. The chart draws them as a thin green band along the top, and the glucose page shows the running override's name and percentage under IOB and COB. * Show the override's name and percentage under the watch chart The line shows the running override, or the one at the marker while scrubbing, and shares its view with the glucose page. * Give the crown back to page navigation when watch chart scrubbing ends * Scrub the watch chart on a screen of its own so the crown pages again after going back * Open watch chart scrubbing by tapping the chart * Step watch amounts one detent at a time and add βˆ’ and + buttons The crown moves the amount by whole steps, insulin fields use low crown sensitivity, and the buttons nudge the value by one step. * Keep the watch βˆ’ button clear of the screen edge and enlarge the nudge buttons --------- Co-authored-by: Phil A <76601115+MtlPhil@users.noreply.github.com> --- LoopFollow.xcodeproj/project.pbxproj | 356 ++++++++++++++++++ LoopFollow/Application/AppDelegate.swift | 2 + LoopFollow/Charts/BGChartModel.swift | 1 + LoopFollow/InfoTable/InfoDisplayItem.swift | 20 +- .../LiveActivity/GlucoseSnapshotBuilder.swift | 37 +- .../LiveActivity/LiveActivityManager.swift | 4 +- .../Remote/LoopAPNS/LoopAPNSCarbsView.swift | 26 -- .../Remote/LoopAPNS/LoopAPNSService.swift | 338 ++--------------- .../ReturnNotificationInfo+LoopFollow.swift | 35 ++ .../Remote/Settings/RemoteSettingsView.swift | 8 + .../Remote/TRC/PushNotificationManager.swift | 160 ++------ LoopFollow/Storage/Storage.swift | 2 + .../ViewControllers/MainViewController.swift | 1 + LoopFollow/Watch/WatchCommandHandler.swift | 213 +++++++++++ .../Watch/WatchCredentialsBuilder.swift | 41 ++ LoopFollow/Watch/WatchSessionManager.swift | 210 +++++++++++ LoopFollow/Watch/WatchStateBuilder.swift | 252 +++++++++++++ .../AppIcon.appiconset/1024.png | Bin 0 -> 477801 bytes .../AppIcon.appiconset/Contents.json | 14 + LoopFollowWatch/Assets.xcassets/Contents.json | 6 + LoopFollowWatch/ChartPage.swift | 134 +++++++ LoopFollowWatch/ContentView.swift | 112 ++++++ LoopFollowWatch/CrownSymbol.swift | 11 + LoopFollowWatch/DirectCommandSender.swift | 172 +++++++++ LoopFollowWatch/GlucosePage.swift | 263 +++++++++++++ LoopFollowWatch/Info.plist | 28 ++ LoopFollowWatch/InfoPage.swift | 55 +++ LoopFollowWatch/LogManager.swift | 32 ++ LoopFollowWatch/LoopFollowWatch.entitlements | 10 + LoopFollowWatch/LoopFollowWatchApp.swift | 38 ++ LoopFollowWatch/Remote/BolusEntryView.swift | 186 +++++++++ .../Remote/CommandConfirmView.swift | 176 +++++++++ LoopFollowWatch/Remote/MealEntryView.swift | 151 ++++++++ LoopFollowWatch/Remote/PresetListView.swift | 77 ++++ .../Remote/TreatmentMenuView.swift | 24 ++ LoopFollowWatch/StandaloneFetcher.swift | 183 +++++++++ LoopFollowWatch/StatsPage.swift | 69 ++++ LoopFollowWatch/WatchChartView.swift | 199 ++++++++++ LoopFollowWatch/WatchKeychain.swift | 46 +++ LoopFollowWatch/WatchSession.swift | 269 +++++++++++++ .../GlucoseComplicationView.swift | 132 +++++++ LoopFollowWatchWidgets/Info.plist | 29 ++ .../LoopFollowWatchWidgets.entitlements | 10 + .../LoopFollowWatchWidgets.swift | 50 +++ RemoteShared/APNSDelivery.swift | 100 +++++ .../APNSEnvironment.swift | 0 .../DateExtensions.swift | 0 RemoteShared/DeviceStatusSummary.swift | 92 +++++ .../Helpers => RemoteShared}/JWTManager.swift | 0 .../LogRedactor.swift | 0 RemoteShared/LoopAPNSCommands.swift | 165 ++++++++ .../OTPSecureMessenger.swift | 2 +- .../TRC => RemoteShared}/PushMessage.swift | 18 - .../SecureMessenger.swift | 0 .../ShareClientExtension.swift | 5 +- .../TOTPGenerator.swift | 0 .../TRC => RemoteShared}/TRCCommandType.swift | 0 RemoteShared/WatchCommandValidator.swift | 134 +++++++ RemoteShared/WatchCredentials.swift | 64 ++++ Shared/AppGroupID.swift | 2 + Shared/GlucoseSnapshot.swift | 22 ++ Tests/LoopAPNSCommandPayloadTests.swift | 10 +- Tests/WatchProtocolTests.swift | 104 +++++ Tests/WatchRemoteTests.swift | 138 +++++++ WatchShared/WatchCommand.swift | 69 ++++ WatchShared/WatchFormat.swift | 104 +++++ WatchShared/WatchState.swift | 189 ++++++++++ WatchShared/WatchStateStore.swift | 26 ++ fastlane/Fastfile | 99 ++++- 69 files changed, 4975 insertions(+), 550 deletions(-) create mode 100644 LoopFollow/Remote/ReturnNotificationInfo+LoopFollow.swift create mode 100644 LoopFollow/Watch/WatchCommandHandler.swift create mode 100644 LoopFollow/Watch/WatchCredentialsBuilder.swift create mode 100644 LoopFollow/Watch/WatchSessionManager.swift create mode 100644 LoopFollow/Watch/WatchStateBuilder.swift create mode 100644 LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/1024.png create mode 100644 LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 LoopFollowWatch/Assets.xcassets/Contents.json create mode 100644 LoopFollowWatch/ChartPage.swift create mode 100644 LoopFollowWatch/ContentView.swift create mode 100644 LoopFollowWatch/CrownSymbol.swift create mode 100644 LoopFollowWatch/DirectCommandSender.swift create mode 100644 LoopFollowWatch/GlucosePage.swift create mode 100644 LoopFollowWatch/Info.plist create mode 100644 LoopFollowWatch/InfoPage.swift create mode 100644 LoopFollowWatch/LogManager.swift create mode 100644 LoopFollowWatch/LoopFollowWatch.entitlements create mode 100644 LoopFollowWatch/LoopFollowWatchApp.swift create mode 100644 LoopFollowWatch/Remote/BolusEntryView.swift create mode 100644 LoopFollowWatch/Remote/CommandConfirmView.swift create mode 100644 LoopFollowWatch/Remote/MealEntryView.swift create mode 100644 LoopFollowWatch/Remote/PresetListView.swift create mode 100644 LoopFollowWatch/Remote/TreatmentMenuView.swift create mode 100644 LoopFollowWatch/StandaloneFetcher.swift create mode 100644 LoopFollowWatch/StatsPage.swift create mode 100644 LoopFollowWatch/WatchChartView.swift create mode 100644 LoopFollowWatch/WatchKeychain.swift create mode 100644 LoopFollowWatch/WatchSession.swift create mode 100644 LoopFollowWatchWidgets/GlucoseComplicationView.swift create mode 100644 LoopFollowWatchWidgets/Info.plist create mode 100644 LoopFollowWatchWidgets/LoopFollowWatchWidgets.entitlements create mode 100644 LoopFollowWatchWidgets/LoopFollowWatchWidgets.swift create mode 100644 RemoteShared/APNSDelivery.swift rename {LoopFollow/Remote => RemoteShared}/APNSEnvironment.swift (100%) rename {LoopFollow/Helpers => RemoteShared}/DateExtensions.swift (100%) create mode 100644 RemoteShared/DeviceStatusSummary.swift rename {LoopFollow/Helpers => RemoteShared}/JWTManager.swift (100%) rename {LoopFollow/Helpers => RemoteShared}/LogRedactor.swift (100%) create mode 100644 RemoteShared/LoopAPNSCommands.swift rename {LoopFollow/Remote/LoopAPNS => RemoteShared}/OTPSecureMessenger.swift (97%) rename {LoopFollow/Remote/TRC => RemoteShared}/PushMessage.swift (77%) rename {LoopFollow/Remote/TRC => RemoteShared}/SecureMessenger.swift (100%) rename {LoopFollow/Extensions => RemoteShared}/ShareClientExtension.swift (96%) rename {LoopFollow/Helpers => RemoteShared}/TOTPGenerator.swift (100%) rename {LoopFollow/Remote/TRC => RemoteShared}/TRCCommandType.swift (100%) create mode 100644 RemoteShared/WatchCommandValidator.swift create mode 100644 RemoteShared/WatchCredentials.swift create mode 100644 Tests/WatchProtocolTests.swift create mode 100644 Tests/WatchRemoteTests.swift create mode 100644 WatchShared/WatchCommand.swift create mode 100644 WatchShared/WatchFormat.swift create mode 100644 WatchShared/WatchState.swift create mode 100644 WatchShared/WatchStateStore.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index 560cd2f8e..e9db02c3f 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -17,6 +17,9 @@ DDF2C0142BEFD468007A20E6 /* blacklisted-versions.json in Resources */ = {isa = PBXBuildFile; fileRef = DDF2C0132BEFD468007A20E6 /* blacklisted-versions.json */; }; FC5A5C3D2497B229009C550E /* Config.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = FC5A5C3C2497B229009C550E /* Config.xcconfig */; }; FCFEEC9E2486E68E00402A7F /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCFEEC9D2486E68E00402A7F /* WebKit.framework */; }; + DD57A7C02F70000000000001 /* LoopFollowWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = DD57A7C02F70000000000002 /* LoopFollowWatch.app */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + DD57A7C02F70000000000003 /* LoopFollowWatchWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = DD57A7C02F70000000000004 /* LoopFollowWatchWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + DD57A7C02F70000000000103 /* ShareClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD57A7C02F70000000000102 /* ShareClient.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -34,6 +37,20 @@ remoteGlobalIDString = FC9788132485969B00A7906C; remoteInfo = LoopFollow; }; + DD57A7C02F70000000000005 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FC97880C2485969B00A7906C /* Project object */; + proxyType = 1; + remoteGlobalIDString = DD57A7C02F70000000000006; + remoteInfo = LoopFollowWatch; + }; + DD57A7C02F70000000000007 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = FC97880C2485969B00A7906C /* Project object */; + proxyType = 1; + remoteGlobalIDString = DD57A7C02F70000000000008; + remoteInfo = LoopFollowWatchWidgets; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -48,6 +65,28 @@ name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + DD57A7C02F70000000000009 /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + DD57A7C02F70000000000001 /* LoopFollowWatch.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; + DD57A7C02F7000000000000A /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + DD57A7C02F70000000000003 /* LoopFollowWatchWidgets.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -68,6 +107,9 @@ FC9788142485969B00A7906C /* Loop Follow.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Loop Follow.app"; sourceTree = BUILT_PRODUCTS_DIR; }; FCE537C2249AAB2600F80BF8 /* NotificationCenter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NotificationCenter.framework; path = System/Library/Frameworks/NotificationCenter.framework; sourceTree = SDKROOT; }; FCFEEC9D2486E68E00402A7F /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; }; + DD57A7C02F70000000000002 /* LoopFollowWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LoopFollowWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; + DD57A7C02F70000000000004 /* LoopFollowWatchWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = LoopFollowWatchWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + DD57A7C02F70000000000102 /* ShareClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ShareClient.swift; path = Pods/ShareClient/ShareClient/ShareClient.swift; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -78,6 +120,42 @@ ); target = FC9788132485969B00A7906C /* LoopFollow */; }; + DD57A7C02F7000000000000B /* Exceptions for "LoopFollowWatch" folder in "LoopFollowWatch" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + LoopFollowWatch.entitlements, + ); + target = DD57A7C02F70000000000006 /* LoopFollowWatch */; + }; + DD57A7C02F7000000000000C /* Exceptions for "LoopFollowWatchWidgets" folder in "LoopFollowWatchWidgets" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + LoopFollowWatchWidgets.entitlements, + ); + target = DD57A7C02F70000000000008 /* LoopFollowWatchWidgets */; + }; + DD57A7C02F7000000000000D /* Exceptions for "Shared" folder in "LoopFollowWatch" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + GlucoseLiveActivityAttributes.swift, + LAAppGroupSettings.swift, + LALivenessMarker.swift, + LALivenessStore.swift, + ); + target = DD57A7C02F70000000000006 /* LoopFollowWatch */; + }; + DD57A7C02F7000000000000E /* Exceptions for "Shared" folder in "LoopFollowWatchWidgets" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + GlucoseLiveActivityAttributes.swift, + LAAppGroupSettings.swift, + LALivenessMarker.swift, + LALivenessStore.swift, + ); + target = DD57A7C02F70000000000008 /* LoopFollowWatchWidgets */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -103,6 +181,8 @@ BC0FBFD3754955D028EF6631 /* Shared */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( + DD57A7C02F7000000000000D /* Exceptions for "Shared" folder in "LoopFollowWatch" target */, + DD57A7C02F7000000000000E /* Exceptions for "Shared" folder in "LoopFollowWatchWidgets" target */, ); path = Shared; sourceTree = ""; @@ -118,6 +198,36 @@ path = Tests; sourceTree = ""; }; + DD57A7C02F7000000000000F /* LoopFollowWatch */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + DD57A7C02F7000000000000B /* Exceptions */, + ); + path = LoopFollowWatch; + sourceTree = ""; + }; + DD57A7C02F70000000000010 /* LoopFollowWatchWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + DD57A7C02F7000000000000C /* Exceptions */, + ); + path = LoopFollowWatchWidgets; + sourceTree = ""; + }; + DD57A7C02F70000000000011 /* WatchShared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + path = WatchShared; + sourceTree = ""; + }; + DD57A7C02F70000000000101 /* RemoteShared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); + path = RemoteShared; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -147,6 +257,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DD57A7C02F70000000000012 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DD57A7C02F70000000000013 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -168,6 +292,7 @@ children = ( ECA3EFB4037410B4973BB632 /* Pods-LoopFollow.debug.xcconfig */, 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */, + DD57A7C02F70000000000102 /* ShareClient.swift */, ); path = Pods; sourceTree = ""; @@ -196,6 +321,10 @@ 6A5880E0B811AF443B05AB02 /* Frameworks */, 9555FE45F7FAEFA6142D04CA /* LoopFollow */, BC0FBFD3754955D028EF6631 /* Shared */, + DD57A7C02F7000000000000F /* LoopFollowWatch */, + DD57A7C02F70000000000010 /* LoopFollowWatchWidgets */, + DD57A7C02F70000000000011 /* WatchShared */, + DD57A7C02F70000000000101 /* RemoteShared */, ); sourceTree = ""; }; @@ -205,6 +334,8 @@ FC9788142485969B00A7906C /* Loop Follow.app */, DDCC3AD62DDE1790006F1C10 /* Tests.xctest */, 37A4BDD92F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension.appex */, + DD57A7C02F70000000000002 /* LoopFollowWatch.app */, + DD57A7C02F70000000000004 /* LoopFollowWatchWidgets.appex */, ); name = Products; sourceTree = ""; @@ -266,15 +397,19 @@ 04DA71CCA0280FA5FA2DF7A6 /* [CP] Embed Pods Frameworks */, DDB0AF532BB1AA0900AFA48B /* Capture Build Details */, 37A4BDED2F5B6B4C00EEB289 /* Embed Foundation Extensions */, + DD57A7C02F70000000000009 /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( 37A4BDE72F5B6B4C00EEB289 /* PBXTargetDependency */, + DD57A7C02F7000000000001B /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 9555FE45F7FAEFA6142D04CA /* LoopFollow */, BC0FBFD3754955D028EF6631 /* Shared */, + DD57A7C02F70000000000011 /* WatchShared */, + DD57A7C02F70000000000101 /* RemoteShared */, ); name = LoopFollow; packageProductDependencies = ( @@ -284,6 +419,53 @@ productReference = FC9788142485969B00A7906C /* Loop Follow.app */; productType = "com.apple.product-type.application"; }; + DD57A7C02F70000000000006 /* LoopFollowWatch */ = { + isa = PBXNativeTarget; + buildConfigurationList = DD57A7C02F70000000000014 /* Build configuration list for PBXNativeTarget "LoopFollowWatch" */; + buildPhases = ( + DD57A7C02F70000000000015 /* Sources */, + DD57A7C02F70000000000012 /* Frameworks */, + DD57A7C02F70000000000016 /* Resources */, + DD57A7C02F7000000000000A /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + DD57A7C02F70000000000017 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + DD57A7C02F7000000000000F /* LoopFollowWatch */, + DD57A7C02F70000000000011 /* WatchShared */, + BC0FBFD3754955D028EF6631 /* Shared */, + DD57A7C02F70000000000101 /* RemoteShared */, + ); + name = LoopFollowWatch; + productName = LoopFollowWatch; + productReference = DD57A7C02F70000000000002 /* LoopFollowWatch.app */; + productType = "com.apple.product-type.application"; + }; + DD57A7C02F70000000000008 /* LoopFollowWatchWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = DD57A7C02F70000000000018 /* Build configuration list for PBXNativeTarget "LoopFollowWatchWidgets" */; + buildPhases = ( + DD57A7C02F70000000000019 /* Sources */, + DD57A7C02F70000000000013 /* Frameworks */, + DD57A7C02F7000000000001A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + DD57A7C02F70000000000010 /* LoopFollowWatchWidgets */, + DD57A7C02F70000000000011 /* WatchShared */, + BC0FBFD3754955D028EF6631 /* Shared */, + ); + name = LoopFollowWatchWidgets; + productName = LoopFollowWatchWidgets; + productReference = DD57A7C02F70000000000004 /* LoopFollowWatchWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -297,6 +479,12 @@ 37A4BDD82F5B6B4A00EEB289 = { CreatedOnToolsVersion = 26.2; }; + DD57A7C02F70000000000006 = { + CreatedOnToolsVersion = 27.0; + }; + DD57A7C02F70000000000008 = { + CreatedOnToolsVersion = 27.0; + }; DDCC3AD52DDE1790006F1C10 = { CreatedOnToolsVersion = 16.3; TestTargetID = FC9788132485969B00A7906C; @@ -325,6 +513,8 @@ FC9788132485969B00A7906C /* LoopFollow */, DDCC3AD52DDE1790006F1C10 /* Tests */, 37A4BDD82F5B6B4A00EEB289 /* LoopFollowLAExtensionExtension */, + DD57A7C02F70000000000006 /* LoopFollowWatch */, + DD57A7C02F70000000000008 /* LoopFollowWatchWidgets */, ); }; /* End PBXProject section */ @@ -355,6 +545,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DD57A7C02F70000000000016 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DD57A7C02F7000000000001A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -462,6 +666,21 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DD57A7C02F70000000000015 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + DD57A7C02F70000000000103 /* ShareClient.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DD57A7C02F70000000000019 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -476,6 +695,17 @@ target = FC9788132485969B00A7906C /* LoopFollow */; targetProxy = DDCC3ADA2DDE1790006F1C10 /* PBXContainerItemProxy */; }; + DD57A7C02F7000000000001B /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = DD57A7C02F70000000000006 /* LoopFollowWatch */; + targetProxy = DD57A7C02F70000000000005 /* PBXContainerItemProxy */; + }; + DD57A7C02F70000000000017 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DD57A7C02F70000000000008 /* LoopFollowWatchWidgets */; + targetProxy = DD57A7C02F70000000000007 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -813,6 +1043,114 @@ }; name = Release; }; + DD57A7C02F7000000000001C /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = LoopFollowWatch/LoopFollowWatch.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowWatch/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).watchkitapp"; + PRODUCT_MODULE_NAME = LoopFollowWatch; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + DD57A7C02F7000000000001D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = LoopFollowWatch/LoopFollowWatch.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowWatch/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).watchkitapp"; + PRODUCT_MODULE_NAME = LoopFollowWatch; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; + DD57A7C02F7000000000001E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = LoopFollowWatchWidgets/LoopFollowWatchWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowWatchWidgets/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).watchkitapp.widgets"; + PRODUCT_MODULE_NAME = LoopFollowWatchWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Debug; + }; + DD57A7C02F7000000000001F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = LoopFollowWatchWidgets/LoopFollowWatchWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = "$(LF_DEVELOPMENT_TEAM)"; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = LoopFollowWatchWidgets/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = "com.$(unique_id).LoopFollow$(app_suffix).watchkitapp.widgets"; + PRODUCT_MODULE_NAME = LoopFollowWatchWidgets; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 10.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -852,6 +1190,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DD57A7C02F70000000000014 /* Build configuration list for PBXNativeTarget "LoopFollowWatch" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DD57A7C02F7000000000001C /* Debug */, + DD57A7C02F7000000000001D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + DD57A7C02F70000000000018 /* Build configuration list for PBXNativeTarget "LoopFollowWatchWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + DD57A7C02F7000000000001E /* Debug */, + DD57A7C02F7000000000001F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index dd67f8801..9a061cc1f 100644 --- a/LoopFollow/Application/AppDelegate.swift +++ b/LoopFollow/Application/AppDelegate.swift @@ -56,6 +56,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { UNUserNotificationCenter.current().delegate = self + WatchSessionManager.shared.activate() + // Only spin up Bluetooth if the user has chosen a BLE-based background // refresh. Initializing BLEManager creates a CBCentralManager, which // triggers the Bluetooth permission prompt β€” deferring it keeps that diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index a9b8815d7..a7089d6dc 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -403,6 +403,7 @@ final class BGChartModel: ObservableObject { guard let self else { return } self.rebuildScheduled = false self.performRebuild() + WatchSessionManager.shared.requestUpdate(reason: "chart") } } diff --git a/LoopFollow/InfoTable/InfoDisplayItem.swift b/LoopFollow/InfoTable/InfoDisplayItem.swift index cf3a3c1b9..8e8da861a 100644 --- a/LoopFollow/InfoTable/InfoDisplayItem.swift +++ b/LoopFollow/InfoTable/InfoDisplayItem.swift @@ -59,15 +59,25 @@ struct InfoColoring: Codable, Equatable { /// the default text color); when enabled, an in-range value reads green, /// mirroring the app's BG text coloring. func color(for value: Double, direction: InfoColorDirection) -> Color? { + switch level(for: value, direction: direction) { + case .urgent: .red + case .warning: .yellow + case .normal: .green + case nil: nil + } + } + + /// The threshold band a value falls in, or `nil` when coloring is off. + func level(for value: Double, direction: InfoColorDirection) -> InfoColorLevel? { guard enabled else { return nil } switch direction { case .above: - if let urgent, value >= urgent { return .red } - if let warning, value >= warning { return .yellow } + if let urgent, value >= urgent { return .urgent } + if let warning, value >= warning { return .warning } case .below: - if let urgent, value <= urgent { return .red } - if let warning, value <= warning { return .yellow } + if let urgent, value <= urgent { return .urgent } + if let warning, value <= warning { return .warning } } - return .green + return .normal } } diff --git a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift index 3010a98c3..bb59255dd 100644 --- a/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift +++ b/LoopFollow/LiveActivity/GlucoseSnapshotBuilder.swift @@ -132,7 +132,7 @@ enum GlucoseSnapshotBuilder { let preferredUnit = PreferredGlucoseUnit.snapshotUnit() let deltaMgdl = provider.deltaMgdl ?? 0.0 - let trend = mapTrend(provider.trendCode) + let trend = GlucoseSnapshot.Trend(code: provider.trendCode) if provider.showRenewalOverlay { LogManager.shared.log(category: .general, message: "[LA] renewal overlay ON") @@ -180,39 +180,4 @@ enum GlucoseSnapshotBuilder { showRenewalOverlay: provider.showRenewalOverlay, ) } - - // MARK: - Trend Mapping - - private static func mapTrend(_ code: String?) -> GlucoseSnapshot.Trend { - guard - let raw = code? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased(), - !raw.isEmpty - else { return .unknown } - - if raw.contains("doubleup") || raw.contains("rapidrise") || raw == "up2" || raw == "upfast" { - return .upFast - } - if raw.contains("fortyfiveup") { - return .upSlight - } - if raw.contains("singleup") || raw == "up" || raw == "up1" || raw == "rising" { - return .up - } - if raw.contains("flat") || raw == "steady" || raw == "none" { - return .flat - } - if raw.contains("doubledown") || raw.contains("rapidfall") || raw == "down2" || raw == "downfast" { - return .downFast - } - if raw.contains("fortyfivedown") { - return .downSlight - } - if raw.contains("singledown") || raw == "down" || raw == "down1" || raw == "falling" { - return .down - } - - return .unknown - } } diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 82987e79b..0d3e61fe5 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -659,6 +659,7 @@ final class LiveActivityManager { DispatchQueue.main.async { self.refreshFromCurrentState(reason: reason) } return } + WatchSessionManager.shared.requestUpdate(reason: reason) refreshCoalescer.request(reason: reason) } @@ -1064,10 +1065,9 @@ final class LiveActivityManager { let snapshotUnchanged = GlucoseSnapshotStore.shared.load() == snapshot let thresholdsChanged = LAAppGroupSettings.thresholdsMgdl() != UnitSettingsStore.shared.effectiveThresholds() - // Store + Watch: always update, independent of LA state. + // Store: always update, independent of LA state. writeThresholds() GlucoseSnapshotStore.shared.save(snapshot) - // WatchConnectivityManager.shared.send(snapshot: snapshot) // LA update: gated on LA being active, snapshot having changed, and activities enabled. if !Storage.shared.laEnabled.value { diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift index 537fa29d2..1392e697e 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift @@ -623,29 +623,3 @@ struct LoopAPNSCarbsView: View { min(max(date, oldestAcceptedDate), latestAcceptedDate) } } - -// APNS Payload structure for carbs -struct LoopAPNSPayload { - enum PayloadType { - case carbs - case bolus - } - - let type: PayloadType - let carbsAmount: Double? - let absorptionTime: Double? - let foodType: String? - let bolusAmount: Double? - let consumedDate: Date? - let otp: String - - init(type: PayloadType, carbsAmount: Double? = nil, absorptionTime: Double? = nil, foodType: String? = nil, bolusAmount: Double? = nil, consumedDate: Date? = nil, otp: String) { - self.type = type - self.carbsAmount = carbsAmount - self.absorptionTime = absorptionTime - self.foodType = foodType - self.bolusAmount = bolusAmount - self.consumedDate = consumedDate - self.otp = otp - } -} diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index 2ca317809..0bd67521a 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -10,15 +10,13 @@ class LoopAPNSService { /// Returns the effective APNs credentials for sending commands to the remote app. /// Same team β†’ use LoopFollow's own key. Different team β†’ use remote-specific key. private func effectiveCredentials() -> (apnsKey: String, keyId: String, teamId: String) { - let lfTeamId = BuildDetails.default.teamID ?? "" - let remoteTeamId = storage.teamId.value ?? "" - let sameTeam = !lfTeamId.isEmpty && !remoteTeamId.isEmpty && lfTeamId == remoteTeamId - - if sameTeam || remoteTeamId.isEmpty { - return (storage.lfApnsKey.value, storage.lfKeyId.value, lfTeamId) - } else { - return (storage.remoteApnsKey.value, storage.remoteKeyId.value, remoteTeamId) - } + let resolved = RemoteSigningKey.resolve( + lfTeamID: BuildDetails.default.teamID ?? "", + remoteTeamID: storage.teamId.value ?? "", + lfKey: (storage.lfApnsKey.value, storage.lfKeyId.value), + remoteKey: (storage.remoteApnsKey.value, storage.remoteKeyId.value) + ) + return (resolved.key, resolved.keyID, resolved.teamID) } enum LoopAPNSError: Error, LocalizedError { @@ -59,34 +57,6 @@ class LoopAPNSService { } } - private func createReturnNotificationInfo() -> ReturnNotificationInfo? { - let loopFollowDeviceToken = Observable.shared.loopFollowDeviceToken.value - guard !loopFollowDeviceToken.isEmpty else { return nil } - - // Get LoopFollow's own Team ID from BuildDetails. - guard let loopFollowTeamID = BuildDetails.default.teamID, !loopFollowTeamID.isEmpty else { - LogManager.shared.log(category: .apns, message: "LoopFollow Team ID not found in BuildDetails.plist. Cannot create return notification info.") - return nil - } - - let lfKeyId = storage.lfKeyId.value - let lfApnsKey = storage.lfApnsKey.value - - guard !lfKeyId.isEmpty, !lfApnsKey.isEmpty else { - LogManager.shared.log(category: .apns, message: "Missing LoopFollow APNS credentials. Configure them in App Settings β†’ APN.") - return nil - } - - return ReturnNotificationInfo( - productionEnvironment: BuildDetails.default.isTestFlightBuild(), - deviceToken: loopFollowDeviceToken, - bundleId: Bundle.main.bundleIdentifier ?? "", - teamId: loopFollowTeamID, - keyId: lfKeyId, - apnsKey: lfApnsKey - ) - } - /// Encrypts return notification info using OTP code private func encryptReturnNotificationInfo(returnInfo: ReturnNotificationInfo, otpCode: String) -> String? { guard let messenger = OTPSecureMessenger(otpCode: otpCode) else { @@ -124,76 +94,9 @@ class LoopAPNSService { private static let returnNotificationRequiredMessage = "Editing or deleting a carb entry needs LoopFollow's own APNS credentials so Loop can confirm the result. Configure them in App Settings β†’ APN." - // MARK: - Command payloads (matching Nightscout's loop.js format) - - /// Fields every Loop remote command carries. - static func commandHeader(otp: String, alert: String, now: Date) -> [String: Any] { - [ - "otp": otp, - "remote-address": "LoopFollow", - "notes": "Sent via LoopFollow APNS", - "entered-by": "LoopFollow", - "sent-at": formatDateForAPNS(now), - "expiration": formatDateForAPNS(now.addingTimeInterval(5 * 60)), - "alert": alert, - ] - } - - static func carbsCommandPayload(_ payload: LoopAPNSPayload, now: Date = Date()) -> [String: Any] { - let carbsAmount = payload.carbsAmount ?? 0.0 - let absorptionTime = payload.absorptionTime ?? 3.0 - var fields = commandHeader( - otp: payload.otp, - alert: "Remote Carbs Entry: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTime)) hours", - now: now - ) - fields["carbs-entry"] = carbsAmount - fields["absorption-time"] = absorptionTime - fields["start-time"] = formatDateForAPNS(payload.consumedDate ?? now) - return fields - } - - static func bolusCommandPayload(_ payload: LoopAPNSPayload, now: Date = Date()) -> [String: Any] { - let bolusAmount = payload.bolusAmount ?? 0.0 - var fields = commandHeader(otp: payload.otp, alert: "Remote Bolus Entry: \(String(format: "%.2f", bolusAmount)) U", now: now) - fields["bolus-entry"] = bolusAmount - return fields - } - - static func carbsDeleteCommandPayload(syncIdentifier: String, otp: String, now: Date = Date()) -> [String: Any] { - var fields = commandHeader(otp: otp, alert: "Remote Carbs Delete", now: now) - fields["carbs-delete"] = syncIdentifier - return fields - } - - /// Keys are prefixed so an unpatched Loop rejects the command. Amount, absorption time and start time are always sent; food type only when set. - static func carbsEditCommandPayload( - syncIdentifier: String, - carbsAmount: Double, - absorptionTimeHours: Double, - foodType: String?, - consumedDate: Date, - otp: String, - now: Date = Date() - ) -> [String: Any] { - var fields = commandHeader( - otp: otp, - alert: "Remote Carbs Edit: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTimeHours)) hours", - now: now - ) - fields["carbs-edit"] = syncIdentifier - fields["carbs-edit-entry"] = carbsAmount - fields["carbs-edit-absorption-time"] = absorptionTimeHours - fields["carbs-edit-start-time"] = formatDateForAPNS(consumedDate) - if let foodType, !foodType.isEmpty { - fields["carbs-edit-food-type"] = foodType - } - return fields - } - /// The return-notification block encrypted with the OTP, when LoopFollow's own credentials are configured. private func encryptedReturnNotification(otp: String) -> String? { - guard let returnInfo = createReturnNotificationInfo() else { + guard let returnInfo = ReturnNotificationInfo.loopFollow() else { LogManager.shared.log(category: .apns, message: "Return notification info unavailable; Loop cannot confirm this command") return nil } @@ -233,7 +136,7 @@ class LoopAPNSService { /// Deletes the Loop carb entry with this `syncIdentifier`. Requires the Loop remote carb edit patch. func sendCarbsDelete(syncIdentifier: String, otp: String, completion: @escaping (Bool, String?) -> Void) { LogManager.shared.log(category: .apns, message: "Sending carbs delete for syncIdentifier=\(LogRedactor.tail(syncIdentifier))") - sendConfirmedCommand(Self.carbsDeleteCommandPayload(syncIdentifier: syncIdentifier, otp: otp), otp: otp, completion: completion) + sendConfirmedCommand(LoopAPNSCommands.carbsDeleteCommandPayload(syncIdentifier: syncIdentifier, otp: otp), otp: otp, completion: completion) } /// Replaces the Loop carb entry with this `syncIdentifier`. Requires the Loop remote carb edit patch. @@ -247,7 +150,7 @@ class LoopAPNSService { completion: @escaping (Bool, String?) -> Void ) { LogManager.shared.log(category: .apns, message: "Sending carbs edit for syncIdentifier=\(LogRedactor.tail(syncIdentifier)): \(String(format: "%.1f", carbsAmount))g, absorption \(String(format: "%.1f", absorptionTimeHours))h") - let payload = Self.carbsEditCommandPayload( + let payload = LoopAPNSCommands.carbsEditCommandPayload( syncIdentifier: syncIdentifier, carbsAmount: carbsAmount, absorptionTimeHours: absorptionTimeHours, @@ -263,7 +166,7 @@ class LoopAPNSService { /// - payload: The carbs payload to send /// - completion: Completion handler with success status and error message func sendCarbsViaAPNS(payload: LoopAPNSPayload, completion: @escaping (Bool, String?) -> Void) { - var finalPayload = Self.carbsCommandPayload(payload) + var finalPayload = LoopAPNSCommands.carbsCommandPayload(payload) if let encryptedReturnInfo = encryptedReturnNotification(otp: payload.otp) { finalPayload["encrypted_return_notification"] = encryptedReturnInfo } @@ -276,7 +179,7 @@ class LoopAPNSService { /// - payload: The bolus payload to send /// - completion: Completion handler with success status and error message func sendBolusViaAPNS(payload: LoopAPNSPayload, completion: @escaping (Bool, String?) -> Void) { - var finalPayload = Self.bolusCommandPayload(payload) + var finalPayload = LoopAPNSCommands.bolusCommandPayload(payload) if let encryptedReturnInfo = encryptedReturnNotification(otp: payload.otp) { finalPayload["encrypted_return_notification"] = encryptedReturnInfo } @@ -359,145 +262,37 @@ class LoopAPNSService { return } - // Create JWT token for APNS authentication - guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: teamId, apnsKey: apnsKey) else { - let errorMessage = "Failed to generate JWT, please check that the APNS Key ID, APNS Key and Team ID are correct." - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - return - } - - // Determine APNS environment - let isProduction = storage.productionEnvironment.value - let apnsURL = APNSEnvironment.baseURL(production: isProduction) - guard let requestURL = URL(string: "\(apnsURL)/3/device/\(deviceToken)") else { - let errorMessage = "Failed to construct APNs URL" - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - return - } - - var request = URLRequest(url: requestURL) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "content-type") - request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") - request.setValue(bundleIdentifier, forHTTPHeaderField: "apns-topic") - request.setValue("alert", forHTTPHeaderField: "apns-push-type") - request.setValue("10", forHTTPHeaderField: "apns-priority") // High priority - // Validate bundle identifier format if !bundleIdentifier.contains(".") { LogManager.shared.log(category: .apns, message: "Warning: Bundle identifier may be in wrong format: \(bundleIdentifier)") } - // Create the proper APNS payload structure (matching @parse/node-apn format) - var apnsPayload: [String: Any] = [ - "aps": [ - "alert": payload["alert"] as? String ?? "", - "content-available": 1, - "interruption-level": "time-sensitive", - ], - ] - - // Add all the custom payload fields (excluding APNS-specific fields) - for (key, value) in payload { - if key != "alert", key != "content-available", key != "interruption-level" { - apnsPayload[key] = value - } - } - - // Remove nil values to clean up the payload - let cleanPayload = apnsPayload.compactMapValues { $0 } - - // Log if encrypted_return_notification is in the payload - if cleanPayload["encrypted_return_notification"] != nil { + if payload["encrypted_return_notification"] != nil { LogManager.shared.log(category: .apns, message: "encrypted_return_notification is present in final APNS payload") } else { - LogManager.shared.log(category: .apns, message: "WARNING: encrypted_return_notification is NOT in final APNS payload. Available keys: \(Array(cleanPayload.keys).joined(separator: ", "))") + LogManager.shared.log(category: .apns, message: "WARNING: encrypted_return_notification is NOT in final APNS payload. Available keys: \(Array(payload.keys).joined(separator: ", "))") } + let body: Data do { - let jsonData = try JSONSerialization.data(withJSONObject: cleanPayload) - - request.httpBody = jsonData - - let task = URLSession.shared.dataTask(with: request) { data, response, error in - if let error = error { - let errorMessage = "Failed to send push notification: \(error.localizedDescription)" - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - return - } - - if let httpResponse = response as? HTTPURLResponse { - var responseBodyMessage = "" - if let data = data, let responseBody = String(data: data, encoding: .utf8) { - if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let reason = json["reason"] as? String - { - responseBodyMessage = reason - } - } - - switch httpResponse.statusCode { - case 200: - LogManager.shared.log(category: .apns, message: "APNS notification sent successfully") - completion(true, nil) - case 400: - let errorMessage = "Bad request. The request was invalid or malformed. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 400: \(responseBodyMessage) - Check device token and environment settings") - completion(false, errorMessage) - case 403: - JWTManager.shared.invalidateCache() - let errorMessage = "Authentication error. Check your certificate or authentication token. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 403: \(responseBodyMessage) - Check APNS key permissions for bundle ID") - completion(false, errorMessage) - case 404: - let errorMessage = "Invalid request: The :path value was incorrect. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 404: \(responseBodyMessage)") - completion(false, errorMessage) - case 405: - let errorMessage = "Invalid request: Only POST requests are supported. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 405: \(responseBodyMessage)") - completion(false, errorMessage) - case 410: - let errorMessage = "The device token is no longer active for the topic. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 410: Device token is invalid or expired") - completion(false, errorMessage) - case 413: - let errorMessage = "Payload too large. The notification payload exceeded the size limit. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 413: \(responseBodyMessage)") - completion(false, errorMessage) - case 429: - let errorMessage = "Too many requests. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 429: Rate limited - wait before retrying") - completion(false, errorMessage) - case 500: - let errorMessage = "Internal server error at APNs. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 500: \(responseBodyMessage)") - completion(false, errorMessage) - case 503: - let errorMessage = "Service unavailable. The server is temporarily unavailable. Try again later. \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error 503: \(responseBodyMessage)") - completion(false, errorMessage) - default: - let errorMessage = "Unexpected status code: \(httpResponse.statusCode). \(responseBodyMessage)" - LogManager.shared.log(category: .apns, message: "APNS error \(httpResponse.statusCode): \(responseBodyMessage)") - completion(false, errorMessage) - } - } else { - let errorMessage = "Failed to get a valid HTTP response." - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - } - } - task.resume() - + body = try LoopAPNSCommands.apnsBody(payload) } catch { let errorMessage = "Failed to serialize APNS payload: \(error.localizedDescription)" LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) + return } + + APNSDelivery.send( + body: body, + deviceToken: deviceToken, + topic: bundleIdentifier, + production: storage.productionEnvironment.value, + keyId: keyId, + teamId: teamId, + apnsKey: apnsKey, + completion: completion + ) } /// Validates and fixes APNS key format if needed @@ -648,15 +443,6 @@ class LoopAPNSService { // MARK: - Date Formatting Helper - /// Creates a properly formatted ISO8601 date string with milliseconds (matching Nightscout's format) - /// - Parameter date: The date to format - /// - Returns: Formatted date string like "2022-12-24T21:34:02.090Z" - private static func formatDateForAPNS(_ date: Date) -> String { - let dateFormatter = ISO8601DateFormatter() - dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return dateFormatter.string(from: date) - } - // MARK: - Override Methods func sendOverrideNotification(presetName: String, duration: TimeInterval? = nil, completion: @escaping (Bool, String?) -> Void) { @@ -676,47 +462,7 @@ class LoopAPNSService { return } - // Create APNS notification payload (matching Loop's expected format) - let now = Date() - let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now - - // Create alert text (matching Nightscout's format) - var alertText = "\(presetName) Temporary Override" - if let duration = duration, duration > 0 { - let hours = Int(duration / 3600) - let minutes = Int((duration.truncatingRemainder(dividingBy: 3600)) / 60) - if hours > 0 { - alertText += " (\(hours)h \(minutes)m)" - } else { - alertText += " (\(minutes)m)" - } - } - - var payload: [String: Any] = [ - "override-name": presetName, - "remote-address": "LoopFollow", - "entered-by": "LoopFollow", - "sent-at": Self.formatDateForAPNS(now), - "expiration": Self.formatDateForAPNS(expiration), - "alert": alertText, - ] - - if let duration = duration { - payload["override-duration-minutes"] = Int(duration / 60) - } - - // For override commands, we can include return notification info unencrypted - // since override commands don't require OTP validation in Loop - if let returnInfo = createReturnNotificationInfo() { - payload["return_notification"] = [ - "production_environment": returnInfo.productionEnvironment, - "device_token": returnInfo.deviceToken, - "bundle_id": returnInfo.bundleId, - "team_id": returnInfo.teamId, - "key_id": returnInfo.keyId, - "apns_key": returnInfo.apnsKey, - ] - } + let payload = LoopAPNSCommands.overridePayload(presetName: presetName, duration: duration, returnInfo: ReturnNotificationInfo.loopFollow()) // Send the notification using the existing APNS infrastructure let creds = effectiveCredentials() @@ -748,31 +494,7 @@ class LoopAPNSService { return } - // Create APNS notification payload (matching Loop's expected format) - let now = Date() - let expiration = Date(timeIntervalSinceNow: 5 * 60) // 5 minutes from now - - var payload: [String: Any] = [ - "cancel-temporary-override": "true", - "remote-address": "LoopFollow", - "entered-by": "LoopFollow", - "sent-at": Self.formatDateForAPNS(now), - "expiration": Self.formatDateForAPNS(expiration), - "alert": "Cancel Temporary Override", - ] - - // For override commands, we can include return notification info unencrypted - // since override commands don't require OTP validation in Loop - if let returnInfo = createReturnNotificationInfo() { - payload["return_notification"] = [ - "production_environment": returnInfo.productionEnvironment, - "device_token": returnInfo.deviceToken, - "bundle_id": returnInfo.bundleId, - "team_id": returnInfo.teamId, - "key_id": returnInfo.keyId, - "apns_key": returnInfo.apnsKey, - ] - } + let payload = LoopAPNSCommands.cancelOverridePayload(returnInfo: ReturnNotificationInfo.loopFollow()) // Send the notification using the existing APNS infrastructure let creds = effectiveCredentials() diff --git a/LoopFollow/Remote/ReturnNotificationInfo+LoopFollow.swift b/LoopFollow/Remote/ReturnNotificationInfo+LoopFollow.swift new file mode 100644 index 000000000..34c61d15a --- /dev/null +++ b/LoopFollow/Remote/ReturnNotificationInfo+LoopFollow.swift @@ -0,0 +1,35 @@ +// LoopFollow +// ReturnNotificationInfo+LoopFollow.swift + +import Foundation + +extension ReturnNotificationInfo { + /// How Loop or Trio reaches this LoopFollow app to confirm a command, or nil + /// when LoopFollow's own APNs credentials or device token are missing. + static func loopFollow() -> ReturnNotificationInfo? { + let loopFollowDeviceToken = Observable.shared.loopFollowDeviceToken.value + guard !loopFollowDeviceToken.isEmpty else { return nil } + + guard let loopFollowTeamID = BuildDetails.default.teamID, !loopFollowTeamID.isEmpty else { + LogManager.shared.log(category: .apns, message: "LoopFollow Team ID not found in BuildDetails.plist. Cannot create return notification info.") + return nil + } + + let lfKeyId = Storage.shared.lfKeyId.value + let lfApnsKey = Storage.shared.lfApnsKey.value + + guard !lfKeyId.isEmpty, !lfApnsKey.isEmpty else { + LogManager.shared.log(category: .apns, message: "Missing LoopFollow APNS credentials. Configure them in App Settings β†’ APN.") + return nil + } + + return ReturnNotificationInfo( + productionEnvironment: BuildDetails.default.isTestFlightBuild(), + deviceToken: loopFollowDeviceToken, + bundleId: Bundle.main.bundleIdentifier ?? "", + teamId: loopFollowTeamID, + keyId: lfKeyId, + apnsKey: lfApnsKey + ) + } +} diff --git a/LoopFollow/Remote/Settings/RemoteSettingsView.swift b/LoopFollow/Remote/Settings/RemoteSettingsView.swift index c4f8a5c92..c3146108c 100644 --- a/LoopFollow/Remote/Settings/RemoteSettingsView.swift +++ b/LoopFollow/Remote/Settings/RemoteSettingsView.swift @@ -9,6 +9,7 @@ struct RemoteSettingsView: View { @ObservedObject var viewModel: RemoteSettingsViewModel @ObservedObject private var device = Storage.shared.device @ObservedObject var bolusIncrement = Storage.shared.bolusIncrement + @ObservedObject private var watchRemoteCommands = Storage.shared.watchRemoteCommands @State private var showAlert: Bool = false @State private var alertType: AlertType? = nil @@ -93,6 +94,13 @@ struct RemoteSettingsView: View { if viewModel.remoteType == .trc || viewModel.remoteType == .loopAPNS { guardrailsSection + + Section( + header: Text("Apple Watch"), + footer: Text("Commands from the LoopFollow watch app are sent by this iPhone, with the same guardrails as here. The iPhone must be within reach of the watch.") + ) { + Toggle("Allow Commands from Apple Watch", isOn: $watchRemoteCommands.value) + } } if !Storage.shared.bolusIncrementDetected.value { diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index 236161b06..7a6513a15 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -21,49 +21,15 @@ class PushNotificationManager { user = Storage.shared.user.value bundleId = Storage.shared.bundleId.value - let lfTeamId = BuildDetails.default.teamID ?? "" - let remoteTeamId = Storage.shared.teamId.value ?? "" - let sameTeam = !lfTeamId.isEmpty && !remoteTeamId.isEmpty && lfTeamId == remoteTeamId - - if sameTeam || remoteTeamId.isEmpty { - apnsKey = Storage.shared.lfApnsKey.value - keyId = Storage.shared.lfKeyId.value - teamId = lfTeamId - } else { - apnsKey = Storage.shared.remoteApnsKey.value - keyId = Storage.shared.remoteKeyId.value - teamId = remoteTeamId - } - } - - private func createReturnNotificationInfo() -> CommandPayload.ReturnNotificationInfo? { - let loopFollowDeviceToken = Observable.shared.loopFollowDeviceToken.value - - guard !loopFollowDeviceToken.isEmpty else { - return nil - } - - guard let loopFollowTeamID = BuildDetails.default.teamID, !loopFollowTeamID.isEmpty else { - LogManager.shared.log(category: .apns, message: "LoopFollow Team ID not found in BuildDetails.plist. Cannot create return notification info.") - return nil - } - - let lfKeyId = Storage.shared.lfKeyId.value - let lfApnsKey = Storage.shared.lfApnsKey.value - - guard !lfKeyId.isEmpty, !lfApnsKey.isEmpty else { - LogManager.shared.log(category: .apns, message: "Missing LoopFollow APNS credentials. Configure them in App Settings β†’ APN.") - return nil - } - - return CommandPayload.ReturnNotificationInfo( - productionEnvironment: BuildDetails.default.isTestFlightBuild(), - deviceToken: loopFollowDeviceToken, - bundleId: Bundle.main.bundleIdentifier ?? "", - teamId: loopFollowTeamID, - keyId: lfKeyId, - apnsKey: lfApnsKey + let resolved = RemoteSigningKey.resolve( + lfTeamID: BuildDetails.default.teamID ?? "", + remoteTeamID: Storage.shared.teamId.value ?? "", + lfKey: (Storage.shared.lfApnsKey.value, Storage.shared.lfKeyId.value), + remoteKey: (Storage.shared.remoteApnsKey.value, Storage.shared.remoteKeyId.value) ) + apnsKey = resolved.key + keyId = resolved.keyID + teamId = resolved.teamID } func sendOverridePushNotification(override: ProfileManager.TrioOverride, completion: @escaping (Bool, String?) -> Void) { @@ -72,7 +38,7 @@ class PushNotificationManager { commandType: .startOverride, timestamp: Date().timeIntervalSince1970, overrideName: override.name, - returnNotification: createReturnNotificationInfo() + returnNotification: ReturnNotificationInfo.loopFollow() ) sendEncryptedCommand(payload: payload, completion: completion) } @@ -83,7 +49,7 @@ class PushNotificationManager { commandType: .cancelOverride, timestamp: Date().timeIntervalSince1970, overrideName: nil, - returnNotification: createReturnNotificationInfo() + returnNotification: ReturnNotificationInfo.loopFollow() ) sendEncryptedCommand(payload: payload, completion: completion) } @@ -95,7 +61,7 @@ class PushNotificationManager { commandType: .bolus, timestamp: Date().timeIntervalSince1970, bolusAmount: bolusAmountDecimal, - returnNotification: createReturnNotificationInfo() + returnNotification: ReturnNotificationInfo.loopFollow() ) sendEncryptedCommand(payload: payload, completion: completion) } @@ -109,7 +75,7 @@ class PushNotificationManager { timestamp: Date().timeIntervalSince1970, target: targetValue, duration: durationValue, - returnNotification: createReturnNotificationInfo() + returnNotification: ReturnNotificationInfo.loopFollow() ) sendEncryptedCommand(payload: payload, completion: completion) } @@ -119,7 +85,7 @@ class PushNotificationManager { user: user, commandType: .cancelTempTarget, timestamp: Date().timeIntervalSince1970, - returnNotification: createReturnNotificationInfo() + returnNotification: ReturnNotificationInfo.loopFollow() ) sendEncryptedCommand(payload: payload, completion: completion) } @@ -159,14 +125,14 @@ class PushNotificationManager { protein: proteinValue, fat: fatValue, scheduledTime: scheduledTimeInterval, - returnNotification: createReturnNotificationInfo() + returnNotification: ReturnNotificationInfo.loopFollow() ) sendEncryptedCommand(payload: payload, completion: completion) } /// Trio confirms edit/delete through a return notification, so LoopFollow's own APNs credentials are required. func sendDeleteMealPushNotification(mealID: String, commandID: String, completion: @escaping (Bool, String?) -> Void) { - guard let returnNotification = createReturnNotificationInfo() else { + guard let returnNotification = ReturnNotificationInfo.loopFollow() else { completion(false, Self.returnNotificationRequiredMessage) return } @@ -194,7 +160,7 @@ class PushNotificationManager { completion(false, "No nutrient data provided. At least one of carbs, fat, or protein must be greater than 0.") return } - guard let returnNotification = createReturnNotificationInfo() else { + guard let returnNotification = ReturnNotificationInfo.loopFollow() else { completion(false, Self.returnNotificationRequiredMessage) return } @@ -281,20 +247,6 @@ class PushNotificationManager { return } - guard let url = constructAPNsURL() else { - let errorMessage = "Failed to construct APNs URL" - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - return - } - - guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: teamId, apnsKey: apnsKey) else { - let errorMessage = "Failed to generate JWT, please check that the token is correct." - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - return - } - do { guard let messenger = SecureMessenger(sharedSecret: sharedSecret) else { let errorMessage = "Failed to initialize security module. Check shared secret." @@ -306,78 +258,22 @@ class PushNotificationManager { let encryptedDataString = try messenger.encrypt(payload) let finalMessage = EncryptedPushMessage(encryptedData: encryptedDataString, commandType: payload.commandType) - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") - request.setValue("application/json", forHTTPHeaderField: "content-type") - request.setValue("10", forHTTPHeaderField: "apns-priority") - request.setValue("600", forHTTPHeaderField: "apns-expiration") - request.setValue(bundleId, forHTTPHeaderField: "apns-topic") - request.setValue("alert", forHTTPHeaderField: "apns-push-type") - request.setValue(payload.apnsCollapseID, forHTTPHeaderField: "apns-collapse-id") - - request.httpBody = try JSONEncoder().encode(finalMessage) - - let task = URLSession.shared.dataTask(with: request) { data, response, error in - if let error = error { - let errorMessage = "Failed to send push notification: \(error.localizedDescription)" - LogManager.shared.log(category: .apns, message: errorMessage) - completion(false, errorMessage) - return - } - - if let httpResponse = response as? HTTPURLResponse { - LogManager.shared.log(category: .apns, message: "Push notification sent. Status code: \(httpResponse.statusCode)", isDebug: true) - - var responseBodyMessage = "" - if let data = data, let responseBody = String(data: data, encoding: .utf8) { - LogManager.shared.log(category: .apns, message: "Response body: \(responseBody)", isDebug: true) - if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let reason = json["reason"] as? String - { - responseBodyMessage = reason - } - } - - switch httpResponse.statusCode { - case 200: - completion(true, nil) - case 400: - completion(false, "Bad request. The request was invalid or malformed. \(responseBodyMessage)") - case 403: - JWTManager.shared.invalidateCache() - completion(false, "Authentication error. Check your certificate or authentication token. \(responseBodyMessage)") - case 404: - completion(false, "Invalid request: The :path value was incorrect. \(responseBodyMessage)") - case 405: - completion(false, "Invalid request: Only POST requests are supported. \(responseBodyMessage)") - case 410: - completion(false, "The device token is no longer active for the topic. \(responseBodyMessage)") - case 413: - completion(false, "Payload too large. The notification payload exceeded the size limit. \(responseBodyMessage)") - case 429: - completion(false, "Too many requests. \(responseBodyMessage)") - case 500: - completion(false, "Internal server error at APNs. \(responseBodyMessage)") - case 503: - completion(false, "Service unavailable. The server is temporarily unavailable. Try again later. \(responseBodyMessage)") - default: - completion(false, "Unexpected status code: \(httpResponse.statusCode). \(responseBodyMessage)") - } - } else { - completion(false, "Failed to get a valid HTTP response.") - } - } - task.resume() - + try APNSDelivery.send( + body: JSONEncoder().encode(finalMessage), + deviceToken: deviceToken, + topic: bundleId, + production: productionEnvironment, + keyId: keyId, + teamId: teamId, + apnsKey: apnsKey, + expiration: "600", + collapseID: payload.apnsCollapseID, + completion: completion + ) } catch { let errorMessage = "Failed to encode or encrypt push message: \(error.localizedDescription)" LogManager.shared.log(category: .apns, message: errorMessage) completion(false, errorMessage) } } - - private func constructAPNsURL() -> URL? { - URL(string: "\(APNSEnvironment.baseURL(production: productionEnvironment))/3/device/\(deviceToken)") - } } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 1432352b7..ddb47e7b5 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -34,6 +34,8 @@ class Storage { var mealWithBolus = StorageValue(key: "mealWithBolus", defaultValue: false) var mealWithFatProtein = StorageValue(key: "mealWithFatProtein", defaultValue: false) + /// Lets the Apple Watch app send remote commands through this phone. + var watchRemoteCommands = StorageValue(key: "watchRemoteCommands", defaultValue: true) // TODO: This flag can be deleted in March 2027. Check the commit for other places to cleanup. var hasSeenFatProteinOrderChange = StorageValue(key: "hasSeenFatProteinOrderChange", defaultValue: true) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index d788abfeb..40c8659be 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -153,6 +153,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { view.backgroundColor = .systemBackground infoManager = InfoManager() + WatchSessionManager.shared.observe(infoManager, stats: statsDisplayModel) let mainView = MainHomeView( chartModel: chartModel, diff --git a/LoopFollow/Watch/WatchCommandHandler.swift b/LoopFollow/Watch/WatchCommandHandler.swift new file mode 100644 index 000000000..238e1d964 --- /dev/null +++ b/LoopFollow/Watch/WatchCommandHandler.swift @@ -0,0 +1,213 @@ +// LoopFollow +// WatchCommandHandler.swift + +import Foundation +import HealthKit + +/// Executes commands from the watch with the same checks and send paths as the +/// phone's Remote screens. Runs on the main queue. +final class WatchCommandHandler { + static let shared = WatchCommandHandler() + + private var handledIDs: [UUID] = [] + + private init() {} + + /// The guardrails from the phone's Remote settings. + static func limits() -> WatchCommandValidator.Limits { + WatchCommandValidator.Limits( + maxBolus: Storage.shared.maxBolus.value.doubleValue(for: .internationalUnit()), + bolusIncrement: Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit()), + maxCarbs: Storage.shared.maxCarbs.value.doubleValue(for: .gram()), + maxFat: Storage.shared.maxFat.value.doubleValue(for: .gram()), + maxProtein: Storage.shared.maxProtein.value.doubleValue(for: .gram()), + mealWithBolus: Storage.shared.mealWithBolus.value, + mealWithFatProtein: Storage.shared.mealWithFatProtein.value + ) + } + + func handle(_ command: WatchCommand, reply: @escaping (WatchCommandReply) -> Void) { + let finish: (Bool, String) -> Void = { success, message in + LogManager.shared.log(category: .apns, message: "Watch command \(command.action.name) finished: success=\(success) \(message)") + reply(WatchCommandReply(success: success, message: message)) + } + + guard !handledIDs.contains(command.id) else { + finish(false, "This command was already handled.") + return + } + handledIDs.append(command.id) + if handledIDs.count > 50 { handledIDs.removeFirst() } + + guard abs(Date().timeIntervalSince(command.createdAt)) <= WatchCommand.maxAge else { + finish(false, "The command arrived too late and was not sent. Try again.") + return + } + + LogManager.shared.log(category: .apns, message: "Watch command received: \(command.action.name)") + + if case .snoozeAlarm = command.action { + guard Observable.shared.currentAlarm.value != nil else { + finish(false, "No alarm is active.") + return + } + AlarmManager.shared.performSnooze() + finish(true, "Alarm snoozed.") + return + } + + guard Storage.shared.watchRemoteCommands.value else { + finish(false, "Commands from Apple Watch are turned off in LoopFollow's Remote settings.") + return + } + guard let kind = WatchStateBuilder.remoteKind() else { + finish(false, "Remote control is not set up in LoopFollow.") + return + } + + switch WatchCommandValidator.validate(command.action, kind: kind, limits: Self.limits()) { + case let .failure(error): + finish(false, error.message) + case let .success(action): + switch kind { + case .loop: sendLoop(action, finish: finish) + case .trio: sendTrio(action, finish: finish) + } + } + } + + // MARK: - Loop + + private func sendLoop(_ action: WatchCommand.Action, finish: @escaping (Bool, String) -> Void) { + let service = LoopAPNSService() + let sent: (Bool, String?) -> Void = { success, error in + DispatchQueue.main.async { + finish(success, success ? RemoteCommandMessage.sent : (error ?? "Failed to send the command.")) + } + } + + switch action { + case let .bolus(units): + guard service.validateSetup() else { return finish(false, loopSetupIncomplete) } + guard let otp = loopOTP(finish: finish) else { return } + service.sendBolusViaAPNS(payload: LoopAPNSPayload(type: .bolus, bolusAmount: units, otp: otp)) { success, error in + DispatchQueue.main.async { + if success { + TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) + QuickPickBolusesManager.shared.recordBolus(units: units) + } + } + sent(success, error) + } + + case let .meal(carbs, _, _, _, consumedAt): + guard service.validateSetup() else { return finish(false, loopSetupIncomplete) } + guard let otp = loopOTP(finish: finish) else { return } + let payload = LoopAPNSPayload( + type: .carbs, + carbsAmount: carbs, + absorptionTime: WatchCommandValidator.loopAbsorptionHours, + consumedDate: consumedAt.dateUsingCurrentSeconds(), + otp: otp + ) + service.sendCarbsViaAPNS(payload: payload) { success, error in + DispatchQueue.main.async { + if success { + TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) + QuickPickMealsManager.shared.recordMeal(carbs: carbs) + } + } + sent(success, error) + } + + case let .startOverride(presetID): + guard let preset = ProfileManager.shared.loopOverrides.first(where: { $0.name == presetID }) else { + return finish(false, "The override preset was not found.") + } + let duration = TimeInterval(preset.duration ?? 0) + service.sendOverrideNotification(presetName: preset.name, duration: duration > 0 ? duration : nil, completion: sent) + + case .cancelOverride: + service.sendCancelOverrideNotification(completion: sent) + + case .startTempTarget, .cancelTempTarget, .snoozeAlarm: + finish(false, "This command is not available for Loop.") + } + } + + private var loopSetupIncomplete: String { + "Loop APNS setup is incomplete. Configure it in LoopFollow's Remote settings." + } + + /// Mirrors the one-time code checks in the phone's Loop bolus and carbs screens. + private func loopOTP(finish: (Bool, String) -> Void) -> String? { + let url = Storage.shared.loopAPNSQrCodeURL.value + guard let otp = TOTPGenerator.extractOTPFromURL(url) else { + finish(false, "Invalid QR code URL. Re-scan the QR code in LoopFollow's Remote settings.") + return nil + } + guard !TOTPService.shared.isTOTPBlocked(qrCodeURL: url) else { + finish(false, "The current one-time code was already used. Wait for the next code (up to 30 seconds) and try again.") + return nil + } + return otp + } + + // MARK: - Trio + + private func sendTrio(_ action: WatchCommand.Action, finish: @escaping (Bool, String) -> Void) { + let manager = PushNotificationManager() + let sent: (Bool, String?) -> Void = { success, error in + DispatchQueue.main.async { + finish(success, success ? RemoteCommandMessage.sent : (error ?? "Failed to send the command.")) + } + } + + switch action { + case let .bolus(units): + manager.sendBolusPushNotification(bolusAmount: HKQuantity(unit: .internationalUnit(), doubleValue: units)) { success, error in + if success { + DispatchQueue.main.async { QuickPickBolusesManager.shared.recordBolus(units: units) } + } + sent(success, error) + } + + case let .meal(carbs, fat, protein, bolus, consumedAt): + manager.sendMealPushNotification( + carbs: HKQuantity(unit: .gram(), doubleValue: carbs), + protein: HKQuantity(unit: .gram(), doubleValue: protein), + fat: HKQuantity(unit: .gram(), doubleValue: fat), + bolusAmount: HKQuantity(unit: .internationalUnit(), doubleValue: bolus), + scheduledTime: WatchCommand.trioScheduledTime(consumedAt) + ) { success, error in + if success, carbs > 0 { + DispatchQueue.main.async { + QuickPickMealsManager.shared.recordMeal(carbs: carbs, fat: fat, protein: protein, bolus: bolus) + } + } + sent(success, error) + } + + case let .startOverride(presetID): + guard let override = ProfileManager.shared.trioOverrides.first(where: { $0.name == presetID }) else { + return finish(false, "The override preset was not found.") + } + manager.sendOverridePushNotification(override: override, completion: sent) + + case .cancelOverride: + manager.sendCancelOverridePushNotification(completion: sent) + + case let .startTempTarget(presetID): + guard let preset = TempTargetPresetManager.shared.presets.first(where: { $0.id.uuidString == presetID }) else { + return finish(false, "The temp target preset was not found.") + } + manager.sendTempTargetPushNotification(target: preset.target, duration: preset.duration, completion: sent) + + case .cancelTempTarget: + manager.sendCancelTempTargetPushNotification(completion: sent) + + case .snoozeAlarm: + finish(false, "Unknown request.") + } + } +} diff --git a/LoopFollow/Watch/WatchCredentialsBuilder.swift b/LoopFollow/Watch/WatchCredentialsBuilder.swift new file mode 100644 index 000000000..52ef3965d --- /dev/null +++ b/LoopFollow/Watch/WatchCredentialsBuilder.swift @@ -0,0 +1,41 @@ +// LoopFollow +// WatchCredentialsBuilder.swift + +import Foundation + +/// Collects what the watch needs to fetch data and send commands on its own. +enum WatchCredentialsBuilder { + static func build() -> WatchCredentials { + WatchCredentials( + nightscoutURL: Storage.shared.url.value, + nightscoutToken: Storage.shared.token.value, + dexcomUsername: Storage.shared.shareUserName.value, + dexcomPassword: Storage.shared.sharePassword.value, + dexcomServer: Storage.shared.shareServer.value, + remote: remote() + ) + } + + private static func remote() -> WatchCredentials.Remote? { + guard Storage.shared.watchRemoteCommands.value, let kind = WatchStateBuilder.remoteKind() else { return nil } + let signing = RemoteSigningKey.resolve( + lfTeamID: BuildDetails.default.teamID ?? "", + remoteTeamID: Storage.shared.teamId.value ?? "", + lfKey: (Storage.shared.lfApnsKey.value, Storage.shared.lfKeyId.value), + remoteKey: (Storage.shared.remoteApnsKey.value, Storage.shared.remoteKeyId.value) + ) + return WatchCredentials.Remote( + kind: kind, + deviceToken: Storage.shared.deviceToken.value, + bundleID: Storage.shared.bundleId.value, + production: Storage.shared.productionEnvironment.value, + apnsKey: signing.key, + keyID: signing.keyID, + teamID: signing.teamID, + user: Storage.shared.user.value, + sharedSecret: Storage.shared.sharedSecret.value, + loopQRCodeURL: Storage.shared.loopAPNSQrCodeURL.value, + returnNotification: ReturnNotificationInfo.loopFollow() + ) + } +} diff --git a/LoopFollow/Watch/WatchSessionManager.swift b/LoopFollow/Watch/WatchSessionManager.swift new file mode 100644 index 000000000..08a9aacb1 --- /dev/null +++ b/LoopFollow/Watch/WatchSessionManager.swift @@ -0,0 +1,210 @@ +// LoopFollow +// WatchSessionManager.swift + +import Combine +import Foundation +import WatchConnectivity + +/// Phone side of the Apple Watch link. Publishes `WatchState` as the +/// application context and relays commands from the watch to +/// `WatchCommandHandler`. +final class WatchSessionManager: NSObject { + static let shared = WatchSessionManager() + + /// Complication pushes are rationed by watchOS (about 50 per day), so they + /// are only used when a new reading arrives and this long has passed. + private let complicationTransferInterval: TimeInterval = 15 * 60 + + private var lastSentState: WatchState? + private var lastSentCredentials: WatchCredentials? + private var lastComplicationTransfer: Date = .distantPast + private var cancellables = Set() + private lazy var coalescer = RefreshCoalescer(delay: 2) { [weak self] reason in + self?.publish(reason: reason) + } + + private var session: WCSession? { + WCSession.isSupported() ? WCSession.default : nil + } + + func activate() { + guard let session else { return } + session.delegate = self + session.activate() + observeSettings() + } + + /// Schedules a state update. Safe to call often; requests are coalesced. + func requestUpdate(reason: String) { + guard Thread.isMainThread else { + DispatchQueue.main.async { self.requestUpdate(reason: reason) } + return + } + guard let session, session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { return } + coalescer.request(reason: reason) + } + + private func observeSettings() { + let triggers: [AnyPublisher] = [ + Observable.shared.currentAlarm.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.watchRemoteCommands.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.remoteType.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.colorBGText.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.units.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.mealWithBolus.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.mealWithFatProtein.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.infoDisplayItems.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.graphTimeZoneEnabled.$value.map { _ in () }.eraseToAnyPublisher(), + ] + Publishers.MergeMany(triggers) + .dropFirst(triggers.count) + .sink { [weak self] in self?.requestUpdate(reason: "settings") } + .store(in: &cancellables) + + let credentialTriggers: [AnyPublisher] = [ + Storage.shared.url.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.token.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.shareUserName.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.sharePassword.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.shareServer.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.remoteType.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.watchRemoteCommands.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.deviceToken.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.bundleId.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.productionEnvironment.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.teamId.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.lfApnsKey.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.lfKeyId.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.remoteApnsKey.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.remoteKeyId.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.user.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.sharedSecret.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.loopAPNSQrCodeURL.$value.map { _ in () }.eraseToAnyPublisher(), + Observable.shared.loopFollowDeviceToken.$value.map { _ in () }.eraseToAnyPublisher(), + ] + Publishers.MergeMany(credentialTriggers) + .debounce(for: .seconds(2), scheduler: DispatchQueue.main) + .sink { [weak self] in self?.sendCredentialsIfChanged(force: false) } + .store(in: &cancellables) + } + + /// Sends the credentials when they differ from what was last sent, or when + /// the watch asks for them. They never travel with the state updates. + private func sendCredentialsIfChanged(force: Bool) { + guard let session, session.activationState == .activated, session.isPaired, session.isWatchAppInstalled else { return } + let credentials = WatchCredentialsBuilder.build() + guard force || credentials != lastSentCredentials, + let data = try? JSONEncoder().encode(credentials) + else { return } + for transfer in session.outstandingUserInfoTransfers where transfer.userInfo[WatchCredentials.userInfoKey] != nil { + transfer.cancel() + } + // Immediate when the watch app is reachable; the queued transfer covers the rest. + if session.isReachable { + session.sendMessage([WatchCredentials.userInfoKey: data], replyHandler: nil, errorHandler: nil) + } + session.transferUserInfo([WatchCredentials.userInfoKey: data]) + lastSentCredentials = credentials + LogManager.shared.log(category: .general, message: "[Watch] credentials sent") + } + + /// Sends the info table and statistics to the watch whenever they change. + func observe(_ infoManager: InfoManager, stats: StatsDisplayModel) { + infoManager.objectWillChange + .sink { [weak self] in self?.requestUpdate(reason: "info") } + .store(in: &cancellables) + stats.objectWillChange + .sink { [weak self] in self?.requestUpdate(reason: "stats") } + .store(in: &cancellables) + } + + private func publish(reason: String) { + guard let session, session.activationState == .activated else { return } + let state = WatchStateBuilder.build() + guard let data = state.encoded() else { return } + + do { + try session.updateApplicationContext([WatchState.contextKey: data]) + } catch { + LogManager.shared.log(category: .general, message: "[Watch] context update failed: \(error.localizedDescription)") + } + + let newReading = state.snapshot?.updatedAt != lastSentState?.snapshot?.updatedAt + if newReading, + session.isComplicationEnabled, + session.remainingComplicationUserInfoTransfers > 0, + Date().timeIntervalSince(lastComplicationTransfer) >= complicationTransferInterval + { + session.transferCurrentComplicationUserInfo([WatchState.contextKey: data]) + lastComplicationTransfer = Date() + } + + lastSentState = state + LogManager.shared.log(category: .general, message: "[Watch] state sent (\(data.count) bytes) reason=\(reason)", isDebug: true) + } +} + +// MARK: - WCSessionDelegate + +extension WatchSessionManager: WCSessionDelegate { + func session(_: WCSession, activationDidCompleteWith state: WCSessionActivationState, error: Error?) { + if let error { + LogManager.shared.log(category: .general, message: "[Watch] activation failed: \(error.localizedDescription)") + return + } + if state == .activated { + requestUpdate(reason: "activated") + DispatchQueue.main.async { self.sendCredentialsIfChanged(force: false) } + } + } + + func sessionDidBecomeInactive(_: WCSession) {} + + func sessionDidDeactivate(_ session: WCSession) { + // Required when the user switches to another watch. + session.activate() + } + + func sessionWatchStateDidChange(_: WCSession) { + requestUpdate(reason: "watchStateChanged") + } + + func session(_: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + DispatchQueue.main.async { + if message[WatchCredentials.requestKey] != nil { + let data = (try? JSONEncoder().encode(WatchCredentialsBuilder.build())) ?? Data() + replyHandler([WatchCredentials.userInfoKey: data]) + return + } + + if message[WatchMessageKey.requestState] != nil { + let data = WatchStateBuilder.build().encoded() ?? Data() + replyHandler([WatchState.contextKey: data]) + return + } + + guard let data = message[WatchCommand.messageKey] as? Data, + let command = try? JSONDecoder().decode(WatchCommand.self, from: data) + else { + Self.reply(WatchCommandReply(success: false, message: "Unknown request."), to: replyHandler) + return + } + + WatchCommandHandler.shared.handle(command) { result in + Self.reply(result, to: replyHandler) + self.requestUpdate(reason: "command") + } + } + } + + func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { + if userInfo[WatchCredentials.requestKey] != nil { + DispatchQueue.main.async { self.sendCredentialsIfChanged(force: true) } + } + } + + private static func reply(_ reply: WatchCommandReply, to handler: ([String: Any]) -> Void) { + let data = (try? JSONEncoder().encode(reply)) ?? Data() + handler([WatchCommandReply.messageKey: data]) + } +} diff --git a/LoopFollow/Watch/WatchStateBuilder.swift b/LoopFollow/Watch/WatchStateBuilder.swift new file mode 100644 index 000000000..21d89021c --- /dev/null +++ b/LoopFollow/Watch/WatchStateBuilder.swift @@ -0,0 +1,252 @@ +// LoopFollow +// WatchStateBuilder.swift + +import Foundation +import HealthKit + +/// Collects the phone's current data into the state the watch renders. +enum WatchStateBuilder { + /// History sent to the watch; the watch chart scrolls back this far. + static let historyWindow: TimeInterval = 24 * 3600 + + static func build(now: Date = Date()) -> WatchState { + let thresholds = UnitSettingsStore.shared.effectiveThresholds() + let vc = MainViewController.shared + let cutoff = now.timeIntervalSince1970 - historyWindow + + let readings = (vc?.bgData ?? []) + .filter { $0.date >= cutoff && $0.sgv > 0 } + .map { WatchReading(t: $0.date, v: Double($0.sgv)) } + + let boluses = ((vc?.bolusData ?? []) + (vc?.smbData ?? [])) + .filter { $0.date >= cutoff } + .map { WatchReading(t: $0.date, v: $0.value) } + .sorted { $0.t < $1.t } + + let carbs = (vc?.carbData ?? []) + .filter { $0.date >= cutoff } + .map { WatchReading(t: $0.date, v: $0.value) } + + let overrides = (vc?.overrideGraphData ?? []) + .filter { $0.endDate >= cutoff } + .map { WatchOverride(start: $0.date, end: $0.endDate, name: $0.reason.isEmpty ? "Override" : $0.reason, percent: overridePercent($0)) } + + var predictions: [WatchPredictionLine] = [] + if let vc { + let lines: [(WatchPredictionLine.Kind, [ShareGlucoseData])] = [ + (.loop, vc.predictionData), + (.zt, vc.ztPredictionData), + (.iob, vc.iobPredictionData), + (.cob, vc.cobPredictionData), + (.uam, vc.uamPredictionData), + ] + for (kind, data) in lines where !data.isEmpty { + predictions.append(WatchPredictionLine(kind: kind, points: data.map { WatchReading(t: $0.date, v: Double($0.sgv)) })) + } + } + + let cone = (vc?.chartModel.cone ?? []).map { + WatchConePoint(t: $0.date.timeIntervalSince1970, low: $0.yMin, high: $0.yMax) + } + + return WatchState( + builtAt: now, + source: .phone, + phoneBuiltAt: now, + snapshot: GlucoseSnapshotBuilder.build(from: StorageCurrentGlucoseStateProvider()), + readings: readings, + predictions: predictions, + cone: cone, + boluses: boluses, + carbs: carbs, + overrides: overrides, + lowMgdl: thresholds.low, + highMgdl: thresholds.high, + colorBGText: Storage.shared.colorBGText.value, + infoRows: infoRows(vc), + stats: vc.map { stats($0.statsDisplayModel) }, + device: Storage.shared.device.value, + alarm: currentAlarm(), + remote: remoteConfig() + ) + } + + /// Same rows, order and coloring as the phone's info table (InfoTableView). + private static func infoRows(_ vc: MainViewController?) -> [WatchInfoRow] { + var rows: [WatchInfoRow] = [] + if Storage.shared.graphTimeZoneEnabled.value, + let tz = TimeZone(identifier: Storage.shared.graphTimeZoneIdentifier.value) + { + rows.append(WatchInfoRow(name: "Time Zone", value: tz.identifier, level: nil)) + } + guard let infoManager = vc?.infoManager else { return rows } + let items = Storage.shared.infoDisplayItems.value + for row in infoManager.visibleRows { + var level: InfoColorLevel? + if let numeric = row.numericValue, + let type = InfoType(rawValue: row.id), + let config = type.colorConfig + { + level = items.item(for: type)?.coloring.level(for: numeric, direction: config.direction) + } + rows.append(WatchInfoRow(name: row.name, value: row.value.isEmpty ? "β€”" : row.value, level: level)) + } + return rows + } + + private static func stats(_ model: StatsDisplayModel) -> WatchStats { + WatchStats( + lowPercent: model.lowPercent, + inRangePercent: model.inRangePercent, + highPercent: model.highPercent, + avgBG: model.avgBG, + estA1CTitle: model.estA1CTitle, + estA1C: model.estA1C, + stdDevTitle: model.stdDevTitle, + stdDev: model.stdDev, + pieLow: model.pieLow, + pieRange: model.pieRange, + pieHigh: model.pieHigh + ) + } + + private static func currentAlarm() -> WatchAlarm? { + guard let id = Observable.shared.currentAlarm.value, + let alarm = Storage.shared.alarms.value.first(where: { $0.id == id }) + else { return nil } + return WatchAlarm(id: id, name: alarm.name) + } + + // MARK: - Remote + + static func remoteKind() -> WatchRemoteConfig.Kind? { + switch Storage.shared.remoteType.value { + case .loopAPNS: + return Storage.shared.device.value == "Loop" ? .loop : nil + case .trc: + return Storage.shared.device.value == "Trio" ? .trio : nil + case .none: + return nil + } + } + + private static func remoteConfig() -> WatchRemoteConfig? { + guard Storage.shared.watchRemoteCommands.value, let kind = remoteKind() else { return nil } + + let maxBolus = Storage.shared.maxBolus.value.doubleValue(for: .internationalUnit()) + let bolusIncrement = max(0.001, Storage.shared.bolusIncrement.value.doubleValue(for: .internationalUnit())) + let maxCarbs = Storage.shared.maxCarbs.value.doubleValue(for: .gram()) + let mealWithFatProtein = kind == .trio && Storage.shared.mealWithFatProtein.value + let now = Date() + + let recommendedAt: TimeInterval? = kind == .trio + ? Observable.shared.enactedOrSuggested.value + : Observable.shared.alertLastLoopTime.value + + return WatchRemoteConfig( + kind: kind, + maxBolus: maxBolus, + bolusIncrement: bolusIncrement, + maxCarbs: maxCarbs, + maxFat: Storage.shared.maxFat.value.doubleValue(for: .gram()), + maxProtein: Storage.shared.maxProtein.value.doubleValue(for: .gram()), + mealWithBolus: kind == .trio && Storage.shared.mealWithBolus.value, + mealWithFatProtein: mealWithFatProtein, + recommendedBolus: Observable.shared.deviceRecBolus.value, + recommendedBolusAt: recommendedAt.map { Date(timeIntervalSince1970: $0) }, + overridePresets: kind == .trio ? trioOverridePresets() : loopOverridePresets(), + tempTargetPresets: kind == .trio ? tempTargetPresets() : [], + quickPickBoluses: QuickPickBolusesManager.computeQuickPickBoluses( + from: Storage.shared.remoteBolusHistory.value, + now: now, + stepIncrement: bolusIncrement, + maxBolus: maxBolus + ).map(\.units), + quickPickMeals: QuickPickMealsManager.computeQuickPickMeals( + from: Storage.shared.remoteMealHistory.value, + now: now, + carbStep: 1, + maxCarbs: maxCarbs, + includeFatProtein: mealWithFatProtein + ).map { WatchMealPick(carbs: $0.carbs, fat: $0.fat, protein: $0.protein, bolus: $0.bolus) } + ) + } + + /// Insulin needs of an override, from its Nightscout entry or else from the preset of the same name, + /// since Trio does not upload the factor with the override. + private static func overridePercent(_ override: DataStructs.overrideStruct) -> Int? { + var factor = override.insulNeedsScaleFactor + if factor == 1 { + if let loop = ProfileManager.shared.loopOverrides.first(where: { $0.name == override.reason }) { + factor = loop.insulinNeedsScaleFactor + } else if let percentage = ProfileManager.shared.trioOverrides.first(where: { $0.name == override.reason })?.percentage { + factor = percentage / 100 + } + } + return factor == 1 ? nil : Int(round(factor * 100)) + } + + private static func loopOverridePresets() -> [WatchPreset] { + ProfileManager.shared.loopOverrides.map { override in + var parts: [String] = [] + if override.insulinNeedsScaleFactor != 1 { + parts.append("\(Int(round(override.insulinNeedsScaleFactor * 100))) %") + } + if override.targetRange.count >= 2 { + let low = Localizer.formatQuantity(override.targetRange[0]) + let high = Localizer.formatQuantity(override.targetRange[1]) + parts.append(low == high ? low : "\(low)–\(high)") + } + parts.append(durationText(seconds: TimeInterval(override.duration ?? 0))) + let symbol = override.symbol.isEmpty ? "" : "\(override.symbol) " + return WatchPreset( + id: override.name, + name: symbol + override.name, + detail: parts.joined(separator: ", "), + durationSeconds: TimeInterval(override.duration ?? 0) + ) + } + } + + private static func trioOverridePresets() -> [WatchPreset] { + ProfileManager.shared.trioOverrides.map { override in + var parts: [String] = [] + if let percentage = override.percentage { + parts.append("\(Int(percentage)) %") + } + if let target = override.target { + parts.append(Localizer.formatQuantity(target)) + } + parts.append(durationText(seconds: (override.duration ?? 0) * 60)) + return WatchPreset( + id: override.name, + name: override.name, + detail: parts.joined(separator: ", "), + durationSeconds: (override.duration ?? 0) * 60 + ) + } + } + + private static func tempTargetPresets() -> [WatchPreset] { + TempTargetPresetManager.shared.presets.map { preset in + let target = Localizer.formatQuantity(preset.target) + let duration = durationText(seconds: preset.duration.doubleValue(for: .second())) + return WatchPreset( + id: preset.id.uuidString, + name: preset.name, + detail: "\(target), \(duration)", + durationSeconds: preset.duration.doubleValue(for: .second()), + targetMgdl: preset.target.doubleValue(for: .milligramsPerDeciliter) + ) + } + } + + private static func durationText(seconds: TimeInterval) -> String { + guard seconds > 0 else { return "Indefinite" } + let hours = Int(seconds) / 3600 + let minutes = (Int(seconds) % 3600) / 60 + if hours > 0, minutes > 0 { return "\(hours) h \(minutes) min" } + if hours > 0 { return "\(hours) h" } + return "\(minutes) min" + } +} diff --git a/LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/1024.png b/LoopFollowWatch/Assets.xcassets/AppIcon.appiconset/1024.png new file mode 100644 index 0000000000000000000000000000000000000000..a975ee0639e82b0a49a7272371b6ac3dc2c2d5d9 GIT binary patch literal 477801 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

?YpwzU%nq_g%*?zyCTOe*7d32HR{#vWJ5>)4@(i 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΄re>kh6IpgoMJ)wd`9)Yb!go8G0M;5^ozfam0 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 some View { + let marker = markerDate(now: now) + let nearest = state.readings.min { abs($0.date.timeIntervalSince(marker)) < abs($1.date.timeIntervalSince(marker)) } + let reading = nearest.flatMap { abs($0.date.timeIntervalSince(marker)) <= 450 ? $0 : nil } + let carbs = total(of: state.carbs, near: marker) + let insulin = total(of: state.boluses, near: marker) + return HStack(spacing: 8) { + Text(marker.formatted(date: .omitted, time: .shortened)) + .foregroundStyle(.secondary) + HStack(spacing: 3) { + if let reading { + Circle() + .fill(WatchFormat.bgColor(reading.v, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl)) + .frame(width: 7, height: 7) + Text(WatchFormat.glucose(reading.v, unit: state.snapshot?.unit ?? .mgdl)) + } else { + Text("β€”") + } + } + if let carbs { + Text("\(Image(systemName: "fork.knife")) \(WatchFormat.cob(carbs))") + .accessibilityLabel("Carbs \(WatchFormat.cob(carbs))") + } + if let insulin { + Text("\(Image(systemName: "syringe")) \(WatchFormat.insulin(insulin)) U") + .accessibilityLabel("Bolus \(WatchFormat.insulin(insulin)) U") + } + } + .font(.footnote) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.6) + } + + private func markerDate(now: Date) -> Date { + WatchChartView.markerDate(state: state, now: now, spanHours: spanHours, scrubOffset: scrubSteps * 300) + } + + /// Sum of the entries within half a crown step of the marker, or nil when there are none. + private func total(of entries: [WatchReading], near marker: Date) -> Double? { + let matching = entries.filter { abs($0.date.timeIntervalSince(marker)) < 150 } + return matching.isEmpty ? nil : matching.reduce(0) { $0 + $1.v } + } +} + +/// Steps through the chart time spans. +private struct SpanButton: View { + @Binding var spanHours: Double + + var body: some View { + Button("\(Int(spanHours)) h") { + let index = WatchChartView.spans.firstIndex(of: spanHours) ?? 1 + spanHours = WatchChartView.spans[(index + 1) % WatchChartView.spans.count] + } + .accessibilityLabel("Time span \(Int(spanHours)) hours") + } +} diff --git a/LoopFollowWatch/ContentView.swift b/LoopFollowWatch/ContentView.swift new file mode 100644 index 000000000..f8abc2df1 --- /dev/null +++ b/LoopFollowWatch/ContentView.swift @@ -0,0 +1,112 @@ +// LoopFollow +// ContentView.swift + +import SwiftUI + +/// Screens pushed on the main navigation stack. +enum WatchRoute: Hashable { + case chartScrub + case treatments + case meal + case bolus + case bolusAmount + case overrides + case tempTargets + case confirm(CommandRequest) +} + +/// What a confirmation screen sends, and how it describes it. +struct CommandRequest: Hashable { + let title: String + let lines: [String] + let action: WatchCommand.Action + let deliversInsulin: Bool +} + +/// Owns the navigation path so a finished command can return to the glucose page. +final class WatchNavigator: ObservableObject { + @Published var path: [WatchRoute] = [] + + func push(_ route: WatchRoute) { + path.append(route) + } + + func popToRoot() { + path.removeAll() + } +} + +struct ContentView: View { + @EnvironmentObject private var session: WatchSession + @StateObject private var navigator = WatchNavigator() + + var body: some View { + NavigationStack(path: $navigator.path) { + if let state = session.state { + TabView { + GlucosePage(state: state) + ChartPage(state: state) + if let stats = state.stats { + StatsPage(state: state, stats: stats) + } + InfoPage(state: state) + } + .tabViewStyle(.verticalPage) + .navigationDestination(for: WatchRoute.self) { route in + destination(route, state: state) + } + } else { + WaitingForPhoneView() + } + } + .environmentObject(navigator) + } + + @ViewBuilder + private func destination(_ route: WatchRoute, state: WatchState) -> some View { + if route == .chartScrub { + ChartScrubView(state: state) + } else if let remote = state.remote { + switch route { + case .chartScrub: + EmptyView() + case .treatments: + TreatmentMenuView() + case .meal: + MealEntryView(remote: remote) + case .bolus: + BolusPickerView(remote: remote) + case .bolusAmount: + BolusAmountView(remote: remote) + case .overrides: + PresetListView(kind: .override, state: state, presets: remote.overridePresets) + case .tempTargets: + PresetListView(kind: .tempTarget, state: state, presets: remote.tempTargetPresets) + case let .confirm(request): + CommandConfirmView(request: request) + } + } else { + Text("Remote commands are turned off in LoopFollow on the iPhone.") + .multilineTextAlignment(.center) + } + } +} + +private struct WaitingForPhoneView: View { + var body: some View { + VStack(spacing: 8) { + Image(systemName: "iphone.radiowaves.left.and.right") + .font(.title2) + .foregroundStyle(.secondary) + Text("Waiting for LoopFollow on iPhone") + .font(.headline) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + Text("Open LoopFollow on the paired iPhone.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding() + } +} diff --git a/LoopFollowWatch/CrownSymbol.swift b/LoopFollowWatch/CrownSymbol.swift new file mode 100644 index 000000000..1b6d66f23 --- /dev/null +++ b/LoopFollowWatch/CrownSymbol.swift @@ -0,0 +1,11 @@ +// LoopFollow +// CrownSymbol.swift + +import WatchKit + +enum CrownSymbol { + /// The crown symbol turning the way that raises a value, for the wrist the watch is worn on. + static var increase: String { + WKInterfaceDevice.current().wristLocation == .left ? "digitalcrown.arrow.clockwise" : "digitalcrown.arrow.counterclockwise" + } +} diff --git a/LoopFollowWatch/DirectCommandSender.swift b/LoopFollowWatch/DirectCommandSender.swift new file mode 100644 index 000000000..b2320524f --- /dev/null +++ b/LoopFollowWatch/DirectCommandSender.swift @@ -0,0 +1,172 @@ +// LoopFollow +// DirectCommandSender.swift + +import Foundation + +/// Sends a command from the watch straight to Loop or Trio when the phone is +/// out of reach, with the credentials and guardrails the phone last sent. +enum DirectCommandSender { + /// At least this long between two insulin commands sent from the watch. + static let insulinInterval: TimeInterval = 60 + + private static let lastInsulinKey = "directLastInsulinAt" + private static let lastLoopOTPKey = "directLastLoopOTP" + + static func send(_ requested: WatchCommand.Action, credentials: WatchCredentials, config: WatchRemoteConfig) async -> WatchCommandReply { + guard let remote = credentials.remote, remote.kind == config.kind else { + return WatchCommandReply(success: false, message: "iPhone not reachable, and commands from the watch alone are not set up. Open LoopFollow on the iPhone once.") + } + + let action: WatchCommand.Action + switch WatchCommandValidator.validate(requested, kind: remote.kind, limits: config.limits) { + case let .failure(error): return WatchCommandReply(success: false, message: error.message) + case let .success(validated): action = validated + } + + if action.deliversInsulin, let last = UserDefaults.standard.object(forKey: lastInsulinKey) as? Date, + Date().timeIntervalSince(last) < insulinInterval + { + return WatchCommandReply(success: false, message: "Insulin was sent from the watch less than a minute ago. Check Loop or Trio before sending more.") + } + + let result: (Bool, String?) + switch remote.kind { + case .trio: result = await sendTrio(action, remote: remote, config: config) + case .loop: result = await sendLoop(action, remote: remote, config: config) + } + + LogManager.shared.log(category: .apns, message: "Direct command \(action.name) finished: success=\(result.0) \(result.1 ?? "")") + guard result.0 else { + return WatchCommandReply(success: false, message: result.1 ?? "Failed to send the command.") + } + if action.deliversInsulin { + UserDefaults.standard.set(Date(), forKey: lastInsulinKey) + } + return WatchCommandReply(success: true, message: "Sent from the watch without the iPhone. Check Loop or Trio for the result.") + } + + // MARK: - Trio + + private static func sendTrio(_ action: WatchCommand.Action, remote: WatchCredentials.Remote, config: WatchRemoteConfig) async -> (Bool, String?) { + var payload = CommandPayload(user: remote.user, commandType: .bolus, timestamp: Date().timeIntervalSince1970, returnNotification: remote.returnNotification) + switch action { + case let .bolus(units): + payload.bolusAmount = Decimal(units) + case let .meal(carbs, fat, protein, bolus, consumedAt): + payload.commandType = .meal + payload.carbs = carbs > 0 ? Int(carbs) : nil + payload.fat = fat > 0 ? Int(fat) : nil + payload.protein = protein > 0 ? Int(protein) : nil + payload.bolusAmount = bolus > 0 ? Decimal(bolus) : nil + payload.scheduledTime = WatchCommand.trioScheduledTime(consumedAt)?.timeIntervalSince1970 + case let .startOverride(presetID): + payload.commandType = .startOverride + payload.overrideName = presetID + case .cancelOverride: + payload.commandType = .cancelOverride + case let .startTempTarget(presetID): + guard let preset = config.tempTargetPresets.first(where: { $0.id == presetID }), let target = preset.targetMgdl else { + return (false, "The temp target preset was not found.") + } + payload.commandType = .tempTarget + payload.target = Int(target) + payload.duration = Int(preset.durationSeconds / 60) + case .cancelTempTarget: + payload.commandType = .cancelTempTarget + case .snoozeAlarm: + return (false, "Snoozing needs the iPhone.") + } + + guard let messenger = SecureMessenger(sharedSecret: remote.sharedSecret) else { + return (false, "Failed to initialize security module. Check shared secret.") + } + do { + let message = try EncryptedPushMessage(encryptedData: messenger.encrypt(payload), commandType: payload.commandType) + let body = try JSONEncoder().encode(message) + return await deliver(body: body, remote: remote, expiration: "600", collapseID: payload.apnsCollapseID) + } catch { + return (false, "Failed to encode or encrypt push message: \(error.localizedDescription)") + } + } + + // MARK: - Loop + + private static func sendLoop(_ action: WatchCommand.Action, remote: WatchCredentials.Remote, config: WatchRemoteConfig) async -> (Bool, String?) { + var payload: [String: Any] + var usedOTP: String? + + switch action { + case let .bolus(units): + guard let otp = loopOTP(remote) else { return (false, otpUnavailable) } + payload = LoopAPNSCommands.bolusCommandPayload(LoopAPNSPayload(type: .bolus, bolusAmount: units, otp: otp)) + usedOTP = otp + case let .meal(carbs, _, _, _, consumedAt): + guard let otp = loopOTP(remote) else { return (false, otpUnavailable) } + payload = LoopAPNSCommands.carbsCommandPayload(LoopAPNSPayload( + type: .carbs, + carbsAmount: carbs, + absorptionTime: WatchCommandValidator.loopAbsorptionHours, + consumedDate: consumedAt.dateUsingCurrentSeconds(), + otp: otp + )) + usedOTP = otp + case let .startOverride(presetID): + guard let preset = config.overridePresets.first(where: { $0.id == presetID }) else { + return (false, "The override preset was not found.") + } + payload = LoopAPNSCommands.overridePayload( + presetName: presetID, + duration: preset.durationSeconds > 0 ? preset.durationSeconds : nil, + returnInfo: remote.returnNotification + ) + case .cancelOverride: + payload = LoopAPNSCommands.cancelOverridePayload(returnInfo: remote.returnNotification) + case .startTempTarget, .cancelTempTarget, .snoozeAlarm: + return (false, "This command is not available for Loop.") + } + + if let usedOTP, let returnInfo = remote.returnNotification, + let messenger = OTPSecureMessenger(otpCode: usedOTP), + let encrypted = try? messenger.encrypt(returnInfo) + { + payload["encrypted_return_notification"] = encrypted + } + + do { + let body = try LoopAPNSCommands.apnsBody(payload) + let result = await deliver(body: body, remote: remote) + if result.0, let usedOTP { + UserDefaults.standard.set(usedOTP, forKey: lastLoopOTPKey) + } + return result + } catch { + return (false, "Failed to serialize APNS payload: \(error.localizedDescription)") + } + } + + private static let otpUnavailable = "No unused one-time code right now. Wait for the next code (up to 30 seconds) and try again." + + /// The current one-time code, unless the watch already used it: Loop rejects a code it has seen. + private static func loopOTP(_ remote: WatchCredentials.Remote) -> String? { + guard let otp = TOTPGenerator.extractOTPFromURL(remote.loopQRCodeURL) else { return nil } + return otp == UserDefaults.standard.string(forKey: lastLoopOTPKey) ? nil : otp + } + + private static func deliver(body: Data, remote: WatchCredentials.Remote, expiration: String? = nil, collapseID: String? = nil) async -> (Bool, String?) { + await withCheckedContinuation { continuation in + APNSDelivery.send( + body: body, + deviceToken: remote.deviceToken, + topic: remote.bundleID, + production: remote.production, + keyId: remote.keyID, + teamId: remote.teamID, + apnsKey: remote.apnsKey, + expiration: expiration, + collapseID: collapseID + ) { success, error in + continuation.resume(returning: (success, error)) + } + } + } +} diff --git a/LoopFollowWatch/GlucosePage.swift b/LoopFollowWatch/GlucosePage.swift new file mode 100644 index 000000000..d07086dd2 --- /dev/null +++ b/LoopFollowWatch/GlucosePage.swift @@ -0,0 +1,263 @@ +// LoopFollow +// GlucosePage.swift + +import Charts +import SwiftUI + +/// Main page: one large BG value with trend and delta, IOB and COB, the running override, a two-hour sparkline, +/// and the remote commands in the bottom toolbar. +struct GlucosePage: View { + let state: WatchState + + @EnvironmentObject private var session: WatchSession + @Environment(\.isLuminanceReduced) private var isLuminanceReduced + + var body: some View { + TimelineView(.periodic(from: .now, by: 30)) { context in + VStack(spacing: 2) { + if let alarm = state.alarm { + AlarmBanner(alarm: alarm) + } + glucose(now: context.date) + if !isLuminanceReduced { + details + overrideLine(now: context.date) + Sparkline(state: state, now: context.date) + .frame(minHeight: 36) + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + age(now: context.date) + } + if !isLuminanceReduced, let remote = state.remote { + ToolbarItemGroup(placement: .bottomBar) { + RemoteToolbar(state: state, remote: remote) + } + } + } + } + } + + @ViewBuilder + private func glucose(now: Date) -> some View { + if let snapshot = state.snapshot { + let isStale = now.timeIntervalSince(snapshot.updatedAt) >= WatchFormat.staleAfter + VStack(spacing: -4) { + // The arrow hangs off the number's trailing edge so the digits, and the delta under them, stay centered. + Text(WatchFormat.glucose(snapshot.glucose, unit: snapshot.unit)) + .font(.system(size: 56, weight: .black)) + .foregroundStyle(WatchFormat.bgColor(snapshot.glucose, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl, colored: state.colorBGText)) + .strikethrough(isStale, pattern: .solid, color: .red) + .lineLimit(1) + .minimumScaleFactor(0.5) + .overlay(alignment: .trailing) { + Text(WatchFormat.trendArrow(snapshot.trend)) + .font(.system(size: 28, weight: .black)) + .fixedSize() + .alignmentGuide(.trailing) { $0[.leading] - 4 } + } + .padding(.horizontal, 34) + Text(WatchFormat.delta(snapshot.delta, unit: snapshot.unit)) + .font(.title3) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .layoutPriority(1) + .accessibilityElement(children: .combine) + } else { + Text("No BG data") + .font(.headline) + .foregroundStyle(.secondary) + } + } + + /// Age of the reading, with a marker while the watch fetches on its own or cannot reach the phone. + @ViewBuilder + private func age(now: Date) -> some View { + HStack(spacing: 2) { + if state.source == .watch { + Image(systemName: "applewatch.radiowaves.left.and.right") + .foregroundStyle(.blue) + .accessibilityLabel("Fetched by the watch") + } else if !session.isReachable { + Image(systemName: "iphone.slash") + .foregroundStyle(.orange) + .accessibilityLabel("iPhone not reachable") + } + if let snapshot = state.snapshot { + Text(WatchFormat.minutesAgo(snapshot.updatedAt, now: now)) + .foregroundStyle(now.timeIntervalSince(snapshot.updatedAt) >= WatchFormat.staleAfter ? .red : .secondary) + } + } + .font(.footnote) + } + + @ViewBuilder + private var details: some View { + if let snapshot = state.snapshot { + if snapshot.isNotLooping { + Label("Not looping", systemImage: "exclamationmark.triangle.fill") + .font(.footnote) + .foregroundStyle(.red) + } else if snapshot.iob != nil || snapshot.cob != nil { + HStack(spacing: 12) { + if let iob = snapshot.iob { + Text("\(Image(systemName: "syringe")) \(WatchFormat.iob(iob))") + .accessibilityLabel("Insulin on board \(WatchFormat.iob(iob))") + } + if let cob = snapshot.cob { + Text("\(Image(systemName: "fork.knife")) \(WatchFormat.cob(cob))") + .accessibilityLabel("Carbs on board \(WatchFormat.cob(cob))") + } + } + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + } + } + + /// The running override's name and insulin needs. + @ViewBuilder + private func overrideLine(now: Date) -> some View { + let active = state.activeOverride(at: now) + // The phone's periods end on time; the watch's own fetch only has the snapshot's name. + if let name = state.overrides == nil ? state.snapshot?.override : active?.name { + OverrideLine(name: name, percent: active?.percent) + } + } +} + +/// An override's name and insulin needs, in the green of the overrides button and chart band. +struct OverrideLine: View { + let name: String + let percent: Int? + + var body: some View { + Text("\(Image(systemName: "slider.horizontal.3")) \(name)\(percent.map { " Β· \($0) %" } ?? "")") + .font(.footnote) + .foregroundStyle(.green) + .lineLimit(1) + .minimumScaleFactor(0.7) + .accessibilityLabel("Override \(name)\(percent.map { ", insulin needs \($0) percent" } ?? "")") + } +} + +/// Override, treatments and temp target, as in Apple's bottom-toolbar pattern. +private struct RemoteToolbar: View { + let state: WatchState + let remote: WatchRemoteConfig + @EnvironmentObject private var session: WatchSession + @EnvironmentObject private var navigator: WatchNavigator + + var body: some View { + Button { + navigator.push(.overrides) + } label: { + Image(systemName: "slider.horizontal.3") + } + .tint(state.snapshot?.override != nil ? .green : nil) + .accessibilityLabel("Overrides") + .disabled(!session.canSendCommands) + + Button { + navigator.push(.treatments) + } label: { + Image(systemName: "plus") + } + .tint(.blue) + .accessibilityLabel("Meal or bolus") + .disabled(!session.canSendCommands) + + if remote.kind == .trio { + Button { + navigator.push(.tempTargets) + } label: { + Image(systemName: "scope") + } + .tint(state.snapshot?.tempTargetMgdl != nil ? .green : nil) + .accessibilityLabel("Temp Target") + .disabled(!session.canSendCommands) + } else { + // Holds the trailing slot so + stays centered. + Button {} label: { + Image(systemName: "scope") + } + .hidden() + .accessibilityHidden(true) + } + } +} + +/// The last two hours in range colors, without axes, scaled to the readings and the range lines. +struct Sparkline: View { + let state: WatchState + let now: Date + + var body: some View { + let start = now.addingTimeInterval(-2 * 3600) + let readings = state.readings.filter { $0.date >= start } + let values = readings.map(\.v) + let bottom = min(values.min() ?? state.lowMgdl, state.lowMgdl) - 10 + let top = max(values.max() ?? state.highMgdl, state.highMgdl) + 10 + Chart { + RuleMark(y: .value("High", state.highMgdl)) + .foregroundStyle(Color.yellow.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + RuleMark(y: .value("Low", state.lowMgdl)) + .foregroundStyle(Color.red.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + ForEach(readings, id: \.t) { reading in + PointMark(x: .value("Time", reading.date), y: .value("BG", reading.v)) + .symbolSize(12) + .foregroundStyle(WatchFormat.bgColor(reading.v, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl)) + } + } + .chartXScale(domain: start ... now) + .chartYScale(domain: bottom ... top) + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .accessibilityHidden(true) + } +} + +/// Shown while an alarm is sounding on the phone. +struct AlarmBanner: View { + let alarm: WatchAlarm + @EnvironmentObject private var session: WatchSession + @State private var isSending = false + @State private var error: String? + + var body: some View { + Button { + Task { + isSending = true + let reply = await session.send(.snoozeAlarm) + isSending = false + error = reply.success ? nil : reply.message + } + } label: { + HStack { + Image(systemName: "bell.fill") + VStack(alignment: .leading) { + Text(alarm.name) + .lineLimit(1) + Text(isSending ? "Snoozing…" : "Tap to snooze") + .font(.caption2) + .opacity(0.8) + } + Spacer(minLength: 0) + } + } + .buttonStyle(.borderedProminent) + .tint(.red) + .disabled(isSending) + .alert("Snooze failed", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { + Button("OK", role: .cancel) {} + } message: { + Text(error ?? "") + } + } +} diff --git a/LoopFollowWatch/Info.plist b/LoopFollowWatch/Info.plist new file mode 100644 index 000000000..cdb0808fa --- /dev/null +++ b/LoopFollowWatch/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + $(display_name) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(LOOP_FOLLOW_MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + WKApplication + + WKCompanionAppBundleIdentifier + com.$(unique_id).LoopFollow$(app_suffix) + + diff --git a/LoopFollowWatch/InfoPage.swift b/LoopFollowWatch/InfoPage.swift new file mode 100644 index 000000000..c8a6d5826 --- /dev/null +++ b/LoopFollowWatch/InfoPage.swift @@ -0,0 +1,55 @@ +// LoopFollow +// InfoPage.swift + +import SwiftUI + +/// The phone's info table: the same rows, order and threshold coloring. +struct InfoPage: View { + let state: WatchState + + var body: some View { + ScrollView { + VStack(spacing: 0) { + if state.source == .watch, !state.infoRows.isEmpty { + Text("From the iPhone at \(state.phoneBuiltAt.formatted(date: .omitted, time: .shortened))") + .font(.caption2) + .foregroundStyle(.orange) + .frame(maxWidth: .infinity, alignment: .leading) + } + if state.infoRows.isEmpty { + Text("No info rows are shown on the iPhone.") + .font(.footnote) + .foregroundStyle(.secondary) + } + ForEach(Array(state.infoRows.enumerated()), id: \.offset) { _, row in + HStack { + Text(row.name) + .foregroundStyle(.secondary) + Spacer() + Text(row.value) + .foregroundStyle(Self.color(row.level)) + .multilineTextAlignment(.trailing) + } + .font(.footnote) + .lineLimit(1) + .minimumScaleFactor(0.6) + .padding(.vertical, 3) + Divider() + } + } + // Keeps the values clear of the page indicator. + .padding(.trailing, 10) + } + .navigationTitle("Info") + } + + /// Same colors as the phone's InfoColoring. + static func color(_ level: InfoColorLevel?) -> Color { + switch level { + case .urgent: .red + case .warning: .yellow + case .normal: .green + case nil: .primary + } + } +} diff --git a/LoopFollowWatch/LogManager.swift b/LoopFollowWatch/LogManager.swift new file mode 100644 index 000000000..5494d37f3 --- /dev/null +++ b/LoopFollowWatch/LogManager.swift @@ -0,0 +1,32 @@ +// LoopFollow +// LogManager.swift + +import Foundation +import os + +/// The watch's logger. Same call shape as the phone's LogManager so the code in +/// RemoteShared compiles for both; lines go to the unified system log. +final class LogManager { + static let shared = LogManager() + + enum Category: String { + case apns = "APNS" + case nightscout = "Nightscout" + case dexcom = "Dexcom" + case general = "General" + case remote = "Remote" + } + + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "LoopFollowWatch", category: "watch") + + private init() {} + + func log(category: Category, message: String, isDebug: Bool = false) { + let line = "[\(category.rawValue)] \(LogRedactor.sweep(message))" + if isDebug { + logger.debug("\(line, privacy: .public)") + } else { + logger.log("\(line, privacy: .public)") + } + } +} diff --git a/LoopFollowWatch/LoopFollowWatch.entitlements b/LoopFollowWatch/LoopFollowWatch.entitlements new file mode 100644 index 000000000..5b963cc90 --- /dev/null +++ b/LoopFollowWatch/LoopFollowWatch.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.$(unique_id).LoopFollow$(app_suffix) + + + diff --git a/LoopFollowWatch/LoopFollowWatchApp.swift b/LoopFollowWatch/LoopFollowWatchApp.swift new file mode 100644 index 000000000..8db456fa3 --- /dev/null +++ b/LoopFollowWatch/LoopFollowWatchApp.swift @@ -0,0 +1,38 @@ +// LoopFollow +// LoopFollowWatchApp.swift + +import SwiftUI + +@main +struct LoopFollowWatchApp: App { + @StateObject private var session = WatchSession.shared + @Environment(\.scenePhase) private var scenePhase + + init() { + WatchSession.shared.activate() + } + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(session) + .task(id: scenePhase) { + guard scenePhase == .active else { return } + session.requestState() + session.scheduleBackgroundRefresh() + // While open, check every minute whether the watch has to fetch on its own. + while !Task.isCancelled { + await session.refreshIfNeeded() + try? await Task.sleep(nanoseconds: 60_000_000_000) + } + } + } + .backgroundTask(.watchConnectivity) { + await WatchSession.shared.waitForPendingContent() + } + .backgroundTask(.appRefresh(WatchSession.backgroundRefreshID)) { + await WatchSession.shared.refreshIfNeeded() + await WatchSession.shared.scheduleBackgroundRefresh() + } + } +} diff --git a/LoopFollowWatch/Remote/BolusEntryView.swift b/LoopFollowWatch/Remote/BolusEntryView.swift new file mode 100644 index 000000000..7129b1d67 --- /dev/null +++ b/LoopFollowWatch/Remote/BolusEntryView.swift @@ -0,0 +1,186 @@ +// LoopFollow +// BolusEntryView.swift + +import SwiftUI + +/// Pick an amount: the AID's recommendation, the phone's Quick-Pick boluses, or +/// any amount with the Digital Crown. Every choice still needs the crown confirmation. +struct BolusPickerView: View { + let remote: WatchRemoteConfig + + @EnvironmentObject private var navigator: WatchNavigator + @State private var staleRecommendation: Double? + + /// Same cut-offs as the phone's bolus screens. + private let recommendationMaxAge: TimeInterval = 12 * 60 + private let recommendationWarnAge: TimeInterval = 5 * 60 + + private var step: Double { BolusAmountView.step(remote) } + + var body: some View { + List { + recommendation + ForEach(remote.quickPickBoluses.filter { $0 > 0 && $0 <= remote.maxBolus }, id: \.self) { units in + Button { + confirm(units) + } label: { + Text("\(WatchFormat.insulin(units)) U") + } + } + Button { + navigator.push(.bolusAmount) + } label: { + Text("Other Amount") + } + } + .navigationTitle("Bolus") + .alert( + "Old Calculation Warning", + isPresented: Binding(get: { staleRecommendation != nil }, set: { if !$0 { staleRecommendation = nil } }) + ) { + Button("Use Anyway") { + if let staleRecommendation { confirm(staleRecommendation) } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This recommended bolus was calculated more than 5 minutes ago. New treatments may have occurred since then. Proceed with caution.") + } + } + + @ViewBuilder + private var recommendation: some View { + if let rec = remote.recommendedBolus, rec > 0, let at = remote.recommendedBolusAt { + let age = Date().timeIntervalSince(at) + if age < recommendationMaxAge { + let stepped = BolusAmountView.roundedDown(min(rec, remote.maxBolus), step: step) + Section { + Button { + if age >= recommendationWarnAge { + staleRecommendation = stepped + } else { + confirm(stepped) + } + } label: { + VStack(alignment: .leading, spacing: 2) { + Text("Recommended \(WatchFormat.insulin(stepped)) U") + Text("Calculated \(Int(age / 60)) min ago") + .font(.caption2) + .foregroundStyle(age >= recommendationWarnAge ? .red : .secondary) + } + } + .disabled(stepped <= 0) + } + } + } + } + + private func confirm(_ units: Double) { + navigator.push(.confirm(BolusAmountView.request(units))) + } +} + +/// Any amount, set with the Digital Crown. +struct BolusAmountView: View { + let remote: WatchRemoteConfig + + @EnvironmentObject private var navigator: WatchNavigator + @State private var units = 0.0 + @FocusState private var amountFocused: Bool + + var body: some View { + ScrollView { + VStack(spacing: 8) { + AmountField(value: $units, unit: "U", step: Self.step(remote), maximum: remote.maxBolus, isHighlighted: amountFocused, sensitivity: .low, format: WatchFormat.insulin) + .focused($amountFocused) + Text("Max \(WatchFormat.insulin(remote.maxBolus)) U") + .font(.footnote) + .foregroundStyle(.secondary) + Button("Continue") { + navigator.push(.confirm(Self.request(units))) + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .disabled(units <= 0) + } + } + .navigationTitle("Bolus") + .onAppear { amountFocused = true } + } + + static func step(_ remote: WatchRemoteConfig) -> Double { + max(remote.bolusIncrement, 0.025) + } + + static func request(_ units: Double) -> CommandRequest { + CommandRequest(title: "Bolus", lines: ["\(WatchFormat.insulin(units)) U"], action: .bolus(units: units), deliversInsulin: true) + } + + static func roundedDown(_ value: Double, step: Double) -> Double { + let stepped = ((value / step) + 1e-10).rounded(.down) * step + return (stepped * 1000).rounded() / 1000 + } +} + +/// A value edited with the Digital Crown or the βˆ’ and + buttons, in fixed steps between a minimum and a maximum. +struct AmountField: View { + @Binding var value: Double + let unit: String + let step: Double + var minimum: Double = 0 + let maximum: Double + /// The caller owns focus; this only draws the highlight. + let isHighlighted: Bool + var sensitivity: DigitalCrownRotationalSensitivity = .medium + let format: (Double) -> String + + private var maxSteps: Double { max(((maximum - minimum) / step + 1e-10).rounded(.down), 0) } + + /// The crown works on whole step counts, so every detent is exactly one step. + private var steps: Binding { + Binding( + get: { ((value - minimum) / step).rounded() }, + set: { newSteps in + let clamped = min(max(newSteps.rounded(), 0), maxSteps) + value = ((minimum + clamped * step) * 1000).rounded() / 1000 + } + ) + } + + var body: some View { + HStack(spacing: 2) { + nudge("minus", by: -1) + .disabled(steps.wrappedValue <= 0) + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(format(value)) + .font(.system(size: 34, weight: .bold)) + .monospacedDigit() + .minimumScaleFactor(0.6) + .lineLimit(1) + Text(unit) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + nudge("plus", by: 1) + .disabled(steps.wrappedValue >= maxSteps) + } + // Keeps βˆ’ clear of the left edge, where watchOS takes swipes for going back. + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(RoundedRectangle(cornerRadius: 8).stroke(isHighlighted ? Color.green : Color.gray.opacity(0.4), lineWidth: 1.5)) + .focusable() + .digitalCrownRotation(detent: steps, from: 0, through: max(maxSteps, 1), by: 1, sensitivity: sensitivity, isContinuous: false, isHapticFeedbackEnabled: true) + } + + private func nudge(_ symbol: String, by delta: Double) -> some View { + Button { + steps.wrappedValue += delta + } label: { + Image(systemName: symbol) + .font(.footnote.weight(.semibold)) + .frame(width: 36, height: 36) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(.tint) + } +} diff --git a/LoopFollowWatch/Remote/CommandConfirmView.swift b/LoopFollowWatch/Remote/CommandConfirmView.swift new file mode 100644 index 000000000..d7e8cbbb7 --- /dev/null +++ b/LoopFollowWatch/Remote/CommandConfirmView.swift @@ -0,0 +1,176 @@ +// LoopFollow +// CommandConfirmView.swift + +import LocalAuthentication +import SwiftUI +import WatchKit + +/// Final step for every command: shows what will be sent, asks for confirmation +/// and shows the phone's reply. Insulin needs a full turn of the Digital Crown +/// and an unlocked watch on the wrist. A sent command is not yet delivered: +/// Loop or Trio confirms it later with a notification. +struct CommandConfirmView: View { + let request: CommandRequest + + @EnvironmentObject private var session: WatchSession + @EnvironmentObject private var navigator: WatchNavigator + @Environment(\.dismiss) private var dismiss + + /// How long the "sent" screen stays before returning to the glucose page. + private let sentDisplayTime: TimeInterval = 3 + + @State private var phase: Phase = .confirm + @State private var crownProgress = 0.0 + @State private var lastCrownChange = Date() + @FocusState private var crownFocused: Bool + + enum Phase: Equatable { + case confirm + case sending + case done(success: Bool, message: String) + } + + var body: some View { + ScrollView { + VStack(spacing: 8) { + switch phase { + case .confirm: + summary + if request.deliversInsulin { + crownConfirm + } else { + Button("Send") { Task { await send() } } + .buttonStyle(.borderedProminent) + .tint(.blue) + } + case .sending: + ProgressView() + Text("Sending…") + .foregroundStyle(.secondary) + case let .done(success, message): + if success { + Image(systemName: "paperplane.fill") + .font(.largeTitle) + .foregroundStyle(.blue) + Text("Sent") + .font(.headline) + Text(message) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } else { + Image(systemName: "xmark.octagon.fill") + .font(.largeTitle) + .foregroundStyle(.red) + Text(message) + .font(.footnote) + .multilineTextAlignment(.center) + Button("OK") { dismiss() } + } + } + } + .frame(maxWidth: .infinity) + } + .navigationTitle(request.title) + .navigationBarBackButtonHidden(phase != .confirm) + } + + private var summary: some View { + VStack(spacing: 2) { + ForEach(request.lines, id: \.self) { line in + Text(line) + .font(.headline) + .multilineTextAlignment(.center) + } + } + } + + private var crownConfirm: some View { + VStack(spacing: 4) { + ZStack { + Circle() + .stroke(Color.gray.opacity(0.3), lineWidth: 6) + Circle() + .trim(from: 0, to: crownProgress) + .stroke(Color.blue, style: StrokeStyle(lineWidth: 6, lineCap: .round)) + .rotationEffect(.degrees(-90)) + Image(systemName: CrownSymbol.increase) + .font(.title3) + } + .frame(width: 56, height: 56) + Text("Turn the Digital Crown to confirm") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .focusable() + .focused($crownFocused) + .digitalCrownRotation($crownProgress, from: 0, through: 1, by: 0.02, sensitivity: .low, isContinuous: false, isHapticFeedbackEnabled: true) + .onAppear { crownFocused = true } + .onChange(of: crownProgress) { _, value in + lastCrownChange = Date() + if value >= 1 { + WKInterfaceDevice.current().play(.success) + Task { await authenticateAndSend() } + } else { + resetCrownIfIdle() + } + } + } + + /// Partial turns reset after a pause, so a bump of the crown never adds up to a confirmation. + private func resetCrownIfIdle() { + let changedAt = lastCrownChange + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + if phase == .confirm, lastCrownChange == changedAt, crownProgress < 1 { + withAnimation { crownProgress = 0 } + } + } + } + + private func authenticateAndSend() async { + switch await Self.authenticate() { + case .success: + await send() + case let .failure(message): + crownProgress = 0 + phase = .done(success: false, message: message) + WKInterfaceDevice.current().play(.failure) + } + } + + private func send() async { + phase = .sending + let reply = await session.send(request.action) + phase = .done(success: reply.success, message: reply.message) + WKInterfaceDevice.current().play(reply.success ? .success : .failure) + if reply.success { + try? await Task.sleep(nanoseconds: UInt64(sentDisplayTime * 1_000_000_000)) + navigator.popToRoot() + } + } + + private enum AuthOutcome { + case success + case failure(String) + } + + /// Requires a passcode-protected watch that is unlocked on the wrist. + private static func authenticate() async -> AuthOutcome { + #if targetEnvironment(simulator) + return .success + #else + let context = LAContext() + var error: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithWristDetection, error: &error) else { + return .failure("Insulin can only be sent from a watch with a passcode and Wrist Detection turned on.") + } + do { + let ok = try await context.evaluatePolicy(.deviceOwnerAuthenticationWithWristDetection, localizedReason: "Confirm to send insulin") + return ok ? .success : .failure("Authentication failed.") + } catch { + return .failure("Authentication failed.") + } + #endif + } +} diff --git a/LoopFollowWatch/Remote/MealEntryView.swift b/LoopFollowWatch/Remote/MealEntryView.swift new file mode 100644 index 000000000..504e06d0a --- /dev/null +++ b/LoopFollowWatch/Remote/MealEntryView.swift @@ -0,0 +1,151 @@ +// LoopFollow +// MealEntryView.swift + +import SwiftUI + +/// Carbs, and for Trio fat, protein and a bolus per the phone's Meal settings, +/// as on the phone; the time behind a button. Everything goes as one command. +/// The crown scrolls the page until a field is tapped. +struct MealEntryView: View { + let remote: WatchRemoteConfig + + @EnvironmentObject private var navigator: WatchNavigator + @State private var carbs = 0.0 + @State private var fat = 0.0 + @State private var protein = 0.0 + @State private var bolus = 0.0 + @State private var offsetMinutes = 0.0 + @State private var showTime = false + @FocusState private var focus: Field? + + private enum Field: Hashable { + case carbs, fat, protein, bolus, time + } + + private var bolusStep: Double { BolusAmountView.step(remote) } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 6) { + quickPicks + + field("Carbs", .carbs) { + AmountField(value: $carbs, unit: "g", step: 1, maximum: remote.maxCarbs, isHighlighted: focus == .carbs, format: grams) + } + + if remote.mealWithFatProtein { + field("Fat", .fat) { + AmountField(value: $fat, unit: "g", step: 1, maximum: remote.maxFat, isHighlighted: focus == .fat, format: grams) + } + field("Protein", .protein) { + AmountField(value: $protein, unit: "g", step: 1, maximum: remote.maxProtein, isHighlighted: focus == .protein, format: grams) + } + } + if remote.mealWithBolus { + field("Bolus", .bolus) { + AmountField(value: $bolus, unit: "U", step: bolusStep, maximum: remote.maxBolus, isHighlighted: focus == .bolus, sensitivity: .low, format: WatchFormat.insulin) + } + } + if showTime { + field("Time", .time) { + AmountField(value: $offsetMinutes, unit: "min", step: 5, minimum: -120, maximum: 60, isHighlighted: focus == .time, format: timeText) + } + } else { + Button { + showTime = true + focus = .time + } label: { + Label("Time", systemImage: "clock") + .font(.footnote) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity) + } + + Button { + navigator.push(.confirm(request(carbs: carbs, fat: fat, protein: protein, bolus: bolus, offsetMinutes: offsetMinutes))) + } label: { + Text(logTitle) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .tint(.blue) + .disabled(carbs <= 0 && fat <= 0 && protein <= 0) + } + } + .navigationTitle("Meal") + } + + /// The phone's Quick-Pick meals, limited to what the phone's Meal settings allow. + @ViewBuilder + private var quickPicks: some View { + let picks = remote.quickPickMeals.filter { $0.carbs > 0 && $0.carbs <= remote.maxCarbs } + if !picks.isEmpty { + ForEach(Array(picks.enumerated()), id: \.offset) { _, pick in + let fat = remote.mealWithFatProtein ? pick.fat : 0 + let protein = remote.mealWithFatProtein ? pick.protein : 0 + let bolus = remote.mealWithBolus && pick.bolus <= remote.maxBolus ? pick.bolus : 0 + Button { + navigator.push(.confirm(request(carbs: pick.carbs, fat: fat, protein: protein, bolus: bolus, offsetMinutes: 0))) + } label: { + Text(pickTitle(carbs: pick.carbs, fat: fat, protein: protein, bolus: bolus)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity) + } + } + } + + private func field(_ label: String, _ id: Field, content: () -> AmountField) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.footnote) + .foregroundStyle(.secondary) + content() + .focused($focus, equals: id) + .onTapGesture { focus = id } + } + } + + private var logTitle: String { + if bolus > 0 { return "Log \(Int(carbs)) g + \(WatchFormat.insulin(bolus)) U" } + return "Log \(Int(carbs)) g" + } + + private func pickTitle(carbs: Double, fat: Double, protein: Double, bolus: Double) -> String { + var parts = ["\(Int(carbs)) g"] + if fat > 0 || protein > 0 { parts.append("F \(Int(fat)) P \(Int(protein))") } + if bolus > 0 { parts.append("\(WatchFormat.insulin(bolus)) U") } + return parts.joined(separator: " Β· ") + } + + private func grams(_ value: Double) -> String { + "\(Int(value))" + } + + private func timeText(_ minutes: Double) -> String { + let m = Int(minutes) + if m == 0 { return "Now" } + return m > 0 ? "+\(m)" : "\(m)" + } + + private func request(carbs: Double, fat: Double, protein: Double, bolus: Double, offsetMinutes: Double) -> CommandRequest { + var lines: [String] = [] + if carbs > 0 { lines.append("Carbs \(Int(carbs)) g") } + if fat > 0 { lines.append("Fat \(Int(fat)) g") } + if protein > 0 { lines.append("Protein \(Int(protein)) g") } + if bolus > 0 { lines.append("Bolus \(WatchFormat.insulin(bolus)) U") } + let at = Date().addingTimeInterval(offsetMinutes * 60) + lines.append(offsetMinutes == 0 ? "Now" : at.formatted(date: .omitted, time: .shortened)) + if bolus > 0, offsetMinutes != 0 { + // Same note as the phone's meal screen. + lines.append("The meal will be scheduled, but the bolus is enacted immediately.") + } + return CommandRequest( + title: "Meal", + lines: lines, + action: .meal(carbs: carbs, fat: fat, protein: protein, bolus: bolus, consumedAt: at), + deliversInsulin: bolus > 0 + ) + } +} diff --git a/LoopFollowWatch/Remote/PresetListView.swift b/LoopFollowWatch/Remote/PresetListView.swift new file mode 100644 index 000000000..71846db82 --- /dev/null +++ b/LoopFollowWatch/Remote/PresetListView.swift @@ -0,0 +1,77 @@ +// LoopFollow +// PresetListView.swift + +import SwiftUI + +/// Override or temp target presets synced from the phone, with a cancel row +/// while one is active. +struct PresetListView: View { + enum Kind { + case override + case tempTarget + } + + let kind: Kind + let state: WatchState + let presets: [WatchPreset] + + @EnvironmentObject private var navigator: WatchNavigator + + private var activeText: String? { + guard let snapshot = state.snapshot else { return nil } + switch kind { + case .override: + return snapshot.override + case .tempTarget: + return snapshot.tempTargetMgdl.map { WatchFormat.glucose($0, unit: snapshot.unit) } + } + } + + private var title: String { kind == .override ? "Overrides" : "Temp Target" } + + var body: some View { + List { + if let activeText { + Section("Active") { + Button { + navigator.push(.confirm(CommandRequest( + title: kind == .override ? "Cancel Override" : "Cancel Temp Target", + lines: ["Cancel \(activeText)?"], + action: kind == .override ? .cancelOverride : .cancelTempTarget, + deliversInsulin: false + ))) + } label: { + Label(activeText, systemImage: "xmark.circle") + .foregroundStyle(.red) + } + } + } + + Section("Presets") { + if presets.isEmpty { + Text(kind == .override ? "No override presets found." : "No temp target presets. Add them in LoopFollow on the iPhone.") + .font(.footnote) + .foregroundStyle(.secondary) + } + ForEach(presets) { preset in + Button { + navigator.push(.confirm(CommandRequest( + title: title, + lines: [preset.name, preset.detail], + action: kind == .override ? .startOverride(presetID: preset.id) : .startTempTarget(presetID: preset.id), + deliversInsulin: false + ))) + } label: { + VStack(alignment: .leading) { + Text(preset.name) + Text(preset.detail) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + } + .navigationTitle(title) + } +} diff --git a/LoopFollowWatch/Remote/TreatmentMenuView.swift b/LoopFollowWatch/Remote/TreatmentMenuView.swift new file mode 100644 index 000000000..830648630 --- /dev/null +++ b/LoopFollowWatch/Remote/TreatmentMenuView.swift @@ -0,0 +1,24 @@ +// LoopFollow +// TreatmentMenuView.swift + +import SwiftUI + +struct TreatmentMenuView: View { + @EnvironmentObject private var navigator: WatchNavigator + + var body: some View { + List { + Button { + navigator.push(.meal) + } label: { + Label("Meal", systemImage: "fork.knife") + } + Button { + navigator.push(.bolus) + } label: { + Label("Bolus", systemImage: "syringe") + } + } + .navigationTitle("Treatment") + } +} diff --git a/LoopFollowWatch/StandaloneFetcher.swift b/LoopFollowWatch/StandaloneFetcher.swift new file mode 100644 index 000000000..ea13ad588 --- /dev/null +++ b/LoopFollowWatch/StandaloneFetcher.swift @@ -0,0 +1,183 @@ +// LoopFollow +// StandaloneFetcher.swift + +import Foundation + +/// Builds the watch state straight from Nightscout or Dexcom while the phone is +/// out of reach. What only the phone knows (info rows, presets, guardrails, +/// thresholds) is kept from the last state the phone sent. +enum StandaloneFetcher { + enum FetchError: LocalizedError { + case noDataSource + case badResponse + + var errorDescription: String? { + switch self { + case .noDataSource: "No Nightscout or Dexcom account on the watch yet." + case .badResponse: "Unexpected response from the data source." + } + } + } + + private static let history: TimeInterval = 24 * 3600 + + static func fetch(credentials: WatchCredentials, base: WatchState?, now: Date = Date()) async throws -> WatchState { + if !credentials.nightscoutURL.isEmpty { + return try await fetchNightscout(credentials: credentials, base: base, now: now) + } + if !credentials.dexcomUsername.isEmpty, !credentials.dexcomPassword.isEmpty { + return try await fetchDexcom(credentials: credentials, base: base, now: now) + } + throw FetchError.noDataSource + } + + // MARK: - Nightscout + + private static func fetchNightscout(credentials: WatchCredentials, base: WatchState?, now: Date) async throws -> WatchState { + let since = now.addingTimeInterval(-history) + async let entries = get(credentials, "/api/v1/entries.json", [ + "find[date][$gte]": String(Int(since.timeIntervalSince1970 * 1000)), + "find[type][$ne]": "cal", + "count": "400", + ]) + async let deviceStatus = get(credentials, "/api/v1/devicestatus.json", ["count": "1"]) + async let treatments = get(credentials, "/api/v1/treatments.json", [ + "find[created_at][$gte]": DeviceStatusSummary.iso.string(from: since), + "count": "500", + ]) + + // Nightscout dates are milliseconds; the rest of the app works in seconds, as on the phone. + let glucose = try JSONDecoder().decode([ShareGlucoseData].self, from: await entries) + .filter { $0.sgv > 0 } + .map { ShareGlucoseData(sgv: $0.sgv, date: $0.date / 1000, direction: $0.direction) } + .sorted { $0.date < $1.date } + let status = (try? JSONSerialization.jsonObject(with: await deviceStatus) as? [[String: Any]])?.first + let treatmentList = (try? JSONSerialization.jsonObject(with: await treatments) as? [[String: Any]]) ?? [] + + return build(glucose: glucose, status: status.map { DeviceStatusSummary.parse($0, showsCone: base?.cone.isEmpty == false) }, treatments: treatmentList, base: base, now: now) + } + + private static func get(_ credentials: WatchCredentials, _ path: String, _ parameters: [String: String]) async throws -> Data { + var components = URLComponents(string: credentials.nightscoutURL) + components?.path = path + var items = parameters.map { URLQueryItem(name: $0.key, value: $0.value) } + if !credentials.nightscoutToken.isEmpty { + items.append(URLQueryItem(name: "token", value: credentials.nightscoutToken)) + } + components?.queryItems = items + guard let url = components?.url else { throw FetchError.badResponse } + var request = URLRequest(url: url) + request.timeoutInterval = 20 + let (data, response) = try await URLSession.shared.data(for: request) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw FetchError.badResponse } + return data + } + + // MARK: - Dexcom + + private static func fetchDexcom(credentials: WatchCredentials, base: WatchState?, now: Date) async throws -> WatchState { + let server = credentials.dexcomServer == "US" ? KnownShareServers.US.rawValue : KnownShareServers.NON_US.rawValue + let client = ShareClient(username: credentials.dexcomUsername, password: credentials.dexcomPassword, shareServer: server) + let glucose: [ShareGlucoseData] = try await withCheckedThrowingContinuation { continuation in + client.fetchData(288) { error, data in + if let data { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: error ?? FetchError.badResponse) + } + } + } + return build(glucose: glucose.sorted { $0.date < $1.date }, status: nil, treatments: [], base: base, now: now) + } + + // MARK: - Building + + private static func build(glucose: [ShareGlucoseData], status: DeviceStatusSummary?, treatments: [[String: Any]], base: WatchState?, now: Date) -> WatchState { + let cutoff = now.timeIntervalSince1970 - history + let readings = glucose.filter { $0.date >= cutoff }.map { WatchReading(t: $0.date, v: Double($0.sgv)) } + let latest = glucose.last + let previous = glucose.dropLast().last + + let active = activeAdjustments(treatments, now: now) + var snapshot: GlucoseSnapshot? + if let latest { + let loopAge = status?.loopAt.map { now.timeIntervalSince($0) } + snapshot = GlucoseSnapshot( + glucose: Double(latest.sgv), + delta: previous.map { Double(latest.sgv - $0.sgv) } ?? 0, + trend: GlucoseSnapshot.Trend(code: latest.direction), + updatedAt: Date(timeIntervalSince1970: latest.date), + iob: status?.iob, + cob: status?.cob, + projected: nil, + override: active.override, + tempTargetMgdl: active.tempTargetMgdl, + recBolus: status?.recommendedBolus, + unit: base?.snapshot?.unit ?? .mgdl, + isNotLooping: loopAge.map { $0 >= 15 * 60 } ?? false + ) + } + + var remote = base?.remote + if remote != nil { + remote?.recommendedBolus = status?.recommendedBolus + remote?.recommendedBolusAt = status?.loopAt + } + + return WatchState( + builtAt: now, + source: .watch, + phoneBuiltAt: base?.phoneBuiltAt ?? .distantPast, + snapshot: snapshot, + readings: readings, + predictions: status?.predictions ?? [], + cone: status?.cone ?? [], + boluses: treatments.compactMap { reading($0, key: "insulin", cutoff: cutoff) }.sorted { $0.t < $1.t }, + carbs: treatments.compactMap { reading($0, key: "carbs", cutoff: cutoff) }.sorted { $0.t < $1.t }, + lowMgdl: base?.lowMgdl ?? 70, + highMgdl: base?.highMgdl ?? 180, + colorBGText: base?.colorBGText ?? true, + infoRows: base?.infoRows ?? [], + stats: base?.stats, + device: base?.device ?? "", + alarm: nil, + remote: remote + ) + } + + private static func reading(_ treatment: [String: Any], key: String, cutoff: TimeInterval) -> WatchReading? { + guard let value = treatment[key] as? Double, value > 0, + let at = DeviceStatusSummary.date(treatment["timestamp"] ?? treatment["created_at"]), + at.timeIntervalSince1970 >= cutoff + else { return nil } + return WatchReading(t: at.timeIntervalSince1970, v: value) + } + + /// The override and temp target that are running now, from the latest of each in the treatments. + private static func activeAdjustments(_ treatments: [[String: Any]], now: Date) -> (override: String?, tempTargetMgdl: Double?) { + func latest(_ eventType: String) -> (start: Date, entry: [String: Any])? { + treatments + .filter { $0["eventType"] as? String == eventType } + .compactMap { entry in DeviceStatusSummary.date(entry["timestamp"] ?? entry["created_at"]).map { ($0, entry) } } + .max { $0.0 < $1.0 } + } + + var override: String? + if let (start, entry) = latest("Temporary Override") { + let minutes = entry["duration"] as? Double ?? 0 + let indefinite = minutes == 0 || (entry["durationType"] as? String) == "indefinite" + if start <= now, indefinite || now < start.addingTimeInterval(minutes * 60) { + override = entry["reason"] as? String ?? entry["notes"] as? String ?? "Override" + } + } + + var tempTarget: Double? + if let (start, entry) = latest("Temporary Target") { + let minutes = entry["duration"] as? Double ?? 0 + if minutes > 0, start <= now, now < start.addingTimeInterval(minutes * 60) { + tempTarget = entry["targetTop"] as? Double ?? entry["targetBottom"] as? Double + } + } + return (override, tempTarget) + } +} diff --git a/LoopFollowWatch/StatsPage.swift b/LoopFollowWatch/StatsPage.swift new file mode 100644 index 000000000..aa9057e2c --- /dev/null +++ b/LoopFollowWatch/StatsPage.swift @@ -0,0 +1,69 @@ +// LoopFollow +// StatsPage.swift + +import Charts +import SwiftUI + +/// The statistics panel from under the phone's chart: the same pie and values. +struct StatsPage: View { + let state: WatchState + let stats: WatchStats + + var body: some View { + VStack(spacing: 8) { + Chart(slices, id: \.0) { slice in + SectorMark(angle: .value("share", slice.1)) + .foregroundStyle(slice.2) + } + .chartLegend(.hidden) + // Shrinks so the page fits the smaller watch sizes without scrolling. + .frame(minHeight: 30, maxHeight: 70) + .accessibilityHidden(true) + + Grid(horizontalSpacing: 4, verticalSpacing: 6) { + GridRow { + value("Low:", stats.lowPercent) + value("In Range:", stats.inRangePercent) + value("High:", stats.highPercent) + } + GridRow { + value("Avg BG:", stats.avgBG) + value(stats.estA1CTitle, stats.estA1C) + value(stats.stdDevTitle, stats.stdDev) + } + } + .layoutPriority(1) + + if state.source == .watch { + Text("From the iPhone at \(state.phoneBuiltAt.formatted(date: .omitted, time: .shortened))") + .font(.caption2) + .foregroundStyle(.orange) + .layoutPriority(1) + } + } + // Keeps the values clear of the page indicator. + .padding(.trailing, 10) + .navigationTitle("Statistics") + } + + /// Same colors as the phone's pie: red low, green in range, yellow high. + private var slices: [(String, Double, Color)] { + [ + ("low", max(stats.pieLow, 0.1), .red), + ("range", max(stats.pieRange, 0.1), .green), + ("high", max(stats.pieHigh, 0.1), .yellow), + ] + } + + private func value(_ title: String, _ value: String) -> some View { + VStack(spacing: 0) { + Text(title) + .foregroundStyle(.secondary) + Text(value.isEmpty ? "β€”" : value) + } + .font(.caption2) + .lineLimit(1) + .minimumScaleFactor(0.6) + .frame(maxWidth: .infinity) + } +} diff --git a/LoopFollowWatch/WatchChartView.swift b/LoopFollowWatch/WatchChartView.swift new file mode 100644 index 000000000..71cc3225f --- /dev/null +++ b/LoopFollowWatch/WatchChartView.swift @@ -0,0 +1,199 @@ +// LoopFollow +// WatchChartView.swift + +import Charts +import SwiftUI + +/// BG chart in the phone's colors: readings colored by range, dashed low/high +/// lines, prediction lines or cone in their phone colors, boluses and carbs as markers, +/// and overrides as a green band along the top. +struct WatchChartView: View { + let state: WatchState + let now: Date + let spanHours: Double + /// How far back the chart is scrolled; nil when not scrubbing. + var scrubOffset: TimeInterval? + + static let spans: [Double] = [1, 3, 6, 12, 24] + + private var unit: GlucoseSnapshot.Unit { state.snapshot?.unit ?? .mgdl } + + var body: some View { + let window = visibleWindow + let readings = state.readings.filter { window.contains($0.date) } + let predictions = state.predictions.map { line in + (line.kind, line.points.filter { window.contains($0.date) }) + } + let cone = state.cone.filter { window.contains($0.date) } + let overrides = (state.overrides ?? []).filter { $0.endDate > window.lowerBound && $0.startDate < window.upperBound } + let top = yTop(readings: readings, predictions: predictions.flatMap(\.1) + cone.map { WatchReading(t: $0.t, v: $0.high) }) + + Chart { + RuleMark(y: .value("High", state.highMgdl)) + .foregroundStyle(Color.yellow.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) + RuleMark(y: .value("Low", state.lowMgdl)) + .foregroundStyle(Color.red.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) + RuleMark(x: .value("Now", now)) + .foregroundStyle(Color.gray.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1)) + + ForEach(cone, id: \.t) { point in + AreaMark( + x: .value("Time", point.date), + yStart: .value("Low", point.low), + yEnd: .value("High", min(point.high, top)) + ) + .foregroundStyle(Color.blue.opacity(0.4)) + } + + // A thin band along the top edge, like the override strip on the phone's chart. + ForEach(overrides, id: \.start) { override in + RectangleMark( + xStart: .value("Start", max(override.startDate, window.lowerBound)), + xEnd: .value("End", min(override.endDate, window.upperBound)), + y: .value("Override", top), + height: .fixed(4) + ) + .foregroundStyle(Color.green.opacity(0.8)) + } + + if let marker = markerDate { + RuleMark(x: .value("Marker", marker)) + .foregroundStyle(Color.white.opacity(0.8)) + .lineStyle(StrokeStyle(lineWidth: 1)) + } + + ForEach(predictions, id: \.0) { kind, points in + ForEach(points, id: \.t) { point in + LineMark( + x: .value("Time", point.date), + y: .value("BG", min(point.v, top)), + series: .value("Series", kind.rawValue) + ) + .foregroundStyle(Self.color(for: kind)) + .lineStyle(StrokeStyle(lineWidth: 1.5)) + } + } + + ForEach(readings, id: \.t) { reading in + PointMark( + x: .value("Time", reading.date), + y: .value("BG", min(reading.v, top)) + ) + .symbolSize(spanHours <= 3 ? 14 : 6) + .foregroundStyle(WatchFormat.bgColor(reading.v, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl)) + } + + ForEach(state.carbs.filter { window.contains($0.date) }, id: \.t) { carb in + PointMark( + x: .value("Time", carb.date), + y: .value("BG", max(markerY(at: carb.date, top: top) - 30, 45)) + ) + .symbolSize(30) + .foregroundStyle(Color.orange.opacity(0.85)) + } + + ForEach(state.boluses.filter { window.contains($0.date) }, id: \.t) { bolus in + PointMark( + x: .value("Time", bolus.date), + y: .value("BG", markerY(at: bolus.date, top: top) + 30) + ) + .symbol(.triangle) + .symbolSize(24) + .foregroundStyle(Color.blue.opacity(0.85)) + } + } + .chartXScale(domain: window.lowerBound ... window.upperBound) + .chartYScale(domain: 39 ... top) + .chartXAxis { + AxisMarks(values: hourTicks(in: window)) { _ in + AxisGridLine().foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel(format: .dateTime.hour()) + } + } + .chartYAxis { + AxisMarks(position: .trailing, values: yAxisValues(top: top)) { value in + AxisGridLine().foregroundStyle(Color.gray.opacity(0.3)) + AxisValueLabel { + if let mgdl = value.as(Double.self) { + Text(WatchFormat.glucose(mgdl, unit: unit)) + } + } + } + } + .accessibilityLabel("BG chart, last \(Int(spanHours)) hours.") + } + + private var visibleWindow: ClosedRange { + Self.window(state: state, now: now, spanHours: spanHours, scrubOffset: scrubOffset ?? 0) + } + + private var markerDate: Date? { + scrubOffset.map { Self.markerDate(state: state, now: now, spanHours: spanHours, scrubOffset: $0) } + } + + /// The visible span ends a little after now so the prediction stays visible; + /// while scrubbing it moves back by the scroll offset. + static func window(state: WatchState, now: Date, spanHours: Double, scrubOffset: TimeInterval) -> ClosedRange { + let span = spanHours * 3600 + let lookAhead = state.predictions.isEmpty && state.cone.isEmpty ? span * 0.05 : span * 0.25 + let end = now.addingTimeInterval(lookAhead - scrubOffset) + return end.addingTimeInterval(-span) ... end + } + + /// While scrubbing, the time the marker points at: three quarters across the chart. + static func markerDate(state: WatchState, now: Date, spanHours: Double, scrubOffset: TimeInterval) -> Date { + window(state: state, now: now, spanHours: spanHours, scrubOffset: scrubOffset).upperBound + .addingTimeInterval(-spanHours * 3600 * 0.25) + } + + /// Whole hours in the window, leaving out those too close to an edge for their label to fit. + private func hourTicks(in window: ClosedRange) -> [Date] { + let step = spanHours > 6 ? 6 : (spanHours > 3 ? 2 : 1) + let margin = (window.upperBound.timeIntervalSince(window.lowerBound)) * 0.06 + let calendar = Calendar.current + guard var tick = calendar.dateInterval(of: .hour, for: window.lowerBound)?.end else { return [] } + var ticks: [Date] = [] + while tick < window.upperBound { + let hour = calendar.component(.hour, from: tick) + if hour % step == 0, + tick.timeIntervalSince(window.lowerBound) > margin, + window.upperBound.timeIntervalSince(tick) > margin + { + ticks.append(tick) + } + tick = tick.addingTimeInterval(3600) + } + return ticks + } + + private func yTop(readings: [WatchReading], predictions: [WatchReading]) -> Double { + let highest = max(readings.map(\.v).max() ?? 0, predictions.map(\.v).max() ?? 0) + return min(max(250, highest + 20), 400) + } + + private func yAxisValues(top: Double) -> [Double] { + let steps: [Double] = unit == .mmol + ? [72, 144, 216, 288, 360].filter { $0 < top } + : [100, 200, 300].filter { $0 < top } + return steps + } + + /// Markers sit next to the BG reading closest in time: boluses above, carbs below, as on the phone. + private func markerY(at date: Date, top: Double) -> Double { + let nearest = state.readings.min { abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date)) } + return min(nearest?.v ?? 100, top - 30) + } + + static func color(for kind: WatchPredictionLine.Kind) -> Color { + switch kind { + case .loop: .purple + case .zt: Color(red: 0.443, green: 0.380, blue: 0.937) + case .iob: Color(red: 0.118, green: 0.588, blue: 0.988) + case .cob: .orange + case .uam: Color(red: 0.820, green: 0.169, blue: 0.969) + } + } +} diff --git a/LoopFollowWatch/WatchKeychain.swift b/LoopFollowWatch/WatchKeychain.swift new file mode 100644 index 000000000..3c53feb87 --- /dev/null +++ b/LoopFollowWatch/WatchKeychain.swift @@ -0,0 +1,46 @@ +// LoopFollow +// WatchKeychain.swift + +import Foundation +import Security + +/// Stores the credentials from the phone in the watch's Keychain. Items are +/// readable after the first unlock and never leave this watch, not even in a backup. +enum WatchKeychain { + private static let service = "LoopFollowWatch" + private static let account = "credentials" + + private static var query: [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } + + static func save(_ credentials: WatchCredentials) { + guard let data = try? JSONEncoder().encode(credentials) else { return } + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + var status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + status = SecItemAdd(query.merging(attributes) { $1 } as CFDictionary, nil) + } + if status != errSecSuccess { + LogManager.shared.log(category: .general, message: "Keychain save failed: \(status)") + } + } + + static func load() -> WatchCredentials? { + var search = query + search[kSecReturnData as String] = true + search[kSecMatchLimit as String] = kSecMatchLimitOne + var result: AnyObject? + guard SecItemCopyMatching(search as CFDictionary, &result) == errSecSuccess, + let data = result as? Data + else { return nil } + return try? JSONDecoder().decode(WatchCredentials.self, from: data) + } +} diff --git a/LoopFollowWatch/WatchSession.swift b/LoopFollowWatch/WatchSession.swift new file mode 100644 index 000000000..c6101921b --- /dev/null +++ b/LoopFollowWatch/WatchSession.swift @@ -0,0 +1,269 @@ +// LoopFollow +// WatchSession.swift + +import Foundation +import WatchConnectivity +import WatchKit +import WidgetKit + +/// Watch side of the phone link. The phone is the main source: it sends +/// `WatchState` and relays commands. While the phone's data is stale and the +/// phone does not answer, the watch fetches from Nightscout or Dexcom itself +/// and sends commands directly. +@MainActor +final class WatchSession: NSObject, ObservableObject { + static let shared = WatchSession() + + /// Phone data older than this hands over to the watch's own fetching. + static let phoneStaleAfter: TimeInterval = 10 * 60 + /// Minimum time between two fetches made by the watch itself. + private static let standaloneInterval: TimeInterval = 60 + + @Published private(set) var state: WatchState? + @Published private(set) var isReachable = false + + private var session: WCSession { WCSession.default } + private var credentials: WatchCredentials? + private var lastPhoneBuiltAt: Date + private var lastStandaloneAttempt: Date = .distantPast + + override private init() { + let stored = WatchStateStore.load() + state = stored + lastPhoneBuiltAt = stored?.phoneBuiltAt ?? .distantPast + credentials = WatchKeychain.load() + super.init() + } + + func activate() { + guard WCSession.isSupported(), session.delegate == nil else { return } + session.delegate = self + session.activate() + } + + /// Commands go through the phone, or directly when the phone sent what that needs. + var canSendCommands: Bool { + isReachable || credentials?.remote != nil + } + + /// True while the phone's latest state is recent enough to rely on. + var isPhoneDataFresh: Bool { + Date().timeIntervalSince(lastPhoneBuiltAt) < Self.phoneStaleAfter + } + + /// Asks the phone for fresh state; the application context covers the case + /// where the phone is out of reach. + func requestState() { + guard session.activationState == .activated, session.isReachable else { return } + session.sendMessage([WatchMessageKey.requestState: true], replyHandler: { reply in + guard let data = reply[WatchState.contextKey] as? Data else { return } + Task { @MainActor in self.applyFromPhone(data) } + }, errorHandler: nil) + } + + /// Fetches from Nightscout or Dexcom when the phone's data is stale and the phone does not answer. + func refreshIfNeeded() async { + guard !isPhoneDataFresh else { return } + if session.activationState == .activated, session.isReachable { + requestState() + try? await Task.sleep(nanoseconds: 8_000_000_000) + guard !isPhoneDataFresh else { return } + } + guard let credentials, credentials.hasDataSource else { + LogManager.shared.log(category: .general, message: "Phone data is stale, but the watch has no data source credentials yet") + return + } + guard Date().timeIntervalSince(lastStandaloneAttempt) >= Self.standaloneInterval else { return } + lastStandaloneAttempt = Date() + do { + let fetched = try await StandaloneFetcher.fetch(credentials: credentials, base: state) + guard !isPhoneDataFresh else { return } + store(fetched) + LogManager.shared.log(category: .nightscout, message: "Standalone fetch: \(fetched.readings.count) readings") + } catch { + LogManager.shared.log(category: .nightscout, message: "Standalone fetch failed: \(error.localizedDescription)") + } + } + + /// Sends a command through the phone when it is reachable, otherwise directly. + /// Falls back to sending directly only when the phone turned out to be out of + /// reach before the command was delivered; a command that may have reached + /// the phone is never sent a second time. + func send(_ action: WatchCommand.Action) async -> WatchCommandReply { + if session.activationState == .activated, session.isReachable { + switch await sendThroughPhone(action) { + case let .replied(reply): return reply + case .notDelivered: break + } + } + if case .snoozeAlarm = action { + return WatchCommandReply(success: false, message: "Snoozing needs the iPhone, which is not reachable right now.") + } + guard let credentials, let config = state?.remote else { + return WatchCommandReply(success: false, message: "iPhone not reachable. Open LoopFollow on the iPhone and keep it near the watch.") + } + return await DirectCommandSender.send(action, credentials: credentials, config: config) + } + + private enum PhoneSendResult { + case replied(WatchCommandReply) + /// WatchConnectivity reported the phone unreachable, so the command never left the watch. + case notDelivered + } + + private func sendThroughPhone(_ action: WatchCommand.Action) async -> PhoneSendResult { + guard let data = try? JSONEncoder().encode(WatchCommand(action: action)) else { + return .replied(WatchCommandReply(success: false, message: "Could not encode the command.")) + } + let noReply = WatchCommandReply( + success: false, + message: "No reply from the iPhone. The command may still have been sent: check Loop or Trio before trying again." + ) + return await withCheckedContinuation { continuation in + let once = ResumeOnce(continuation) + session.sendMessage([WatchCommand.messageKey: data], replyHandler: { reply in + let result = (reply[WatchCommandReply.messageKey] as? Data) + .flatMap { try? JSONDecoder().decode(WatchCommandReply.self, from: $0) } + once.resume(.replied(result ?? WatchCommandReply(success: false, message: "Unexpected reply from the iPhone."))) + }, errorHandler: { error in + once.resume((error as? WCError)?.code == .notReachable ? .notDelivered : .replied(noReply)) + }) + DispatchQueue.main.asyncAfter(deadline: .now() + Self.phoneReplyTimeout) { + once.resume(.replied(noReply)) + } + } + } + + /// Longest wait for the phone's verdict before telling the user it is unknown. + private static let phoneReplyTimeout: TimeInterval = 30 + + /// Lets a WatchConnectivity background task finish once queued data has been delivered. + func waitForPendingContent() async { + activate() + for _ in 0 ..< 40 { + if session.activationState == .activated, !session.hasContentPending { return } + try? await Task.sleep(nanoseconds: 500_000_000) + } + } + + static let backgroundRefreshID = "standalone" + + /// Schedules the next background check for stale phone data. + func scheduleBackgroundRefresh() { + WKApplication.shared().scheduleBackgroundRefresh( + withPreferredDate: Date().addingTimeInterval(15 * 60), + userInfo: Self.backgroundRefreshID as NSString + ) { _ in } + } + + private func applyFromPhone(_ data: Data) { + guard let incoming = WatchState.decode(data), incoming.builtAt > lastPhoneBuiltAt else { return } + lastPhoneBuiltAt = incoming.builtAt + store(incoming) + } + + private func store(_ incoming: WatchState) { + guard let encoded = incoming.encoded() else { return } + let newAlarm = incoming.alarm != nil && incoming.alarm?.id != state?.alarm?.id + let complicationChanged = Self.complicationKey(incoming) != state.map(Self.complicationKey) + state = incoming + WatchStateStore.save(encoded) + // Widget reloads are budgeted, so only reload when something the complication shows has changed. + if complicationChanged { + WidgetCenter.shared.reloadAllTimelines() + } + + if newAlarm, WKApplication.shared().applicationState == .active { + WKInterfaceDevice.current().play(.notification) + } + } + + private static func complicationKey(_ state: WatchState) -> String { + let s = state.snapshot + return "\(s?.updatedAt.timeIntervalSince1970 ?? 0)|\(s?.unit.rawValue ?? "")|\(state.lowMgdl)|\(state.highMgdl)|\(state.colorBGText)" + } + + private func receiveCredentials(_ data: Data) { + guard let received = try? JSONDecoder().decode(WatchCredentials.self, from: data) else { + LogManager.shared.log(category: .general, message: "Could not decode credentials from the phone") + return + } + credentials = received + WatchKeychain.save(received) + LogManager.shared.log(category: .general, message: "Credentials received (remote commands: \(received.remote != nil))") + } + + /// Asks the phone for credentials when the watch has none, e.g. after a reinstall. + private func requestCredentialsIfMissing() { + guard credentials == nil, session.activationState == .activated else { return } + if session.isReachable { + session.sendMessage([WatchCredentials.requestKey: true], replyHandler: { reply in + guard let data = reply[WatchCredentials.userInfoKey] as? Data else { return } + Task { @MainActor in self.receiveCredentials(data) } + }, errorHandler: nil) + } else { + session.transferUserInfo([WatchCredentials.requestKey: true]) + } + } +} + +extension WatchSession: WCSessionDelegate { + nonisolated func session(_ session: WCSession, activationDidCompleteWith _: WCSessionActivationState, error _: Error?) { + let context = session.receivedApplicationContext[WatchState.contextKey] as? Data + let reachable = session.isReachable + Task { @MainActor in + if let context { self.applyFromPhone(context) } + self.isReachable = reachable + self.requestState() + self.requestCredentialsIfMissing() + } + } + + nonisolated func sessionReachabilityDidChange(_ session: WCSession) { + let reachable = session.isReachable + Task { @MainActor in + self.isReachable = reachable + if reachable { + self.requestState() + self.requestCredentialsIfMissing() + } + } + } + + nonisolated func session(_: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + guard let data = applicationContext[WatchState.contextKey] as? Data else { return } + Task { @MainActor in self.applyFromPhone(data) } + } + + nonisolated func session(_: WCSession, didReceiveMessage message: [String: Any]) { + guard let credentials = message[WatchCredentials.userInfoKey] as? Data else { return } + Task { @MainActor in self.receiveCredentials(credentials) } + } + + nonisolated func session(_: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { + let state = userInfo[WatchState.contextKey] as? Data + let credentials = userInfo[WatchCredentials.userInfoKey] as? Data + Task { @MainActor in + if let state { self.applyFromPhone(state) } + if let credentials { self.receiveCredentials(credentials) } + } + } +} + +/// Resumes a continuation at most once, from whichever callback comes first. +private final class ResumeOnce: @unchecked Sendable { + private var continuation: CheckedContinuation? + private let lock = NSLock() + + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func resume(_ value: T) { + lock.lock() + let pending = continuation + continuation = nil + lock.unlock() + pending?.resume(returning: value) + } +} diff --git a/LoopFollowWatchWidgets/GlucoseComplicationView.swift b/LoopFollowWatchWidgets/GlucoseComplicationView.swift new file mode 100644 index 000000000..7e42e5f29 --- /dev/null +++ b/LoopFollowWatchWidgets/GlucoseComplicationView.swift @@ -0,0 +1,132 @@ +// LoopFollow +// GlucoseComplicationView.swift + +import Charts +import SwiftUI +import WidgetKit + +struct GlucoseComplicationView: View { + let entry: GlucoseEntry + @Environment(\.widgetFamily) private var family + + private var snapshot: GlucoseSnapshot? { entry.state?.snapshot } + + private var isStale: Bool { + guard let snapshot else { return true } + return entry.date.timeIntervalSince(snapshot.updatedAt) >= WatchFormat.staleAfter + } + + private var bgText: String { + guard let snapshot else { return "--" } + return WatchFormat.glucose(snapshot.glucose, unit: snapshot.unit) + } + + private var bgColor: Color { + guard let state = entry.state, let snapshot else { return .secondary } + if isStale { return .secondary } + return WatchFormat.bgColor(snapshot.glucose, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl, colored: state.colorBGText) + } + + private var trendText: String { + snapshot.map { WatchFormat.trendArrow($0.trend) } ?? "" + } + + private var deltaText: String { + snapshot.map { WatchFormat.delta($0.delta, unit: $0.unit) } ?? "" + } + + private var ageText: String { + snapshot.map { WatchFormat.minutesAgo($0.updatedAt, now: entry.date) } ?? "" + } + + var body: some View { + switch family { + case .accessoryCircular: + circular + case .accessoryRectangular: + rectangular + case .accessoryCorner: + corner + default: + inline + } + } + + private var circular: some View { + VStack(spacing: -2) { + Text(bgText) + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(bgColor) + .strikethrough(isStale, color: .red) + .minimumScaleFactor(0.5) + Text(trendText) + .font(.system(size: 12, weight: .bold)) + Text(ageText) + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } + .widgetAccentable() + } + + private var rectangular: some View { + HStack(spacing: 6) { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text(bgText) + .font(.system(size: 26, weight: .bold)) + .foregroundStyle(bgColor) + .strikethrough(isStale, color: .red) + Text(trendText) + .font(.system(size: 14, weight: .bold)) + } + Text("\(deltaText) \(ageText)") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .lineLimit(1) + .minimumScaleFactor(0.6) + .widgetAccentable() + sparkline + } + } + + /// Last two hours of readings in range colors. + @ViewBuilder + private var sparkline: some View { + if let state = entry.state { + let start = entry.date.addingTimeInterval(-2 * 3600) + let readings = state.readings.filter { $0.date >= start } + Chart { + RuleMark(y: .value("High", state.highMgdl)) + .foregroundStyle(Color.yellow.opacity(0.4)) + .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [2, 2])) + RuleMark(y: .value("Low", state.lowMgdl)) + .foregroundStyle(Color.red.opacity(0.4)) + .lineStyle(StrokeStyle(lineWidth: 0.5, dash: [2, 2])) + ForEach(readings, id: \.t) { reading in + PointMark(x: .value("Time", reading.date), y: .value("BG", min(max(reading.v, 40), 300))) + .symbolSize(6) + .foregroundStyle(WatchFormat.bgColor(reading.v, lowMgdl: state.lowMgdl, highMgdl: state.highMgdl)) + } + } + .chartXScale(domain: start ... entry.date) + .chartYScale(domain: 40 ... 300) + .chartXAxis(.hidden) + .chartYAxis(.hidden) + } + } + + private var corner: some View { + Text(bgText) + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(bgColor) + .widgetCurvesContent() + .widgetLabel { + Text("\(trendText) \(deltaText) \(ageText)") + } + } + + private var inline: some View { + Text(snapshot == nil ? "LoopFollow --" : "\(bgText) \(trendText) \(deltaText) Β· \(ageText)") + } +} diff --git a/LoopFollowWatchWidgets/Info.plist b/LoopFollowWatchWidgets/Info.plist new file mode 100644 index 000000000..c8ec08987 --- /dev/null +++ b/LoopFollowWatchWidgets/Info.plist @@ -0,0 +1,29 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + $(display_name) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(LOOP_FOLLOW_MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/LoopFollowWatchWidgets/LoopFollowWatchWidgets.entitlements b/LoopFollowWatchWidgets/LoopFollowWatchWidgets.entitlements new file mode 100644 index 000000000..5b963cc90 --- /dev/null +++ b/LoopFollowWatchWidgets/LoopFollowWatchWidgets.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.$(unique_id).LoopFollow$(app_suffix) + + + diff --git a/LoopFollowWatchWidgets/LoopFollowWatchWidgets.swift b/LoopFollowWatchWidgets/LoopFollowWatchWidgets.swift new file mode 100644 index 000000000..456d3a9bb --- /dev/null +++ b/LoopFollowWatchWidgets/LoopFollowWatchWidgets.swift @@ -0,0 +1,50 @@ +// LoopFollow +// LoopFollowWatchWidgets.swift + +import SwiftUI +import WidgetKit + +@main +struct LoopFollowWatchWidgets: WidgetBundle { + var body: some Widget { + GlucoseComplication() + } +} + +struct GlucoseComplication: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "GlucoseComplication", provider: GlucoseTimelineProvider()) { entry in + GlucoseComplicationView(entry: entry) + .containerBackground(.black, for: .widget) + } + .configurationDisplayName("BG") + .description("Latest BG from LoopFollow on your iPhone.") + .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner]) + } +} + +struct GlucoseEntry: TimelineEntry { + let date: Date + let state: WatchState? +} + +/// Reads the state the watch app stored. One entry per minute keeps the +/// "minutes ago" text and the stale marking current until the next reload. +struct GlucoseTimelineProvider: TimelineProvider { + func placeholder(in _: Context) -> GlucoseEntry { + GlucoseEntry(date: Date(), state: nil) + } + + func getSnapshot(in _: Context, completion: @escaping (GlucoseEntry) -> Void) { + completion(GlucoseEntry(date: Date(), state: WatchStateStore.load())) + } + + func getTimeline(in _: Context, completion: @escaping (Timeline) -> Void) { + let state = WatchStateStore.load() + let now = Date() + let entries = (0 ..< 30).map { minute in + GlucoseEntry(date: now.addingTimeInterval(Double(minute) * 60), state: state) + } + completion(Timeline(entries: entries, policy: .atEnd)) + } +} diff --git a/RemoteShared/APNSDelivery.swift b/RemoteShared/APNSDelivery.swift new file mode 100644 index 000000000..9ec7d44c9 --- /dev/null +++ b/RemoteShared/APNSDelivery.swift @@ -0,0 +1,100 @@ +// LoopFollow +// APNSDelivery.swift + +import Foundation + +/// Sends one remote command push to the Loop or Trio app. Used by the phone and +/// by the watch when it sends without the phone. +enum APNSDelivery { + /// Calls `completion` on an arbitrary queue with success, or the reason for failure. + static func send( + body: Data, + deviceToken: String, + topic: String, + production: Bool, + keyId: String, + teamId: String, + apnsKey: String, + expiration: String? = nil, + collapseID: String? = nil, + completion: @escaping (Bool, String?) -> Void + ) { + guard let jwt = JWTManager.shared.getOrGenerateJWT(keyId: keyId, teamId: teamId, apnsKey: apnsKey) else { + let errorMessage = "Failed to generate JWT, please check that the APNS Key ID, APNS Key and Team ID are correct." + LogManager.shared.log(category: .apns, message: errorMessage) + completion(false, errorMessage) + return + } + + guard let url = URL(string: "\(APNSEnvironment.baseURL(production: production))/3/device/\(deviceToken)") else { + let errorMessage = "Failed to construct APNs URL" + LogManager.shared.log(category: .apns, message: errorMessage) + completion(false, errorMessage) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.setValue("10", forHTTPHeaderField: "apns-priority") + request.setValue(topic, forHTTPHeaderField: "apns-topic") + request.setValue("alert", forHTTPHeaderField: "apns-push-type") + if let expiration { + request.setValue(expiration, forHTTPHeaderField: "apns-expiration") + } + if let collapseID { + request.setValue(collapseID, forHTTPHeaderField: "apns-collapse-id") + } + request.httpBody = body + + URLSession.shared.dataTask(with: request) { data, response, error in + if let error { + let errorMessage = "Failed to send push notification: \(error.localizedDescription)" + LogManager.shared.log(category: .apns, message: errorMessage) + completion(false, errorMessage) + return + } + guard let httpResponse = response as? HTTPURLResponse else { + let errorMessage = "Failed to get a valid HTTP response." + LogManager.shared.log(category: .apns, message: errorMessage) + completion(false, errorMessage) + return + } + + var reason = "" + if let data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let value = json["reason"] as? String + { + reason = value + } + + if httpResponse.statusCode == 200 { + LogManager.shared.log(category: .apns, message: "APNS notification sent successfully") + completion(true, nil) + return + } + if httpResponse.statusCode == 403 { + JWTManager.shared.invalidateCache() + } + LogManager.shared.log(category: .apns, message: "APNS error \(httpResponse.statusCode): \(reason)") + completion(false, "\(message(forStatus: httpResponse.statusCode)) \(reason)") + }.resume() + } + + static func message(forStatus status: Int) -> String { + switch status { + case 400: "Bad request. The request was invalid or malformed." + case 403: "Authentication error. Check your certificate or authentication token." + case 404: "Invalid request: The :path value was incorrect." + case 405: "Invalid request: Only POST requests are supported." + case 410: "The device token is no longer active for the topic." + case 413: "Payload too large. The notification payload exceeded the size limit." + case 429: "Too many requests." + case 500: "Internal server error at APNs." + case 503: "Service unavailable. The server is temporarily unavailable. Try again later." + default: "Unexpected status code: \(status)." + } + } +} diff --git a/LoopFollow/Remote/APNSEnvironment.swift b/RemoteShared/APNSEnvironment.swift similarity index 100% rename from LoopFollow/Remote/APNSEnvironment.swift rename to RemoteShared/APNSEnvironment.swift diff --git a/LoopFollow/Helpers/DateExtensions.swift b/RemoteShared/DateExtensions.swift similarity index 100% rename from LoopFollow/Helpers/DateExtensions.swift rename to RemoteShared/DateExtensions.swift diff --git a/RemoteShared/DeviceStatusSummary.swift b/RemoteShared/DeviceStatusSummary.swift new file mode 100644 index 000000000..99216bba3 --- /dev/null +++ b/RemoteShared/DeviceStatusSummary.swift @@ -0,0 +1,92 @@ +// LoopFollow +// DeviceStatusSummary.swift + +import Foundation + +/// The parts of the latest Nightscout devicestatus the watch shows when it +/// fetches on its own. +struct DeviceStatusSummary: Equatable { + var loopAt: Date? + var iob: Double? + var cob: Double? + var recommendedBolus: Double? + var predictions: [WatchPredictionLine] = [] + var cone: [WatchConePoint] = [] + + /// How far ahead predictions are drawn. + static let predictionPoints = 36 + + /// Reads a devicestatus record the way the phone does: `loop` for Loop, `openaps` for Trio. + static func parse(_ status: [String: Any], showsCone: Bool) -> DeviceStatusSummary { + var result = DeviceStatusSummary() + if let loop = status["loop"] as? [String: Any] { + result.loopAt = date(loop["timestamp"]) + result.iob = (loop["iob"] as? [String: Any])?["iob"] as? Double + result.cob = (loop["cob"] as? [String: Any])?["cob"] as? Double + result.recommendedBolus = loop["recommendedBolus"] as? Double + if let predicted = loop["predicted"] as? [String: Any], + let values = predicted["values"] as? [Double], + let start = date(predicted["startDate"]) + { + result.predictions = [WatchPredictionLine(kind: .loop, points: points(values, start: start))] + } + } else if let openaps = status["openaps"] as? [String: Any] { + let suggested = openaps["suggested"] as? [String: Any] ?? openaps["enacted"] as? [String: Any] ?? [:] + result.loopAt = date(suggested["deliverAt"] ?? suggested["timestamp"]) + let iob = openaps["iob"] as? [String: Any] ?? (openaps["iob"] as? [[String: Any]])?.first + result.iob = iob?["iob"] as? Double ?? suggested["IOB"] as? Double + result.cob = suggested["COB"] as? Double + result.recommendedBolus = openaps["recommendedBolus"] as? Double + if let predBGs = suggested["predBGs"] as? [String: [Double]], let start = result.loopAt { + let lines: [(WatchPredictionLine.Kind, String)] = [(.zt, "ZT"), (.iob, "IOB"), (.cob, "COB"), (.uam, "UAM")] + let series = lines.compactMap { kind, key in predBGs[key].map { (kind, points($0, start: start)) } } + // The phone shows Trio's forecast as a cone or as lines; keep what it showed last. + if showsCone { + result.cone = cone(series.map(\.1)) + } else { + result.predictions = series.map { WatchPredictionLine(kind: $0.0, points: $0.1) } + } + } + } + return result + } + + private static func points(_ values: [Double], start: Date) -> [WatchReading] { + values.prefix(predictionPoints + 1).enumerated().map { index, value in + WatchReading(t: start.timeIntervalSince1970 + Double(index) * 300, v: value) + } + } + + /// Same construction as the phone: the shortest series sets the length, min and max per step. + private static func cone(_ series: [[WatchReading]]) -> [WatchConePoint] { + guard let length = series.map(\.count).min(), length > 0 else { return [] } + return (0 ..< length).map { index in + let values = series.map { $0[index].v } + var low = values.min() ?? 0 + var high = values.max() ?? 0 + if low == high { + low -= 1 + high += 1 + } + return WatchConePoint(t: series[0][index].t, low: low, high: high) + } + } + + // MARK: - Dates + + static let iso: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let isoNoFraction = ISO8601DateFormatter() + + static func date(_ value: Any?) -> Date? { + if let number = value as? Double { + return Date(timeIntervalSince1970: number > 1e11 ? number / 1000 : number) + } + guard let string = value as? String else { return nil } + return iso.date(from: string) ?? isoNoFraction.date(from: string) + } +} diff --git a/LoopFollow/Helpers/JWTManager.swift b/RemoteShared/JWTManager.swift similarity index 100% rename from LoopFollow/Helpers/JWTManager.swift rename to RemoteShared/JWTManager.swift diff --git a/LoopFollow/Helpers/LogRedactor.swift b/RemoteShared/LogRedactor.swift similarity index 100% rename from LoopFollow/Helpers/LogRedactor.swift rename to RemoteShared/LogRedactor.swift diff --git a/RemoteShared/LoopAPNSCommands.swift b/RemoteShared/LoopAPNSCommands.swift new file mode 100644 index 000000000..0473df59e --- /dev/null +++ b/RemoteShared/LoopAPNSCommands.swift @@ -0,0 +1,165 @@ +// LoopFollow +// LoopAPNSCommands.swift + +import Foundation + +/// Loop remote command payloads, matching Nightscout's loop.js format. +enum LoopAPNSCommands { + /// Fields every Loop remote command carries. + static func commandHeader(otp: String, alert: String, now: Date) -> [String: Any] { + [ + "otp": otp, + "remote-address": "LoopFollow", + "notes": "Sent via LoopFollow APNS", + "entered-by": "LoopFollow", + "sent-at": formatDateForAPNS(now), + "expiration": formatDateForAPNS(now.addingTimeInterval(5 * 60)), + "alert": alert, + ] + } + + static func carbsCommandPayload(_ payload: LoopAPNSPayload, now: Date = Date()) -> [String: Any] { + let carbsAmount = payload.carbsAmount ?? 0.0 + let absorptionTime = payload.absorptionTime ?? 3.0 + var fields = commandHeader( + otp: payload.otp, + alert: "Remote Carbs Entry: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTime)) hours", + now: now + ) + fields["carbs-entry"] = carbsAmount + fields["absorption-time"] = absorptionTime + fields["start-time"] = formatDateForAPNS(payload.consumedDate ?? now) + return fields + } + + static func bolusCommandPayload(_ payload: LoopAPNSPayload, now: Date = Date()) -> [String: Any] { + let bolusAmount = payload.bolusAmount ?? 0.0 + var fields = commandHeader(otp: payload.otp, alert: "Remote Bolus Entry: \(String(format: "%.2f", bolusAmount)) U", now: now) + fields["bolus-entry"] = bolusAmount + return fields + } + + static func carbsDeleteCommandPayload(syncIdentifier: String, otp: String, now: Date = Date()) -> [String: Any] { + var fields = commandHeader(otp: otp, alert: "Remote Carbs Delete", now: now) + fields["carbs-delete"] = syncIdentifier + return fields + } + + /// Keys are prefixed so an unpatched Loop rejects the command. Amount, absorption time and start time are always sent; food type only when set. + static func carbsEditCommandPayload( + syncIdentifier: String, + carbsAmount: Double, + absorptionTimeHours: Double, + foodType: String?, + consumedDate: Date, + otp: String, + now: Date = Date() + ) -> [String: Any] { + var fields = commandHeader( + otp: otp, + alert: "Remote Carbs Edit: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTimeHours)) hours", + now: now + ) + fields["carbs-edit"] = syncIdentifier + fields["carbs-edit-entry"] = carbsAmount + fields["carbs-edit-absorption-time"] = absorptionTimeHours + fields["carbs-edit-start-time"] = formatDateForAPNS(consumedDate) + if let foodType, !foodType.isEmpty { + fields["carbs-edit-food-type"] = foodType + } + return fields + } + + /// Creates a properly formatted ISO8601 date string with milliseconds (matching Nightscout's format) + /// - Parameter date: The date to format + /// - Returns: Formatted date string like "2022-12-24T21:34:02.090Z" + /// Override commands need no one-time code, so the return block travels unencrypted. + static func overridePayload(presetName: String, duration: TimeInterval?, returnInfo: ReturnNotificationInfo?, now: Date = Date()) -> [String: Any] { + // Alert text matches Nightscout's format. + var alertText = "\(presetName) Temporary Override" + if let duration, duration > 0 { + let hours = Int(duration / 3600) + let minutes = Int(duration.truncatingRemainder(dividingBy: 3600) / 60) + alertText += hours > 0 ? " (\(hours)h \(minutes)m)" : " (\(minutes)m)" + } + var payload = overrideHeader(alert: alertText, now: now, returnInfo: returnInfo) + payload["override-name"] = presetName + if let duration { + payload["override-duration-minutes"] = Int(duration / 60) + } + return payload + } + + static func cancelOverridePayload(returnInfo: ReturnNotificationInfo?, now: Date = Date()) -> [String: Any] { + var payload = overrideHeader(alert: "Cancel Temporary Override", now: now, returnInfo: returnInfo) + payload["cancel-temporary-override"] = "true" + return payload + } + + private static func overrideHeader(alert: String, now: Date, returnInfo: ReturnNotificationInfo?) -> [String: Any] { + var payload: [String: Any] = [ + "remote-address": "LoopFollow", + "entered-by": "LoopFollow", + "sent-at": formatDateForAPNS(now), + "expiration": formatDateForAPNS(now.addingTimeInterval(5 * 60)), + "alert": alert, + ] + if let returnInfo { + payload["return_notification"] = [ + "production_environment": returnInfo.productionEnvironment, + "device_token": returnInfo.deviceToken, + "bundle_id": returnInfo.bundleId, + "team_id": returnInfo.teamId, + "key_id": returnInfo.keyId, + "apns_key": returnInfo.apnsKey, + ] + } + return payload + } + + /// Wraps command fields in the push payload Loop expects (matching @parse/node-apn). + static func apnsBody(_ payload: [String: Any]) throws -> Data { + var body: [String: Any] = [ + "aps": [ + "alert": payload["alert"] as? String ?? "", + "content-available": 1, + "interruption-level": "time-sensitive", + ], + ] + for (key, value) in payload where key != "alert" && key != "content-available" && key != "interruption-level" { + body[key] = value + } + return try JSONSerialization.data(withJSONObject: body) + } + + static func formatDateForAPNS(_ date: Date) -> String { + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return dateFormatter.string(from: date) + } +} + +struct LoopAPNSPayload { + enum PayloadType { + case carbs + case bolus + } + + let type: PayloadType + let carbsAmount: Double? + let absorptionTime: Double? + let foodType: String? + let bolusAmount: Double? + let consumedDate: Date? + let otp: String + + init(type: PayloadType, carbsAmount: Double? = nil, absorptionTime: Double? = nil, foodType: String? = nil, bolusAmount: Double? = nil, consumedDate: Date? = nil, otp: String) { + self.type = type + self.carbsAmount = carbsAmount + self.absorptionTime = absorptionTime + self.foodType = foodType + self.bolusAmount = bolusAmount + self.consumedDate = consumedDate + self.otp = otp + } +} diff --git a/LoopFollow/Remote/LoopAPNS/OTPSecureMessenger.swift b/RemoteShared/OTPSecureMessenger.swift similarity index 97% rename from LoopFollow/Remote/LoopAPNS/OTPSecureMessenger.swift rename to RemoteShared/OTPSecureMessenger.swift index 02762338b..4e201a965 100644 --- a/LoopFollow/Remote/LoopAPNS/OTPSecureMessenger.swift +++ b/RemoteShared/OTPSecureMessenger.swift @@ -38,7 +38,7 @@ struct OTPSecureMessenger { } /// Information needed to send a response notification back via APNS -struct ReturnNotificationInfo: Codable { +struct ReturnNotificationInfo: Codable, Equatable { let productionEnvironment: Bool let deviceToken: String let bundleId: String diff --git a/LoopFollow/Remote/TRC/PushMessage.swift b/RemoteShared/PushMessage.swift similarity index 77% rename from LoopFollow/Remote/TRC/PushMessage.swift rename to RemoteShared/PushMessage.swift index 319482c7c..1d6267ae6 100644 --- a/LoopFollow/Remote/TRC/PushMessage.swift +++ b/RemoteShared/PushMessage.swift @@ -54,24 +54,6 @@ struct CommandPayload: Encodable { return commandType.rawValue } - struct ReturnNotificationInfo: Encodable { - let productionEnvironment: Bool - let deviceToken: String - let bundleId: String - let teamId: String - let keyId: String - let apnsKey: String - - enum CodingKeys: String, CodingKey { - case productionEnvironment = "production_environment" - case deviceToken = "device_token" - case bundleId = "bundle_id" - case teamId = "team_id" - case keyId = "key_id" - case apnsKey = "apns_key" - } - } - enum CodingKeys: String, CodingKey { case user case commandType = "command_type" diff --git a/LoopFollow/Remote/TRC/SecureMessenger.swift b/RemoteShared/SecureMessenger.swift similarity index 100% rename from LoopFollow/Remote/TRC/SecureMessenger.swift rename to RemoteShared/SecureMessenger.swift diff --git a/LoopFollow/Extensions/ShareClientExtension.swift b/RemoteShared/ShareClientExtension.swift similarity index 96% rename from LoopFollow/Extensions/ShareClientExtension.swift rename to RemoteShared/ShareClientExtension.swift index 6201e9100..c8b492b78 100644 --- a/LoopFollow/Extensions/ShareClientExtension.swift +++ b/RemoteShared/ShareClientExtension.swift @@ -2,7 +2,9 @@ // ShareClientExtension.swift import Foundation -import ShareClient +#if canImport(ShareClient) + import ShareClient +#endif public struct ShareGlucoseData: Decodable { var sgv: Int @@ -56,7 +58,6 @@ private var TrendTable: [String] = [ "RATE OUT OF RANGE", // 9 ] -// TODO: probably better to make this an inherited class rather than an extension public extension ShareClient { func fetchData(_ entries: Int, callback: @escaping (ShareError?, [ShareGlucoseData]?) -> Void) { fetchLast(entries) { error, result in diff --git a/LoopFollow/Helpers/TOTPGenerator.swift b/RemoteShared/TOTPGenerator.swift similarity index 100% rename from LoopFollow/Helpers/TOTPGenerator.swift rename to RemoteShared/TOTPGenerator.swift diff --git a/LoopFollow/Remote/TRC/TRCCommandType.swift b/RemoteShared/TRCCommandType.swift similarity index 100% rename from LoopFollow/Remote/TRC/TRCCommandType.swift rename to RemoteShared/TRCCommandType.swift diff --git a/RemoteShared/WatchCommandValidator.swift b/RemoteShared/WatchCommandValidator.swift new file mode 100644 index 000000000..15769a0b2 --- /dev/null +++ b/RemoteShared/WatchCommandValidator.swift @@ -0,0 +1,134 @@ +// LoopFollow +// WatchCommandValidator.swift + +import Foundation + +/// The guardrails for a watch command, applied by the phone before it sends +/// one and by the watch before it sends one directly. Same limits and messages +/// as the phone's Remote screens. +enum WatchCommandValidator { + struct Limits: Equatable { + var maxBolus: Double + var bolusIncrement: Double + var maxCarbs: Double + var maxFat: Double + var maxProtein: Double + var mealWithBolus: Bool + var mealWithFatProtein: Bool + } + + /// Loop rejects carb entries outside this window; same limits as LoopAPNSCarbsView. + static let loopMaxPastHours: Double = 12 + static let loopMaxFutureHours: Double = 1 + /// Loop's default absorption time for a remote carb entry, in hours. + static let loopAbsorptionHours = 3.0 + + /// Returns the action to send (Trio boluses rounded down to the increment) or the reason it is refused. + static func validate( + _ action: WatchCommand.Action, + kind: WatchRemoteConfig.Kind, + limits: Limits, + now: Date = Date() + ) -> Result { + switch (kind, action) { + case let (.loop, .bolus(units)): + guard units > 0 else { return .failure(ValidationError("Enter an amount above zero.")) } + guard units <= limits.maxBolus else { return .failure(ValidationError(exceedsMaxBolus(units, limits))) } + return .success(action) + + case let (.trio, .bolus(units)): + let stepped = roundedToStep(units, step: limits.bolusIncrement) + guard stepped > 0 else { return .failure(ValidationError("Enter an amount above zero.")) } + guard stepped <= limits.maxBolus else { return .failure(ValidationError(exceedsMaxBolus(stepped, limits))) } + return .success(.bolus(units: stepped)) + + case let (.loop, .meal(carbs, fat, protein, bolus, consumedAt)): + guard fat == 0, protein == 0, bolus == 0 else { return .failure(ValidationError("Loop accepts carbs only from the watch.")) } + guard carbs > 0 else { return .failure(ValidationError("Enter an amount above zero.")) } + guard carbs <= limits.maxCarbs else { return .failure(ValidationError(exceeds("Carbs", carbs, limits.maxCarbs))) } + guard consumedAt >= now.addingTimeInterval(-loopMaxPastHours * 3600) else { + return .failure(ValidationError("Time must be within the prior \(Int(loopMaxPastHours)) hours.")) + } + guard consumedAt <= now.addingTimeInterval(loopMaxFutureHours * 3600) else { + return .failure(ValidationError("Time must be within the next \(Int(loopMaxFutureHours)) hour.")) + } + return .success(action) + + case let (.trio, .meal(carbs, fat, protein, bolus, consumedAt)): + guard limits.mealWithFatProtein || (fat == 0 && protein == 0) else { + return .failure(ValidationError("Meal with Fat/Protein is turned off in LoopFollow's Remote settings.")) + } + guard limits.mealWithBolus || bolus == 0 else { + return .failure(ValidationError("Meal with Bolus is turned off in LoopFollow's Remote settings.")) + } + guard carbs >= 0, fat >= 0, protein >= 0, carbs > 0 || fat > 0 || protein > 0 else { + return .failure(ValidationError("Enter carbs, fat or protein.")) + } + guard carbs <= limits.maxCarbs else { return .failure(ValidationError(exceeds("Carbs", carbs, limits.maxCarbs))) } + guard fat <= limits.maxFat else { return .failure(ValidationError(exceeds("Fat", fat, limits.maxFat))) } + guard protein <= limits.maxProtein else { return .failure(ValidationError(exceeds("Protein", protein, limits.maxProtein))) } + let steppedBolus = roundedToStep(bolus, step: limits.bolusIncrement) + guard steppedBolus <= limits.maxBolus else { return .failure(ValidationError(exceedsMaxBolus(steppedBolus, limits))) } + return .success(.meal(carbs: carbs, fat: fat, protein: protein, bolus: steppedBolus, consumedAt: consumedAt)) + + case (.loop, .startTempTarget), (.loop, .cancelTempTarget): + return .failure(ValidationError("Temp targets are not available for Loop.")) + + default: + return .success(action) + } + } + + /// Rounds down to the bolus increment, as the phone's Trio bolus screen does. + static func roundedToStep(_ value: Double, step: Double) -> Double { + let step = max(0.001, step) + let stepped = ((value / step) + 1e-10).rounded(.down) * step + return (stepped * 1000).rounded() / 1000 + } + + private static func exceedsMaxBolus(_ units: Double, _ limits: Limits) -> String { + "\(format(units)) U exceeds the maximum bolus of \(format(limits.maxBolus)) U set in LoopFollow." + } + + private static func exceeds(_ what: String, _ grams: Double, _ max: Double) -> String { + "\(what) (\(Int(grams)) g) exceeds the maximum of \(Int(max)) g set in LoopFollow." + } + + private static func format(_ units: Double) -> String { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.minimumFractionDigits = 0 + nf.maximumFractionDigits = 3 + return nf.string(from: NSNumber(value: units)) ?? "\(units)" + } + + struct ValidationError: Error, Equatable { + let message: String + + init(_ message: String) { + self.message = message + } + } +} + +extension WatchCommand { + /// Trio treats a missing time as now; within a minute of now counts as now, as in the phone's meal screen. + static func trioScheduledTime(_ consumedAt: Date, now: Date = Date()) -> Date? { + abs(consumedAt.timeIntervalSince(now)) < 60 ? nil : consumedAt.dateUsingCurrentSeconds() + } +} + +extension WatchRemoteConfig { + /// The phone's guardrails as last sent to the watch. + var limits: WatchCommandValidator.Limits { + WatchCommandValidator.Limits( + maxBolus: maxBolus, + bolusIncrement: bolusIncrement, + maxCarbs: maxCarbs, + maxFat: maxFat, + maxProtein: maxProtein, + mealWithBolus: mealWithBolus, + mealWithFatProtein: mealWithFatProtein + ) + } +} diff --git a/RemoteShared/WatchCredentials.swift b/RemoteShared/WatchCredentials.swift new file mode 100644 index 000000000..4d3a30d9d --- /dev/null +++ b/RemoteShared/WatchCredentials.swift @@ -0,0 +1,64 @@ +// LoopFollow +// WatchCredentials.swift + +import Foundation + +/// What the watch needs to keep working without the phone: read access to the +/// data source and, when commands from the watch are allowed, what the phone +/// itself uses to send them. Sent with `transferUserInfo` only when it changes, +/// and kept in the watch's Keychain. +struct WatchCredentials: Codable, Equatable { + static let userInfoKey = "watchCredentials" + /// Watch β†’ phone: asks the phone to send its credentials again. + static let requestKey = "requestCredentials" + + var nightscoutURL: String + var nightscoutToken: String + var dexcomUsername: String + var dexcomPassword: String + /// "US" or anything else for outside the US, as in the phone's Dexcom settings. + var dexcomServer: String + /// Present only when remote commands from the watch are allowed. + var remote: Remote? + + struct Remote: Codable, Equatable { + var kind: WatchRemoteConfig.Kind + /// Push target: the Loop or Trio app. + var deviceToken: String + var bundleID: String + var production: Bool + /// The key the phone signs commands with (LoopFollow's own key when both apps share a team). + var apnsKey: String + var keyID: String + var teamID: String + /// Trio Remote Control. + var user: String + var sharedSecret: String + /// Loop: the one-time-password QR code URL. + var loopQRCodeURL: String + /// Lets Loop or Trio confirm a command to the LoopFollow app on the phone. + var returnNotification: ReturnNotificationInfo? + } + + var hasDataSource: Bool { + !nightscoutURL.isEmpty || (!dexcomUsername.isEmpty && !dexcomPassword.isEmpty) + } +} + +/// Picks the key that signs commands to the Loop or Trio app: LoopFollow's own +/// key when both apps belong to the same team or no remote team is set, +/// otherwise the remote app's key. +enum RemoteSigningKey { + static func resolve( + lfTeamID: String, + remoteTeamID: String, + lfKey: (key: String, keyID: String), + remoteKey: (key: String, keyID: String) + ) -> (key: String, keyID: String, teamID: String) { + let sameTeam = !lfTeamID.isEmpty && !remoteTeamID.isEmpty && lfTeamID == remoteTeamID + if sameTeam || remoteTeamID.isEmpty { + return (lfKey.key, lfKey.keyID, lfTeamID) + } + return (remoteKey.key, remoteKey.keyID, remoteTeamID) + } +} diff --git a/Shared/AppGroupID.swift b/Shared/AppGroupID.swift index 5eb1187b8..507bfb7c2 100644 --- a/Shared/AppGroupID.swift +++ b/Shared/AppGroupID.swift @@ -58,6 +58,8 @@ enum AppGroupID { ".WidgetExtension", ".Widgets", ".WidgetsExtension", + ".watchkitapp.widgets", + ".watchkitapp", ".Watch", ".WatchExtension", ".CarPlay", diff --git a/Shared/GlucoseSnapshot.swift b/Shared/GlucoseSnapshot.swift index b2a015d17..a8c80038e 100644 --- a/Shared/GlucoseSnapshot.swift +++ b/Shared/GlucoseSnapshot.swift @@ -363,5 +363,27 @@ extension GlucoseSnapshot { let raw = try container.decode(String.self) self = Trend(rawValue: raw) ?? .unknown } + + /// Maps a Nightscout or Dexcom direction string, e.g. "FortyFiveUp". + init(code: String?) { + let raw = code?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + if raw.contains("doubleup") || raw.contains("rapidrise") || raw == "up2" || raw == "upfast" { + self = .upFast + } else if raw.contains("fortyfiveup") { + self = .upSlight + } else if raw.contains("singleup") || raw == "up" || raw == "up1" || raw == "rising" { + self = .up + } else if raw.contains("flat") || raw == "steady" || raw == "none" { + self = .flat + } else if raw.contains("doubledown") || raw.contains("rapidfall") || raw == "down2" || raw == "downfast" { + self = .downFast + } else if raw.contains("fortyfivedown") { + self = .downSlight + } else if raw.contains("singledown") || raw == "down" || raw == "down1" || raw == "falling" { + self = .down + } else { + self = .unknown + } + } } } diff --git a/Tests/LoopAPNSCommandPayloadTests.swift b/Tests/LoopAPNSCommandPayloadTests.swift index aabd53185..374509bc6 100644 --- a/Tests/LoopAPNSCommandPayloadTests.swift +++ b/Tests/LoopAPNSCommandPayloadTests.swift @@ -12,7 +12,7 @@ struct LoopAPNSCommandPayloadTests { @Test("carbs payload carries the loop.js keys") func carbsKeys() { let payload = LoopAPNSPayload(type: .carbs, carbsAmount: 30, absorptionTime: 3, consumedDate: now.addingTimeInterval(-600), otp: "123456") - let json = LoopAPNSService.carbsCommandPayload(payload, now: now) + let json = LoopAPNSCommands.carbsCommandPayload(payload, now: now) #expect(Set(json.keys) == headerKeys.union(["carbs-entry", "absorption-time", "start-time"])) #expect(json["carbs-entry"] as? Double == 30) #expect(json["absorption-time"] as? Double == 3) @@ -28,7 +28,7 @@ struct LoopAPNSCommandPayloadTests { @Test("bolus payload carries the loop.js keys") func bolusKeys() { - let json = LoopAPNSService.bolusCommandPayload(LoopAPNSPayload(type: .bolus, bolusAmount: 1.25, otp: "123456"), now: now) + let json = LoopAPNSCommands.bolusCommandPayload(LoopAPNSPayload(type: .bolus, bolusAmount: 1.25, otp: "123456"), now: now) #expect(Set(json.keys) == headerKeys.union(["bolus-entry"])) #expect(json["bolus-entry"] as? Double == 1.25) #expect(json["alert"] as? String == "Remote Bolus Entry: 1.25 U") @@ -36,7 +36,7 @@ struct LoopAPNSCommandPayloadTests { @Test("carbs delete addresses the entry by sync identifier") func deleteKeys() { - let json = LoopAPNSService.carbsDeleteCommandPayload(syncIdentifier: "SYNC-1", otp: "123456", now: now) + let json = LoopAPNSCommands.carbsDeleteCommandPayload(syncIdentifier: "SYNC-1", otp: "123456", now: now) #expect(Set(json.keys) == headerKeys.union(["carbs-delete"])) #expect(json["carbs-delete"] as? String == "SYNC-1") #expect(json["carbs-entry"] == nil) @@ -44,7 +44,7 @@ struct LoopAPNSCommandPayloadTests { @Test("carbs edit always sends amount, absorption and start time; food type only when set") func editKeys() { - let json = LoopAPNSService.carbsEditCommandPayload( + let json = LoopAPNSCommands.carbsEditCommandPayload( syncIdentifier: "SYNC-1", carbsAmount: 45, absorptionTimeHours: 2.5, @@ -61,7 +61,7 @@ struct LoopAPNSCommandPayloadTests { #expect(json["carbs-entry"] == nil) #expect(json["start-time"] == nil) - let withFood = LoopAPNSService.carbsEditCommandPayload( + let withFood = LoopAPNSCommands.carbsEditCommandPayload( syncIdentifier: "SYNC-1", carbsAmount: 45, absorptionTimeHours: 2.5, foodType: "πŸ•", consumedDate: now, otp: "123456", now: now ) #expect(withFood["carbs-edit-food-type"] as? String == "πŸ•") diff --git a/Tests/WatchProtocolTests.swift b/Tests/WatchProtocolTests.swift new file mode 100644 index 000000000..ebb5231b0 --- /dev/null +++ b/Tests/WatchProtocolTests.swift @@ -0,0 +1,104 @@ +// LoopFollow +// WatchProtocolTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct WatchProtocolTests { + private let t0 = Date(timeIntervalSince1970: 1_790_000_000) + + private func state(alarm: WatchAlarm? = nil) -> WatchState { + WatchState( + builtAt: t0, + source: .phone, + phoneBuiltAt: t0, + snapshot: GlucoseSnapshot(glucose: 142, delta: -3, trend: .downSlight, updatedAt: t0, iob: 1.2, cob: 15, projected: 120, unit: .mmol, isNotLooping: false), + readings: [WatchReading(t: t0.timeIntervalSince1970 - 300, v: 145), WatchReading(t: t0.timeIntervalSince1970, v: 142)], + predictions: [WatchPredictionLine(kind: .iob, points: [WatchReading(t: t0.timeIntervalSince1970 + 300, v: 138)])], + cone: [WatchConePoint(t: t0.timeIntervalSince1970 + 300, low: 120, high: 150)], + boluses: [WatchReading(t: t0.timeIntervalSince1970 - 600, v: 2.5)], + carbs: [WatchReading(t: t0.timeIntervalSince1970 - 600, v: 30)], + overrides: [ + WatchOverride(start: t0.timeIntervalSince1970 - 7200, end: t0.timeIntervalSince1970 - 3600, name: "Sleep", percent: nil), + WatchOverride(start: t0.timeIntervalSince1970 - 1800, end: t0.timeIntervalSince1970 + 1800, name: "Exercise", percent: 80), + ], + lowMgdl: 70, + highMgdl: 180, + colorBGText: true, + infoRows: [WatchInfoRow(name: "IOB", value: "1,2", level: nil), WatchInfoRow(name: "SAGE", value: "9d 2h", level: .warning)], + stats: WatchStats(lowPercent: "2%", inRangePercent: "80%", highPercent: "18%", avgBG: "8,1", estA1CTitle: "GMI:", estA1C: "6,6", stdDevTitle: "Std Dev:", stdDev: "2,3", pieLow: 2, pieRange: 80, pieHigh: 18), + device: "Trio", + alarm: alarm, + remote: WatchRemoteConfig( + kind: .trio, + maxBolus: 5, + bolusIncrement: 0.05, + maxCarbs: 80, + maxFat: 30, + maxProtein: 30, + mealWithBolus: true, + mealWithFatProtein: false, + recommendedBolus: 0.4, + recommendedBolusAt: t0, + overridePresets: [WatchPreset(id: "Exercise", name: "Exercise", detail: "80 %, 1 h")], + tempTargetPresets: [], + quickPickBoluses: [1, 2.5], + quickPickMeals: [WatchMealPick(carbs: 45, fat: 0, protein: 0, bolus: 0)] + ) + ) + } + + @Test("State survives the round trip through the application context") + func stateRoundTrip() throws { + let original = state(alarm: WatchAlarm(id: UUID(), name: "Low")) + let data = try #require(original.encoded()) + #expect(WatchState.decode(data) == original) + } + + @Test("The active override is the one whose period contains the time") + func activeOverride() { + let state = state() + #expect(state.activeOverride(at: t0)?.name == "Exercise") + #expect(state.activeOverride(at: t0.addingTimeInterval(-5400))?.name == "Sleep") + #expect(state.activeOverride(at: t0.addingTimeInterval(-3000)) == nil) + #expect(state.activeOverride(at: t0.addingTimeInterval(1800)) == nil) + } + + @Test("Commands keep their action, id and creation time") + func commandRoundTrip() throws { + let actions: [WatchCommand.Action] = [ + .bolus(units: 1.25), + .meal(carbs: 30, fat: 10, protein: 5, bolus: 2, consumedAt: t0), + .startOverride(presetID: "Exercise"), + .cancelOverride, + .startTempTarget(presetID: UUID().uuidString), + .cancelTempTarget, + .snoozeAlarm, + ] + for action in actions { + let command = WatchCommand(action: action) + let decoded = try JSONDecoder().decode(WatchCommand.self, from: JSONEncoder().encode(command)) + #expect(decoded == command) + } + } + + @Test("Glucose and delta follow the phone's unit formatting") + func glucoseFormatting() { + #expect(WatchFormat.glucose(142, unit: .mgdl) == "142") + #expect(WatchFormat.delta(-3, unit: .mgdl) == "-3") + #expect(WatchFormat.delta(4, unit: .mgdl) == "+4") + #expect(WatchFormat.delta(0.4, unit: .mgdl) == "0") + let oneMmol = WatchFormat.delta(GlucoseConversion.mmolToMgDl, unit: .mmol) + #expect(oneMmol.hasPrefix("+1") && oneMmol.count == 4) + #expect(!WatchFormat.delta(0.5, unit: .mmol).hasPrefix("+")) + } + + @Test("Range colors match the phone: red at or below low, yellow at or above high") + func rangeColors() { + #expect(WatchFormat.bgColor(70, lowMgdl: 70, highMgdl: 180) == .red) + #expect(WatchFormat.bgColor(71, lowMgdl: 70, highMgdl: 180) == .green) + #expect(WatchFormat.bgColor(180, lowMgdl: 70, highMgdl: 180) == .yellow) + #expect(WatchFormat.bgColor(250, lowMgdl: 70, highMgdl: 180, colored: false) == .primary) + } +} diff --git a/Tests/WatchRemoteTests.swift b/Tests/WatchRemoteTests.swift new file mode 100644 index 000000000..55c0c5a22 --- /dev/null +++ b/Tests/WatchRemoteTests.swift @@ -0,0 +1,138 @@ +// LoopFollow +// WatchRemoteTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct WatchRemoteTests { + private let now = Date(timeIntervalSince1970: 1_790_000_000) + + private let limits = WatchCommandValidator.Limits( + maxBolus: 2, + bolusIncrement: 0.05, + maxCarbs: 60, + maxFat: 30, + maxProtein: 30, + mealWithBolus: true, + mealWithFatProtein: false + ) + + private func validate(_ action: WatchCommand.Action, _ kind: WatchRemoteConfig.Kind, limits: WatchCommandValidator.Limits? = nil) -> Result { + WatchCommandValidator.validate(action, kind: kind, limits: limits ?? self.limits, now: now) + } + + // MARK: - Validation + + @Test("Trio boluses round down to the increment and respect the maximum") + func trioBolus() { + #expect(validate(.bolus(units: 1.27), .trio) == .success(.bolus(units: 1.25))) + #expect((try? validate(.bolus(units: 2.5), .trio).get()) == nil) + #expect((try? validate(.bolus(units: 0.01), .trio).get()) == nil) + } + + @Test("Loop boluses are sent as entered, up to the maximum") + func loopBolus() { + #expect(validate(.bolus(units: 1.27), .loop) == .success(.bolus(units: 1.27))) + #expect((try? validate(.bolus(units: 2.01), .loop).get()) == nil) + } + + @Test("Loop meals are carbs only, within the time window Loop accepts") + func loopMeal() { + #expect((try? validate(.meal(carbs: 30, fat: 0, protein: 0, bolus: 0, consumedAt: now), .loop).get()) != nil) + #expect((try? validate(.meal(carbs: 30, fat: 0, protein: 0, bolus: 1, consumedAt: now), .loop).get()) == nil) + #expect((try? validate(.meal(carbs: 70, fat: 0, protein: 0, bolus: 0, consumedAt: now), .loop).get()) == nil) + #expect((try? validate(.meal(carbs: 30, fat: 0, protein: 0, bolus: 0, consumedAt: now.addingTimeInterval(-13 * 3600)), .loop).get()) == nil) + #expect((try? validate(.meal(carbs: 30, fat: 0, protein: 0, bolus: 0, consumedAt: now.addingTimeInterval(2 * 3600)), .loop).get()) == nil) + } + + @Test("Trio meals follow the phone's Meal settings") + func trioMeal() { + #expect((try? validate(.meal(carbs: 30, fat: 10, protein: 0, bolus: 0, consumedAt: now), .trio).get()) == nil) + #expect(validate(.meal(carbs: 30, fat: 0, protein: 0, bolus: 1.27, consumedAt: now), .trio) + == .success(.meal(carbs: 30, fat: 0, protein: 0, bolus: 1.25, consumedAt: now))) + var noBolus = limits + noBolus.mealWithBolus = false + #expect((try? validate(.meal(carbs: 30, fat: 0, protein: 0, bolus: 1, consumedAt: now), .trio, limits: noBolus).get()) == nil) + } + + @Test("Temp targets are refused for Loop") + func loopTempTarget() { + #expect((try? validate(.startTempTarget(presetID: "x"), .loop).get()) == nil) + #expect((try? validate(.startTempTarget(presetID: "x"), .trio).get()) != nil) + } + + // MARK: - Signing key + + @Test("LoopFollow's own key signs when both apps share a team or no remote team is set") + func signingKey() { + let lf = (key: "LF", keyID: "LFKEY") + let remote = (key: "R", keyID: "RKEY") + #expect(RemoteSigningKey.resolve(lfTeamID: "T1", remoteTeamID: "T1", lfKey: lf, remoteKey: remote).key == "LF") + #expect(RemoteSigningKey.resolve(lfTeamID: "T1", remoteTeamID: "", lfKey: lf, remoteKey: remote).key == "LF") + let other = RemoteSigningKey.resolve(lfTeamID: "T1", remoteTeamID: "T2", lfKey: lf, remoteKey: remote) + #expect(other.key == "R") + #expect(other.teamID == "T2") + } + + // MARK: - Device status + + @Test("Loop devicestatus gives IOB, COB, recommendation and the prediction") + func loopDeviceStatus() { + let status: [String: Any] = [ + "loop": [ + "timestamp": "2026-10-19T08:00:00Z", + "iob": ["iob": 1.2], + "cob": ["cob": 15.0], + "recommendedBolus": 0.3, + "predicted": ["startDate": "2026-10-19T08:00:00Z", "values": [120.0, 125.0, 130.0]], + ], + ] + let summary = DeviceStatusSummary.parse(status, showsCone: false) + #expect(summary.iob == 1.2) + #expect(summary.cob == 15) + #expect(summary.recommendedBolus == 0.3) + #expect(summary.predictions.first?.kind == .loop) + #expect(summary.predictions.first?.points.map(\.v) == [120, 125, 130]) + #expect(summary.loopAt == DeviceStatusSummary.date("2026-10-19T08:00:00.000Z")) + } + + @Test("Trio devicestatus gives lines or the cone, matching what the phone showed") + func trioDeviceStatus() { + let status: [String: Any] = [ + "openaps": [ + "iob": ["iob": 1.5], + "suggested": [ + "timestamp": "2026-10-19T08:00:00.000Z", + "COB": 20.0, + "predBGs": ["IOB": [140.0, 138.0], "ZT": [140.0, 130.0], "COB": [140.0, 150.0]], + ], + ], + ] + let lines = DeviceStatusSummary.parse(status, showsCone: false) + #expect(lines.iob == 1.5) + #expect(lines.cob == 20) + #expect(Set(lines.predictions.map(\.kind)) == [.iob, .zt, .cob]) + + let cone = DeviceStatusSummary.parse(status, showsCone: true) + #expect(cone.predictions.isEmpty) + #expect(cone.cone.map(\.low) == [139, 130]) + #expect(cone.cone.map(\.high) == [141, 150]) + } + + // MARK: - Loop override payload + + @Test("Loop override payload carries name, duration and return info") + func loopOverridePayload() { + let info = ReturnNotificationInfo(productionEnvironment: false, deviceToken: "tok", bundleId: "b", teamId: "t", keyId: "k", apnsKey: "a") + let payload = LoopAPNSCommands.overridePayload(presetName: "Running", duration: 3600, returnInfo: info, now: now) + #expect(payload["override-name"] as? String == "Running") + #expect(payload["override-duration-minutes"] as? Int == 60) + #expect(payload["alert"] as? String == "Running Temporary Override (1h 0m)") + #expect((payload["return_notification"] as? [String: Any])?["device_token"] as? String == "tok") + + let cancel = LoopAPNSCommands.cancelOverridePayload(returnInfo: nil, now: now) + #expect(cancel["cancel-temporary-override"] as? String == "true") + #expect(cancel["return_notification"] == nil) + } +} diff --git a/WatchShared/WatchCommand.swift b/WatchShared/WatchCommand.swift new file mode 100644 index 000000000..5668fb463 --- /dev/null +++ b/WatchShared/WatchCommand.swift @@ -0,0 +1,69 @@ +// LoopFollow +// WatchCommand.swift + +import Foundation + +/// A request from the watch. The phone validates it against its own settings and +/// guardrails and sends it with the same code path as its Remote screens. +struct WatchCommand: Codable, Equatable { + static let messageKey = "watchCommand" + /// The phone ignores commands older than this, so a delayed delivery is never acted on. + static let maxAge: TimeInterval = 60 + + enum Action: Codable, Hashable { + case bolus(units: Double) + /// Loop sends carbs only; Trio may add fat, protein and a bolus in the same command. + case meal(carbs: Double, fat: Double, protein: Double, bolus: Double, consumedAt: Date) + case startOverride(presetID: String) + case cancelOverride + case startTempTarget(presetID: String) + case cancelTempTarget + case snoozeAlarm + } + + let id: UUID + let createdAt: Date + let action: Action + + init(action: Action) { + id = UUID() + createdAt = Date() + self.action = action + } +} + +extension WatchCommand.Action { + /// Short name for logs. + var name: String { + switch self { + case .bolus: "bolus" + case .meal: "meal" + case .startOverride: "startOverride" + case .cancelOverride: "cancelOverride" + case .startTempTarget: "startTempTarget" + case .cancelTempTarget: "cancelTempTarget" + case .snoozeAlarm: "snoozeAlarm" + } + } + + /// Whether the command can deliver insulin. + var deliversInsulin: Bool { + switch self { + case .bolus: true + case let .meal(_, _, _, bolus, _): bolus > 0 + default: false + } + } +} + +struct WatchCommandReply: Codable, Equatable { + static let messageKey = "watchCommandReply" + + let success: Bool + let message: String +} + +enum WatchMessageKey { + /// Watch β†’ phone: asks for the current state; the reply carries `WatchState.contextKey`. + static let requestState = "requestState" +} diff --git a/WatchShared/WatchFormat.swift b/WatchShared/WatchFormat.swift new file mode 100644 index 000000000..24502d318 --- /dev/null +++ b/WatchShared/WatchFormat.swift @@ -0,0 +1,104 @@ +// LoopFollow +// WatchFormat.swift + +import Foundation +import SwiftUI + +/// Display formatting shared by the watch app and its complications. Values +/// arrive in mg/dL and are rendered in the unit chosen on the phone. +enum WatchFormat { + /// A reading this old is shown struck through, as on the phone. + static let staleAfter: TimeInterval = 12 * 60 + + private static let mgdlFormatter: NumberFormatter = { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.maximumFractionDigits = 0 + nf.locale = .current + return nf + }() + + private static let oneDecimalFormatter: NumberFormatter = { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.minimumFractionDigits = 1 + nf.maximumFractionDigits = 1 + nf.locale = .current + return nf + }() + + private static let insulinFormatter: NumberFormatter = { + let nf = NumberFormatter() + nf.numberStyle = .decimal + nf.minimumFractionDigits = 0 + nf.maximumFractionDigits = 2 + nf.locale = .current + return nf + }() + + static func glucose(_ 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 oneDecimalFormatter.string(from: NSNumber(value: mmol)) ?? String(format: "%.1f", mmol) + } + } + + static func delta(_ mgdl: Double, unit: GlucoseSnapshot.Unit) -> String { + switch unit { + case .mgdl: + let v = Int(round(mgdl)) + if v == 0 { return "0" } + return v > 0 ? "+\(v)" : "\(v)" + case .mmol: + let mmol = GlucoseConversion.toMmol(mgdl) + let d = abs(mmol) < 0.05 ? 0.0 : mmol + let formatted = oneDecimalFormatter.string(from: NSNumber(value: abs(d))) ?? String(format: "%.1f", abs(d)) + if d == 0 { return formatted } + return d > 0 ? "+\(formatted)" : "-\(formatted)" + } + } + + static func trendArrow(_ trend: GlucoseSnapshot.Trend) -> String { + switch trend { + case .upFast: "↑↑" + case .up: "↑" + case .upSlight: "β†—" + case .flat: "β†’" + case .downSlight: "β†˜οΈŽ" + case .down: "↓" + case .downFast: "↓↓" + case .unknown: "–" + } + } + + static func insulin(_ units: Double) -> String { + insulinFormatter.string(from: NSNumber(value: units)) ?? String(format: "%.2f", units) + } + + static func iob(_ units: Double?) -> String { + guard let units else { return "β€”" } + return (oneDecimalFormatter.string(from: NSNumber(value: units)) ?? String(format: "%.1f", units)) + " U" + } + + static func cob(_ grams: Double?) -> String { + guard let grams else { return "β€”" } + return "\(Int(round(grams))) g" + } + + static func minutesAgo(_ date: Date, now: Date = Date()) -> String { + let minutes = max(0, Int(now.timeIntervalSince(date) / 60)) + return "\(minutes) min" + } + + /// Phone semantics: yellow at or above high, red at or below low, green in + /// range. White when the phone's "Color BG Text" setting is off. + static func bgColor(_ mgdl: Double, lowMgdl: Double, highMgdl: Double, colored: Bool = true) -> Color { + guard colored else { return .primary } + if mgdl >= highMgdl { return .yellow } + if mgdl <= lowMgdl { return .red } + return .green + } +} diff --git a/WatchShared/WatchState.swift b/WatchShared/WatchState.swift new file mode 100644 index 000000000..91c73c69c --- /dev/null +++ b/WatchShared/WatchState.swift @@ -0,0 +1,189 @@ +// LoopFollow +// WatchState.swift + +import Foundation + +/// Everything the watch shows, built by the phone and delivered as the +/// WatchConnectivity application context. The watch never talks to Nightscout, +/// Dexcom or APNs itself, so no credentials are part of this state. +struct WatchState: Codable, Equatable { + static let contextKey = "watchState" + + /// When this state was built, by the phone or by the watch itself. + var builtAt: Date + var source: WatchDataSource + /// When the phone last built a state; info rows, presets and guardrails date from then. + var phoneBuiltAt: Date + /// Latest glucose state, shared with the Live Activity. + var snapshot: GlucoseSnapshot? + /// BG readings for the last 24 hours, oldest first. + var readings: [WatchReading] + /// Prediction lines as drawn on the phone's main chart. + var predictions: [WatchPredictionLine] + /// Trio/OpenAPS forecast drawn as a band when the phone shows the cone. + var cone: [WatchConePoint] + var boluses: [WatchReading] + var carbs: [WatchReading] + /// Override periods in the last 24 hours, oldest first; nil from the watch's own fetch. + var overrides: [WatchOverride]? + /// Range thresholds used for coloring, in mg/dL (same as the phone). + var lowMgdl: Double + var highMgdl: Double + /// Mirrors the phone's "Color BG Text" setting. + var colorBGText: Bool + /// The phone's visible info table rows, in its order and with its coloring. + var infoRows: [WatchInfoRow] + /// The statistics shown under the phone's chart. + var stats: WatchStats? + /// "Loop", "Trio" or another uploader name; empty for CGM-only setups. + var device: String + /// The alarm currently sounding on the phone. + var alarm: WatchAlarm? + /// Present when remote commands from the watch are enabled on the phone. + var remote: WatchRemoteConfig? + + func encoded() -> Data? { + try? JSONEncoder().encode(self) + } + + static func decode(_ data: Data) -> WatchState? { + try? JSONDecoder().decode(WatchState.self, from: data) + } +} + +enum WatchDataSource: String, Codable { + /// Built and sent by the phone. + case phone + /// Fetched by the watch from Nightscout or Dexcom while the phone was out of reach. + case watch +} + +struct WatchReading: Codable, Equatable { + /// Unix epoch seconds. + let t: TimeInterval + /// mg/dL for glucose, units for boluses, grams for carbs. + let v: Double + + var date: Date { Date(timeIntervalSince1970: t) } +} + +/// One override period, as drawn in the band at the top of the phone's chart. +struct WatchOverride: Codable, Equatable { + /// Unix epoch seconds. + let start: TimeInterval + let end: TimeInterval + let name: String + /// Insulin needs as a percentage of normal; nil when unchanged. + let percent: Int? + + var startDate: Date { Date(timeIntervalSince1970: start) } + var endDate: Date { Date(timeIntervalSince1970: end) } +} + +extension WatchState { + /// The override running at `date`, if any. + func activeOverride(at date: Date) -> WatchOverride? { + overrides?.last { $0.startDate <= date && date < $0.endDate } + } +} + +/// The phone's statistics panel, formatted on the phone. +struct WatchStats: Codable, Equatable { + var lowPercent: String + var inRangePercent: String + var highPercent: String + var avgBG: String + var estA1CTitle: String + var estA1C: String + var stdDevTitle: String + var stdDev: String + var pieLow: Double + var pieRange: Double + var pieHigh: Double +} + +struct WatchInfoRow: Codable, Equatable { + let name: String + let value: String + /// Threshold coloring from the phone's Info Display settings; nil uses the default text color. + let level: InfoColorLevel? +} + +/// Threshold band of an info table value, shared by the phone and the watch. +enum InfoColorLevel: String, Codable { + case normal + case warning + case urgent +} + +struct WatchPredictionLine: Codable, Equatable { + /// Matches the phone's chart series and colors. + enum Kind: String, Codable { + case loop + case zt + case iob + case cob + case uam + } + + let kind: Kind + let points: [WatchReading] +} + +struct WatchConePoint: Codable, Equatable { + let t: TimeInterval + let low: Double + let high: Double + + var date: Date { Date(timeIntervalSince1970: t) } +} + +struct WatchAlarm: Codable, Equatable { + let id: UUID + let name: String +} + +struct WatchPreset: Codable, Equatable, Identifiable { + let id: String + let name: String + /// Preformatted on the phone in the user's units, e.g. "120 %, 1 h". + let detail: String + /// Raw values the watch needs to send the preset without the phone: the + /// duration (0 = indefinite) and, for temp targets, the target in mg/dL. + var durationSeconds: Double = 0 + var targetMgdl: Double? +} + +struct WatchRemoteConfig: Codable, Equatable { + enum Kind: String, Codable { + case loop + case trio + } + + let kind: Kind + let maxBolus: Double + let bolusIncrement: Double + let maxCarbs: Double + let maxFat: Double + let maxProtein: Double + /// Trio only: a meal may carry a bolus. + let mealWithBolus: Bool + /// Trio only: a meal may carry fat and protein. + let mealWithFatProtein: Bool + /// The AID's own recommendation, as shown in the phone's bolus screen. + var recommendedBolus: Double? + var recommendedBolusAt: Date? + let overridePresets: [WatchPreset] + /// Trio only. + let tempTargetPresets: [WatchPreset] + /// The phone's Quick-Pick suggestions for this time of day. + let quickPickBoluses: [Double] + let quickPickMeals: [WatchMealPick] +} + +struct WatchMealPick: Codable, Equatable { + let carbs: Double + let fat: Double + let protein: Double + let bolus: Double +} diff --git a/WatchShared/WatchStateStore.swift b/WatchShared/WatchStateStore.swift new file mode 100644 index 000000000..65c078618 --- /dev/null +++ b/WatchShared/WatchStateStore.swift @@ -0,0 +1,26 @@ +// LoopFollow +// WatchStateStore.swift + +import Foundation + +/// Keeps the last state received from the phone in the App Group container, so +/// the watch app shows data at launch and the complications can read it. +enum WatchStateStore { + private static let fileName = "watch-state.json" + + private static var fileURL: URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: AppGroupID.current())? + .appendingPathComponent(fileName) + } + + static func save(_ data: Data) { + guard let url = fileURL else { return } + try? data.write(to: url, options: .atomic) + } + + static func load() -> WatchState? { + guard let url = fileURL, let data = try? Data(contentsOf: url) else { return nil } + return WatchState.decode(data) + } +} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 8f46645c5..8bf9986ce 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -43,6 +43,40 @@ end APP_SUFFIX = loopfollow_app_suffix APP_IDENTIFIER = "com.#{TEAMID}.LoopFollow#{APP_SUFFIX}" LA_IDENTIFIER = "#{APP_IDENTIFIER}.LoopFollowLAExtension" +WATCH_IDENTIFIER = "#{APP_IDENTIFIER}.watchkitapp" +WATCH_WIDGETS_IDENTIFIER = "#{WATCH_IDENTIFIER}.widgets" +WATCH_SETUP_DOCS = "https://loopfollowdocs.org/build/lf-browser-build/#update-for-the-apple-watch-app" + +# The watch app needs two identifiers with the LoopFollow App Group, which +# existing browser builders have to add once by hand. +def watch_setup_error! + UI.user_error!( + "The Apple Watch app identifiers are not set up yet. Run the Add Identifiers action, " \ + "add the LoopFollow App Group to the LoopFollow Watch App and LoopFollow Watch Complications identifiers, " \ + "then build again. Instructions: #{WATCH_SETUP_DOCS}" + ) +end + +# Stops before match, whose read-only failure does not name the missing profile. +def ensure_watch_identifiers_exist + missing = [WATCH_IDENTIFIER, WATCH_WIDGETS_IDENTIFIER].reject { |id| Spaceship::ConnectAPI::BundleId.find(id) } + watch_setup_error! unless missing.empty? +end + +# Turns a signing failure caused by a watch identifier without the App Group into a pointer to the docs. +def explain_watch_setup_failure(error, log_dir = nil) + text = error.message.to_s + if log_dir + roots = [log_dir, File.join("..", log_dir), File.join(GITHUB_WORKSPACE.to_s, log_dir)].uniq + roots.flat_map { |root| Dir.glob(File.join(root, "**", "*.log")) }.uniq.each do |log| + text += File.read(log) rescue nil + end + end + + mentions_watch = [WATCH_IDENTIFIER, WATCH_WIDGETS_IDENTIFIER, "LoopFollowWatch"].any? { |id| text.include?(id) } + missing_app_group = text.match?(/App Groups capability|com\.apple\.security\.application-groups/i) + watch_setup_error! if mentions_watch && missing_app_group +end platform :ios do desc "Build Loop Follow" @@ -72,12 +106,16 @@ platform :ios do build_number: current_build_number ) + ensure_watch_identifiers_exist + match( type: "appstore", git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ APP_IDENTIFIER, - LA_IDENTIFIER + LA_IDENTIFIER, + WATCH_IDENTIFIER, + WATCH_WIDGETS_IDENTIFIER ] ) @@ -99,21 +137,42 @@ platform :ios do targets: ["LoopFollowLAExtensionExtension"] ) - gym( - export_method: "app-store", - scheme: "LoopFollow", - output_name: "LoopFollow.ipa", - configuration: "Release", - destination: 'generic/platform=iOS', - buildlog_path: 'buildlog', - export_options: { - provisioningProfiles: { - APP_IDENTIFIER => mapping[APP_IDENTIFIER], - LA_IDENTIFIER => mapping[LA_IDENTIFIER] - } - } + update_code_signing_settings( + path: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj", + profile_name: mapping[WATCH_IDENTIFIER], + code_sign_identity: "iPhone Distribution", + targets: ["LoopFollowWatch"] ) + update_code_signing_settings( + path: "#{GITHUB_WORKSPACE}/LoopFollow.xcodeproj", + profile_name: mapping[WATCH_WIDGETS_IDENTIFIER], + code_sign_identity: "iPhone Distribution", + targets: ["LoopFollowWatchWidgets"] + ) + + begin + gym( + export_method: "app-store", + scheme: "LoopFollow", + output_name: "LoopFollow.ipa", + configuration: "Release", + destination: 'generic/platform=iOS', + buildlog_path: 'buildlog', + export_options: { + provisioningProfiles: { + APP_IDENTIFIER => mapping[APP_IDENTIFIER], + LA_IDENTIFIER => mapping[LA_IDENTIFIER], + WATCH_IDENTIFIER => mapping[WATCH_IDENTIFIER], + WATCH_WIDGETS_IDENTIFIER => mapping[WATCH_WIDGETS_IDENTIFIER] + } + } + ) + rescue => e + explain_watch_setup_failure(e, "buildlog") + raise + end + copy_artifacts( target_path: "artifacts", artifacts: ["*.mobileprovision", "*.ipa", "*.dSYM.zip"] @@ -168,6 +227,14 @@ platform :ios do Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS ]) + configure_bundle_id("LoopFollow Watch App", WATCH_IDENTIFIER, [ + Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS + ]) + + configure_bundle_id("LoopFollow Watch Complications", WATCH_WIDGETS_IDENTIFIER, [ + Spaceship::ConnectAPI::BundleIdCapability::Type::APP_GROUPS + ]) + end desc "Provision Certificates" @@ -188,7 +255,9 @@ platform :ios do git_basic_authorization: Base64.strict_encode64("#{GITHUB_REPOSITORY_OWNER}:#{GH_PAT}"), app_identifier: [ APP_IDENTIFIER, - LA_IDENTIFIER + LA_IDENTIFIER, + WATCH_IDENTIFIER, + WATCH_WIDGETS_IDENTIFIER ] ) end From f29a2ac9f8c06d4b5eefadc801df39fcfbb54b9a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 26 Oct 2026 14:11:18 +0000 Subject: [PATCH 57/84] CI: Bump dev version to 7.1.21 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index bc667a2f2..067c5a100 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.20 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.21 From 3bf62fc93716078e9b1886fd609cc1dae91d3e63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Tue, 27 Oct 2026 00:29:07 +0100 Subject: [PATCH 58/84] Add an early warning option to the temp target and override end alarms (#721) * Add an early warning option to the temp target and override end alarms The end alarms can now also fire a chosen number of minutes before the scheduled end, titled Temp Target Ending Soon or Override Ending Soon, and still fire normally when the event actually ends. Default is 0 which keeps the current behavior. Overrides keep their unclamped scheduled end so indefinite or long overrides do not warn incorrectly. Includes unit tests for both conditions. * Report end alarm phases from a shared evaluator --- LoopFollow/Alarm/Alarm.swift | 6 +- .../Alarm/AlarmCondition/AlarmCondition.swift | 5 + .../Alarm/AlarmCondition/EndAlarmPhases.swift | 47 ++++++ .../AlarmCondition/OverrideEndCondition.swift | 25 +-- .../TempTargetEndCondition.swift | 26 +-- LoopFollow/Alarm/AlarmData.swift | 4 + .../Editors/OverrideEndAlarmEditor.swift | 13 +- .../Editors/TempTargetEndAlarmEditor.swift | 13 +- LoopFollow/Alarm/AlarmManager.swift | 15 +- .../Nightscout/Treatments/Overrides.swift | 6 +- .../Treatments/TemporaryTarget.swift | 4 +- LoopFollow/Helpers/DataStructs.swift | 10 ++ LoopFollow/Snoozer/SnoozerView.swift | 2 +- LoopFollow/Snoozer/SnoozerViewModel.swift | 9 ++ LoopFollow/Storage/Observable.swift | 3 + LoopFollow/Storage/Storage.swift | 2 + LoopFollow/Task/AlarmTask.swift | 4 + Tests/AlarmConditions/Helpers.swift | 47 ++++++ .../OverrideEndConditionTests.swift | 143 +++++++++++++++++ .../TempTargetEndConditionTests.swift | 151 ++++++++++++++++++ 20 files changed, 500 insertions(+), 35 deletions(-) create mode 100644 LoopFollow/Alarm/AlarmCondition/EndAlarmPhases.swift create mode 100644 Tests/AlarmConditions/OverrideEndConditionTests.swift create mode 100644 Tests/AlarmConditions/TempTargetEndConditionTests.swift diff --git a/LoopFollow/Alarm/Alarm.swift b/LoopFollow/Alarm/Alarm.swift index ee54034f4..7bf83811a 100644 --- a/LoopFollow/Alarm/Alarm.swift +++ b/LoopFollow/Alarm/Alarm.swift @@ -228,8 +228,8 @@ struct Alarm: Identifiable, Codable, Equatable { /// Function for when the alarm is triggered. /// If this alarm, all alarms is disabled or snoozed, then should not be called. This or all alarmd could be muted, then this function will just generate a notification. - func trigger(config: AlarmConfiguration, now: Date) { - LogManager.shared.log(category: .alarm, message: "Alarm triggered: \(type.rawValue)") + func trigger(config: AlarmConfiguration, now: Date, titleOverride: String? = nil) { + LogManager.shared.log(category: .alarm, message: "Alarm triggered: \(titleOverride ?? type.rawValue)") var playSound = true @@ -285,7 +285,7 @@ struct Alarm: Identifiable, Codable, Equatable { } }() - AlarmManager.shared.sendNotification(title: type.rawValue, body: notificationBody, actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze") + AlarmManager.shared.sendNotification(title: titleOverride ?? type.rawValue, body: notificationBody, actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze") if playSound { AlarmSound.setSoundFile(soundFile) diff --git a/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift b/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift index 8e60bcb9d..242d84069 100644 --- a/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift @@ -8,6 +8,9 @@ protocol AlarmCondition { init() /// pure, per-alarm logic against `AlarmData` func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool + /// Title for the firing `evaluate` just reported; nil (the default) uses + /// the alarm type's name. + var firedTitle: String? { get } /// `true` when `belowBG`/`aboveBG` are this condition's own trigger /// threshold, checked in `evaluate`; `false` (the default) treats them /// as activation limits enforced by `passesBGLimits`. @@ -17,6 +20,8 @@ protocol AlarmCondition { extension AlarmCondition { var checksOwnBGLimits: Bool { false } + var firedTitle: String? { nil } + /// Returns `true` when the alarm is allowed to continue evaluating /// after BG-limit checks; `false` blocks it immediately. func passesBGLimits(alarm: Alarm, data: AlarmData) -> Bool { diff --git a/LoopFollow/Alarm/AlarmCondition/EndAlarmPhases.swift b/LoopFollow/Alarm/AlarmCondition/EndAlarmPhases.swift new file mode 100644 index 000000000..db4dd32b9 --- /dev/null +++ b/LoopFollow/Alarm/AlarmCondition/EndAlarmPhases.swift @@ -0,0 +1,47 @@ +// LoopFollow +// EndAlarmPhases.swift + +import Foundation + +/// What an end alarm reports: the event has ended, or its scheduled end is +/// within the alarm's early-warning lead time. +enum EndAlarmPhase { + case ended + case endingSoon +} + +/// Two-phase evaluation shared by the temp target and override end alarms. +/// Each phase fires at most once per event; the markers hold the end +/// timestamp of the event that last fired that phase. +struct EndAlarmPhases { + /// Ends older than this are not reported. + static let endedGrace: TimeInterval = 15 * 60 + + let latestStart: TimeInterval? + let latestEnd: TimeInterval? + let activeEnd: TimeInterval? + let leadMinutes: Int? + let endedMarker: StorageValue + let warnedMarker: StorageValue + + /// The ended phase takes precedence when both are due in the same tick. + func fire(now: Date) -> EndAlarmPhase? { + let nowTS = now.timeIntervalSince1970 + + if let endTS = latestEnd, endTS > 0, + nowTS - endTS <= Self.endedGrace, + endTS > (endedMarker.value ?? 0) + { + endedMarker.value = endTS + return .ended + } + + guard let lead = leadMinutes, lead > 0, let endTS = activeEnd else { return nil } + let warningOpensAt = endTS - Double(lead) * 60 + // An event shorter than the lead time is covered by its start alarm. + if let startTS = latestStart, warningOpensAt <= startTS { return nil } + guard nowTS >= warningOpensAt, warnedMarker.value != endTS else { return nil } + warnedMarker.value = endTS + return .endingSoon + } +} diff --git a/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift b/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift index e54ebd08d..e10bae970 100644 --- a/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift @@ -3,18 +3,23 @@ import Foundation -struct OverrideEndCondition: AlarmCondition { +/// Fires once when the active override ends and, with `predictiveMinutes` +/// set, once that many minutes before the scheduled end. +final class OverrideEndCondition: AlarmCondition { static let type: AlarmType = .overrideEnd + private(set) var firedTitle: String? init() {} - func evaluate(alarm _: Alarm, data: AlarmData, now: Date) -> Bool { - guard let endTS = data.latestOverrideEnd, endTS > 0 else { return false } - guard now.timeIntervalSince1970 - endTS <= 15 * 60 else { return false } - - let last = Storage.shared.lastOverrideEndNotified.value ?? 0 - guard endTS > last else { return false } - - Storage.shared.lastOverrideEndNotified.value = endTS - return true + func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool { + let phase = EndAlarmPhases( + latestStart: data.latestOverrideStart, + latestEnd: data.latestOverrideEnd, + activeEnd: data.activeOverrideEnd, + leadMinutes: alarm.predictiveMinutes, + endedMarker: Storage.shared.lastOverrideEndNotified, + warnedMarker: Storage.shared.lastOverridePreEndNotified + ).fire(now: now) + firedTitle = phase == .endingSoon ? "Override Ending Soon" : nil + return phase != nil } } diff --git a/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift b/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift index 207332e96..800129f96 100644 --- a/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift @@ -3,19 +3,23 @@ import Foundation -/// Fires once when the active temp target ends. -struct TempTargetEndCondition: AlarmCondition { +/// Fires once when the active temp target ends and, with `predictiveMinutes` +/// set, once that many minutes before the scheduled end. +final class TempTargetEndCondition: AlarmCondition { static let type: AlarmType = .tempTargetEnd + private(set) var firedTitle: String? init() {} - func evaluate(alarm _: Alarm, data: AlarmData, now: Date) -> Bool { - guard let endTS = data.latestTempTargetEnd, endTS > 0 else { return false } - guard now.timeIntervalSince1970 - endTS <= 15 * 60 else { return false } - - let last = Storage.shared.lastTempTargetEndNotified.value ?? 0 - guard endTS > last else { return false } - - Storage.shared.lastTempTargetEndNotified.value = endTS - return true + func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool { + let phase = EndAlarmPhases( + latestStart: data.latestTempTargetStart, + latestEnd: data.latestTempTargetEnd, + activeEnd: data.activeTempTargetEnd, + leadMinutes: alarm.predictiveMinutes, + endedMarker: Storage.shared.lastTempTargetEndNotified, + warnedMarker: Storage.shared.lastTempTargetPreEndNotified + ).fire(now: now) + firedTitle = phase == .endingSoon ? "Temp Target Ending Soon" : nil + return phase != nil } } diff --git a/LoopFollow/Alarm/AlarmData.swift b/LoopFollow/Alarm/AlarmData.swift index 588c37c5f..dcf6bf946 100644 --- a/LoopFollow/Alarm/AlarmData.swift +++ b/LoopFollow/Alarm/AlarmData.swift @@ -12,6 +12,10 @@ struct AlarmData: Codable { let latestOverrideEnd: TimeInterval? let latestTempTargetStart: TimeInterval? let latestTempTargetEnd: TimeInterval? + /// Scheduled end of the currently-active override/temp target, if known + /// in advance β€” used by the end alarms' early warning. + var activeOverrideEnd: TimeInterval? + var activeTempTargetEnd: TimeInterval? let recBolus: Double? let COB: Double? let sageInsertTime: TimeInterval? diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/OverrideEndAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/OverrideEndAlarmEditor.swift index 5d88a527a..d29a4a4e3 100644 --- a/LoopFollow/Alarm/AlarmEditing/Editors/OverrideEndAlarmEditor.swift +++ b/LoopFollow/Alarm/AlarmEditing/Editors/OverrideEndAlarmEditor.swift @@ -8,10 +8,21 @@ struct OverrideEndAlarmEditor: View { var body: some View { Group { - InfoBanner(text: "Alerts when an override ends.", alarmType: alarm.type) + InfoBanner(text: "Alerts when an override ends, with an optional early warning before the scheduled end.", alarmType: alarm.type) AlarmGeneralSection(alarm: $alarm) + AlarmStepperSection( + header: "Early Warning", + footer: "Also alert this many minutes before the override " + + "is scheduled to end. Set to 0 to alert only when it ends.", + title: "Warn before end", + range: 0 ... 30, + step: 5, + unitLabel: alarm.type.snoozeTimeUnit.label, + value: $alarm.predictiveMinutes + ) + AlarmActiveSection(alarm: $alarm) AlarmAudioSection(alarm: $alarm, hideRepeat: true) AlarmSnoozeSection(alarm: $alarm) diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/TempTargetEndAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/TempTargetEndAlarmEditor.swift index 03a7756f9..b191da54f 100644 --- a/LoopFollow/Alarm/AlarmEditing/Editors/TempTargetEndAlarmEditor.swift +++ b/LoopFollow/Alarm/AlarmEditing/Editors/TempTargetEndAlarmEditor.swift @@ -8,10 +8,21 @@ struct TempTargetEndAlarmEditor: View { var body: some View { Group { - InfoBanner(text: "Alerts when a temp target ends.", alarmType: alarm.type) + InfoBanner(text: "Alerts when a temp target ends, with an optional early warning before the scheduled end.", alarmType: alarm.type) AlarmGeneralSection(alarm: $alarm) + AlarmStepperSection( + header: "Early Warning", + footer: "Also alert this many minutes before the temp target " + + "is scheduled to end. Set to 0 to alert only when it ends.", + title: "Warn before end", + range: 0 ... 30, + step: 5, + unitLabel: alarm.type.snoozeTimeUnit.label, + value: $alarm.predictiveMinutes + ) + AlarmActiveSection(alarm: $alarm) AlarmAudioSection(alarm: $alarm, hideRepeat: true) AlarmSnoozeSection(alarm: $alarm) diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index b0ee65912..878bf5701 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -127,16 +127,24 @@ class AlarmManager { continue } - // If this alarm is active, and still fulfill the requirements, let it be active - // Break the loop, nothing else to do + let titleOverride = checker.firedTitle + + // An active alarm that still fulfills the requirements stays active. + // A different phase of it (an end alarm's early warning followed + // by the end itself) is announced with its own title. if Observable.shared.currentAlarm.value == alarm.id { + if titleOverride != Observable.shared.currentAlarmTitleOverride.value { + Observable.shared.currentAlarmTitleOverride.value = titleOverride + alarm.trigger(config: Storage.shared.alarmConfiguration.value, now: now, titleOverride: titleOverride) + } break } // Fire the alarm and break the loop; we only allow one alarm per evaluation tick. + Observable.shared.currentAlarmTitleOverride.value = titleOverride Observable.shared.currentAlarm.value = alarm.id - alarm.trigger(config: Storage.shared.alarmConfiguration.value, now: now) + alarm.trigger(config: Storage.shared.alarmConfiguration.value, now: now, titleOverride: titleOverride) // Store the latest bg time so we don't use it again if alarm.type.isBGBased, @@ -184,6 +192,7 @@ class AlarmManager { func stopAlarm() { AlarmSound.stop() Observable.shared.currentAlarm.value = nil + Observable.shared.currentAlarmTitleOverride.value = nil UNUserNotificationCenter.current().removeAllPendingNotificationRequests() } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift index fddc55200..3a56a641c 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift @@ -51,8 +51,9 @@ extension MainViewController { var end: TimeInterval = isIndefinite ? maxEndDate : start + durationSeconds - // True end for countdown display: based on the raw start and never - // clamped to the graph edge; nil while indefinite. + // True end for countdown display and the end alarm's early + // warning: based on the raw start and never clamped to the graph + // edge; nil while indefinite. var trueEnd: TimeInterval? = isIndefinite ? nil : startDate.timeIntervalSince1970 + durationSeconds if let nextStart = nextStart { @@ -85,6 +86,7 @@ extension MainViewController { ?? (e["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "", sgv: -20, + scheduledEndDate: trueEnd, detail: Treatment.historyEntries(from: e, date: startDate.timeIntervalSince1970) { 0 }.entries.first ) overrideGraphData.append(dot) diff --git a/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift b/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift index 2222a230f..9f8cea5a0 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift @@ -61,9 +61,7 @@ extension MainViewController { let currentTime = Date().timeIntervalSince1970 if currentTime < endDate { activeTempTarget = Int(targetValue!) - // Trio represents indefinite temp targets as a ~30-day duration; - // treat a week or longer as indefinite (no end to display). - activeTempTargetEndAt = duration >= 7 * 24 * 3600 ? nil : endDate + activeTempTargetEndAt = dot.scheduledEndDate } } diff --git a/LoopFollow/Helpers/DataStructs.swift b/LoopFollow/Helpers/DataStructs.swift index d6330a536..f2690d279 100644 --- a/LoopFollow/Helpers/DataStructs.swift +++ b/LoopFollow/Helpers/DataStructs.swift @@ -46,6 +46,10 @@ class DataStructs { var enteredBy: String var reason: String var sgv: Float + /// Scheduled end (start + duration); unlike `endDate` it is never + /// clamped to the graph horizon. Nil when the override runs + /// indefinitely, so the end is not known in advance. + var scheduledEndDate: TimeInterval? /// The Treatments list entry this band opens. var detail: Treatment? = nil } @@ -59,5 +63,11 @@ class DataStructs { var reason: String /// The Treatments list entry this band opens. var detail: Treatment? = nil + + /// Scheduled end; nil for an indefinite target, which Trio stores + /// with a duration of about 30 days. + var scheduledEndDate: TimeInterval? { + duration >= 7 * 24 * 3600 ? nil : endDate + } } } diff --git a/LoopFollow/Snoozer/SnoozerView.swift b/LoopFollow/Snoozer/SnoozerView.swift index df65c67cf..939356d74 100644 --- a/LoopFollow/Snoozer/SnoozerView.swift +++ b/LoopFollow/Snoozer/SnoozerView.swift @@ -110,7 +110,7 @@ struct SnoozerView: View { if let alarm = vm.activeAlarm { VStack(spacing: 16) { // Alarm name at the top - Text(alarm.name) + Text(vm.alarmTitle) .font(.system(size: 30, weight: .semibold)) .foregroundColor(.white) .lineLimit(1) diff --git a/LoopFollow/Snoozer/SnoozerViewModel.swift b/LoopFollow/Snoozer/SnoozerViewModel.swift index bb6a3aa16..7b806e52f 100644 --- a/LoopFollow/Snoozer/SnoozerViewModel.swift +++ b/LoopFollow/Snoozer/SnoozerViewModel.swift @@ -6,6 +6,7 @@ import Foundation final class SnoozerViewModel: ObservableObject { @Published var activeAlarm: Alarm? + @Published var alarmTitle: String = "" @Published var snoozeUnits: Int = 5 @Published var timeUnitLabel: String = "minutes" @@ -21,11 +22,19 @@ final class SnoozerViewModel: ObservableObject { .sink { [weak self] alarm in self?.activeAlarm = alarm if let a = alarm { + self?.alarmTitle = Observable.shared.currentAlarmTitleOverride.value ?? a.name self?.snoozeUnits = a.snoozeDuration self?.timeUnitLabel = a.type.snoozeTimeUnit.label } } .store(in: &cancellables) + Observable.shared.currentAlarmTitleOverride.$value + .receive(on: DispatchQueue.main) + .sink { [weak self] title in + guard let self, let alarm = self.activeAlarm else { return } + self.alarmTitle = title ?? alarm.name + } + .store(in: &cancellables) if let alarm = activeAlarm { snoozeUnits = alarm.snoozeDuration } diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 645703b6a..f65268762 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -36,6 +36,9 @@ class Observable { var predictionColor = ObservableValue(default: .purple) var currentAlarm = ObservableValue(default: nil) + /// Replaces the current alarm's name on screen when the firing needs its + /// own wording (e.g. an end alarm's early warning); nil shows the name. + var currentAlarmTitleOverride = ObservableValue(default: nil) var alarmSoundPlaying = ObservableValue(default: false) var debug = ObservableValue(default: false) diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index ddb47e7b5..586fc0d28 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -64,8 +64,10 @@ class Storage { var lastOverrideStartNotified = StorageValue(key: "lastOverrideStartNotified", defaultValue: nil) var lastOverrideEndNotified = StorageValue(key: "lastOverrideEndNotified", defaultValue: nil) + var lastOverridePreEndNotified = StorageValue(key: "lastOverridePreEndNotified", defaultValue: nil) var lastTempTargetStartNotified = StorageValue(key: "lastTempTargetStartNotified", defaultValue: nil) var lastTempTargetEndNotified = StorageValue(key: "lastTempTargetEndNotified", defaultValue: nil) + var lastTempTargetPreEndNotified = StorageValue(key: "lastTempTargetPreEndNotified", defaultValue: nil) var lastRecBolusNotified = StorageValue(key: "lastRecBolusNotified", defaultValue: nil) var lastCOBNotified = StorageValue(key: "lastCOBNotified", defaultValue: nil) var lastMissedBolusNotified = StorageValue(key: "lastMissedBolusNotified", defaultValue: nil) diff --git a/LoopFollow/Task/AlarmTask.swift b/LoopFollow/Task/AlarmTask.swift index 6c6b341ae..53bea67ba 100644 --- a/LoopFollow/Task/AlarmTask.swift +++ b/LoopFollow/Task/AlarmTask.swift @@ -19,6 +19,8 @@ extension MainViewController { let latestOverrideEnd = self.overrideGraphData.last { $0.endDate <= now }?.endDate let latestTempTargetStart = self.tempTargetGraphData.last { $0.date <= now }?.date let latestTempTargetEnd = self.tempTargetGraphData.last { $0.endDate <= now }?.endDate + let activeOverrideEnd = self.overrideGraphData.last { $0.date <= now && ($0.scheduledEndDate ?? 0) > now }?.scheduledEndDate + let activeTempTargetEnd = self.tempTargetGraphData.last { $0.date <= now && $0.endDate > now }?.scheduledEndDate let recBolus = Observable.shared.deviceRecBolus.value let COB = self.latestCOB?.value let sensorInsertedAt = Storage.shared.sageInsertTime.value @@ -40,6 +42,8 @@ extension MainViewController { latestOverrideEnd: latestOverrideEnd, latestTempTargetStart: latestTempTargetStart, latestTempTargetEnd: latestTempTargetEnd, + activeOverrideEnd: activeOverrideEnd, + activeTempTargetEnd: activeTempTargetEnd, recBolus: recBolus, COB: COB, sageInsertTime: sensorInsertedAt, diff --git a/Tests/AlarmConditions/Helpers.swift b/Tests/AlarmConditions/Helpers.swift index 905330552..55a50aac6 100644 --- a/Tests/AlarmConditions/Helpers.swift +++ b/Tests/AlarmConditions/Helpers.swift @@ -52,6 +52,18 @@ extension Alarm { alarm.monitoringWindow = window return alarm } + + static func tempTargetEnd(warnBefore: Int? = nil) -> Self { + var alarm = Alarm(type: .tempTargetEnd) + alarm.predictiveMinutes = warnBefore + return alarm + } + + static func overrideEnd(warnBefore: Int? = nil) -> Self { + var alarm = Alarm(type: .overrideEnd) + alarm.predictiveMinutes = warnBefore + return alarm + } } // MARK: - AlarmData helpers @@ -169,6 +181,41 @@ extension AlarmData { ) } + static func withEnds( + latestTempTargetStart: TimeInterval? = nil, + latestTempTargetEnd: TimeInterval? = nil, + activeTempTargetEnd: TimeInterval? = nil, + latestOverrideStart: TimeInterval? = nil, + latestOverrideEnd: TimeInterval? = nil, + activeOverrideEnd: TimeInterval? = nil + ) -> Self { + AlarmData( + bgReadings: [], + predictionData: [], + expireDate: nil, + lastLoopTime: nil, + latestOverrideStart: latestOverrideStart, + latestOverrideEnd: latestOverrideEnd, + latestTempTargetStart: latestTempTargetStart, + latestTempTargetEnd: latestTempTargetEnd, + activeOverrideEnd: activeOverrideEnd, + activeTempTargetEnd: activeTempTargetEnd, + recBolus: nil, + COB: nil, + sageInsertTime: nil, + pumpInsertTime: nil, + latestPumpVolume: nil, + IOB: nil, + recentBoluses: [], + latestBattery: nil, + latestBatteryIsCharging: nil, + latestPumpBattery: nil, + batteryHistory: [], + recentCarbs: [], + dbSizePercentage: nil + ) + } + static func withCarbs(_ carbs: [CarbSample], boluses: [BolusEntry] = []) -> Self { AlarmData( bgReadings: [], diff --git a/Tests/AlarmConditions/OverrideEndConditionTests.swift b/Tests/AlarmConditions/OverrideEndConditionTests.swift new file mode 100644 index 000000000..dd8ac1175 --- /dev/null +++ b/Tests/AlarmConditions/OverrideEndConditionTests.swift @@ -0,0 +1,143 @@ +// LoopFollow +// OverrideEndConditionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@Suite(.serialized) +struct OverrideEndConditionTests { + let cond = OverrideEndCondition() + + private func reset() { + Storage.shared.lastOverrideEndNotified.value = nil + Storage.shared.lastOverridePreEndNotified.value = nil + } + + // MARK: - End firing + + @Test("end firing fires once, then dedups") + func endFiresOnceThenDedups() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 - 60 + let alarm = Alarm.overrideEnd() + let data = AlarmData.withEnds(latestOverrideEnd: end) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("end firing ignores ends older than 15 minutes") + func endIgnoresStaleEnds() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 - 16 * 60 + let alarm = Alarm.overrideEnd() + let data = AlarmData.withEnds(latestOverrideEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + // MARK: - Early warning + + @Test("no early warning without a lead time") + func noEarlyWarningByDefault() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 4 * 60 + let data = AlarmData.withEnds(activeOverrideEnd: end) + + #expect(!cond.evaluate(alarm: .overrideEnd(), data: data, now: now)) + #expect(!cond.evaluate(alarm: .overrideEnd(warnBefore: 0), data: data, now: now)) + } + + @Test("no early warning when the end is unknown (indefinite override)") + func noEarlyWarningWithoutScheduledEnd() { + reset() + let now = Date() + let alarm = Alarm.overrideEnd(warnBefore: 10) + let data = AlarmData.withEnds(activeOverrideEnd: nil) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("early warning fires inside the lead window, once") + func earlyWarningFiresOnceInsideWindow() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 5 * 60 + let alarm = Alarm.overrideEnd(warnBefore: 10) + let data = AlarmData.withEnds(activeOverrideEnd: end) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("early warning does NOT fire before the lead window") + func earlyWarningWaitsForWindow() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 6 * 60 + let alarm = Alarm.overrideEnd(warnBefore: 5) + let data = AlarmData.withEnds(activeOverrideEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("early warning and end both fire for one event, with the right titles") + func bothPhasesFireWithTitles() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 4 * 60 + let alarm = Alarm.overrideEnd(warnBefore: 5) + + let preData = AlarmData.withEnds(activeOverrideEnd: end) + #expect(cond.evaluate(alarm: alarm, data: preData, now: now)) + #expect(cond.firedTitle == "Override Ending Soon") + + let endNow = Date(timeIntervalSince1970: end + 60) + let endData = AlarmData.withEnds(latestOverrideEnd: end) + #expect(cond.evaluate(alarm: alarm, data: endData, now: endNow)) + #expect(cond.firedTitle == nil) + } + + @Test("extending the override re-arms the early warning for the new end") + func extensionRearmsEarlyWarning() { + reset() + let now = Date() + let firstEnd = now.timeIntervalSince1970 + 4 * 60 + let alarm = Alarm.overrideEnd(warnBefore: 5) + + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeOverrideEnd: firstEnd), now: now)) + + let extendedEnd = firstEnd + 30 * 60 + let laterNow = Date(timeIntervalSince1970: extendedEnd - 4 * 60) + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeOverrideEnd: extendedEnd), now: laterNow)) + } + + @Test("a replacement whose end is earlier than a warned end still warns") + func replacementWithEarlierEndWarns() { + reset() + let now = Date() + let alarm = Alarm.overrideEnd(warnBefore: 30) + let firstEnd = now.timeIntervalSince1970 + 25 * 60 + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeOverrideEnd: firstEnd), now: now)) + + let replacementEnd = now.timeIntervalSince1970 + 10 * 60 + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeOverrideEnd: replacementEnd), now: now)) + #expect(cond.firedTitle == "Override Ending Soon") + } + + @Test("no early warning when the lead time covers the whole event") + func noEarlyWarningForEventShorterThanLead() { + reset() + let now = Date() + let alarm = Alarm.overrideEnd(warnBefore: 30) + let start = now.timeIntervalSince1970 - 60 + let end = now.timeIntervalSince1970 + 20 * 60 + let data = AlarmData.withEnds(latestOverrideStart: start, activeOverrideEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } +} diff --git a/Tests/AlarmConditions/TempTargetEndConditionTests.swift b/Tests/AlarmConditions/TempTargetEndConditionTests.swift new file mode 100644 index 000000000..9cc83f223 --- /dev/null +++ b/Tests/AlarmConditions/TempTargetEndConditionTests.swift @@ -0,0 +1,151 @@ +// LoopFollow +// TempTargetEndConditionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@Suite(.serialized) +struct TempTargetEndConditionTests { + let cond = TempTargetEndCondition() + + private func reset() { + Storage.shared.lastTempTargetEndNotified.value = nil + Storage.shared.lastTempTargetPreEndNotified.value = nil + } + + // MARK: - End firing + + @Test("end firing fires once, then dedups") + func endFiresOnceThenDedups() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 - 60 + let alarm = Alarm.tempTargetEnd() + let data = AlarmData.withEnds(latestTempTargetEnd: end) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("end firing ignores ends older than 15 minutes") + func endIgnoresStaleEnds() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 - 16 * 60 + let alarm = Alarm.tempTargetEnd() + let data = AlarmData.withEnds(latestTempTargetEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + // MARK: - Early warning + + @Test("no early warning without a lead time") + func noEarlyWarningByDefault() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 4 * 60 + let data = AlarmData.withEnds(activeTempTargetEnd: end) + + #expect(!cond.evaluate(alarm: .tempTargetEnd(), data: data, now: now)) + #expect(!cond.evaluate(alarm: .tempTargetEnd(warnBefore: 0), data: data, now: now)) + } + + @Test("early warning fires inside the lead window, once") + func earlyWarningFiresOnceInsideWindow() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 5 * 60 + let alarm = Alarm.tempTargetEnd(warnBefore: 10) + let data = AlarmData.withEnds(activeTempTargetEnd: end) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("early warning does NOT fire before the lead window") + func earlyWarningWaitsForWindow() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 6 * 60 + let alarm = Alarm.tempTargetEnd(warnBefore: 5) + let data = AlarmData.withEnds(activeTempTargetEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("early warning and end both fire for one event, with the right titles") + func bothPhasesFireWithTitles() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 4 * 60 + let alarm = Alarm.tempTargetEnd(warnBefore: 5) + + let preData = AlarmData.withEnds(activeTempTargetEnd: end) + #expect(cond.evaluate(alarm: alarm, data: preData, now: now)) + #expect(cond.firedTitle == "Temp Target Ending Soon") + + let endNow = Date(timeIntervalSince1970: end + 60) + let endData = AlarmData.withEnds(latestTempTargetEnd: end) + #expect(cond.evaluate(alarm: alarm, data: endData, now: endNow)) + #expect(cond.firedTitle == nil) + } + + @Test("extending the temp target re-arms the early warning for the new end") + func extensionRearmsEarlyWarning() { + reset() + let now = Date() + let firstEnd = now.timeIntervalSince1970 + 4 * 60 + let alarm = Alarm.tempTargetEnd(warnBefore: 5) + + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeTempTargetEnd: firstEnd), now: now)) + + let extendedEnd = firstEnd + 30 * 60 + let laterNow = Date(timeIntervalSince1970: extendedEnd - 4 * 60) + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeTempTargetEnd: extendedEnd), now: laterNow)) + } + + @Test("when both phases are due, the end fires first and the warning follows") + func endPhaseWinsSameTick() { + reset() + let now = Date() + let previousEnd = now.timeIntervalSince1970 - 60 + let activeEnd = now.timeIntervalSince1970 + 2 * 60 + let alarm = Alarm.tempTargetEnd(warnBefore: 5) + let data = AlarmData.withEnds(latestTempTargetEnd: previousEnd, activeTempTargetEnd: activeEnd) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(cond.firedTitle == nil) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(cond.firedTitle == "Temp Target Ending Soon") + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("a replacement whose end is earlier than a warned end still warns") + func replacementWithEarlierEndWarns() { + reset() + let now = Date() + let alarm = Alarm.tempTargetEnd(warnBefore: 30) + let firstEnd = now.timeIntervalSince1970 + 25 * 60 + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeTempTargetEnd: firstEnd), now: now)) + + let replacementEnd = now.timeIntervalSince1970 + 10 * 60 + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeTempTargetEnd: replacementEnd), now: now)) + #expect(cond.firedTitle == "Temp Target Ending Soon") + } + + @Test("no early warning when the lead time covers the whole event") + func noEarlyWarningForEventShorterThanLead() { + reset() + let now = Date() + let alarm = Alarm.tempTargetEnd(warnBefore: 30) + let start = now.timeIntervalSince1970 - 60 + let end = now.timeIntervalSince1970 + 20 * 60 + let data = AlarmData.withEnds(latestTempTargetStart: start, activeTempTargetEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } +} From 26da4636718f12fb17eeacde17e4975dd516a66d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 26 Oct 2026 23:29:18 +0000 Subject: [PATCH 59/84] CI: Bump dev version to 7.1.22 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 067c5a100..b3afa7ce1 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.21 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.22 From 20ad48224cd5e0a34c175abca5ee94f6359ddeca Mon Sep 17 00:00:00 2001 From: kristinkand <163904051+kristinkand@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:44:39 +0100 Subject: [PATCH 60/84] Home screen stats: TIR band and shared period (last 24 h or since midnight) (#774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Time in Range band to home screen * Refresh home screen stats when Range Mode changes * Fix TIR band counting and match the stats screen The band now uses TIRCalculator, so each reading is counted once with the same 54 and 250 mg/dL cutoffs as the TIR chart in Stats. It also has a very high segment and uses the same colors as that chart. Every non-empty segment gets a minimum width so a single low reading stays visible, and the bar fills the full width of the band. The Stats Style setting is only in General. * Use the same period for statistics box and TIR band (last 24h or since midnight) Use the same period for statistics box and TIR band (last 24h or since midnight) * Keep very low and very high outside a custom range Very low is below the lower of 54 mg/dL and the range's low bound, and very high is above the higher of 250 mg/dL and the range's high bound. In range in the TIR band and the Stats screen then matches the statistics box for any custom range. * Refresh home screen stats when the custom range thresholds change * Share the Range Mode title between the TIR band and the Stats screen * Share TIR band thresholds between calculations and the Stats legend Very low and very high cutoffs come from one place, so the calculator, the home screen band and the Stats legend agree when a custom range reaches past 54 or 250 mg/dL. Legend rows for low and high bands with no width are hidden. --------- Co-authored-by: Jonas BjΓΆrkert Co-authored-by: Claude Opus 5.5 --- .../MainViewController+updateStats.swift | 70 ++++++++++--- .../Controllers/StatsDisplayModel.swift | 10 ++ LoopFollow/Controllers/TIRBandView.swift | 99 +++++++++++++++++++ LoopFollow/Helpers/Localizer.swift | 19 ++++ LoopFollow/Settings/GeneralSettingsView.swift | 21 +++- LoopFollow/Settings/SettingsMenuView.swift | 4 +- LoopFollow/Stats/TIR/TIRCalculator.swift | 16 +-- LoopFollow/Stats/TIR/TIRGraphView.swift | 2 +- LoopFollow/Stats/TIR/TIRView.swift | 57 ++++++----- LoopFollow/Storage/Storage.swift | 5 + LoopFollow/ViewControllers/MainHomeView.swift | 7 +- .../ViewControllers/MainViewController.swift | 8 +- 12 files changed, 266 insertions(+), 52 deletions(-) create mode 100644 LoopFollow/Controllers/TIRBandView.swift diff --git a/LoopFollow/Controllers/MainViewController+updateStats.swift b/LoopFollow/Controllers/MainViewController+updateStats.swift index f3c4c0d10..1f10d25fb 100644 --- a/LoopFollow/Controllers/MainViewController+updateStats.swift +++ b/LoopFollow/Controllers/MainViewController+updateStats.swift @@ -4,21 +4,34 @@ import Foundation extension MainViewController { + /// Start of the period both home screen stats cover: the last 24 hours, or since + /// midnight when chosen in Settings. Midnight follows the display time zone, so a + /// Time Zone Override is respected. + func homeStatsPeriodStart() -> TimeInterval { + if Storage.shared.statsSinceMidnight.value { + return dateTimeUtils.displayCalendar().startOfDay(for: Date()).timeIntervalSince1970 + } + return dateTimeUtils.getTimeIntervalNHoursAgo(N: 24) + } + func updateStats() { - if bgData.count > 0 { - var lastDayOfData = bgData - let graphHours = 24 * Storage.shared.downloadDays.value - // If we loaded more than 1 day of data, only use the last day for the stats - if graphHours > 24 { - let oneDayAgo = dateTimeUtils.getTimeIntervalNHoursAgo(N: 24) - var startIndex = 0 - while startIndex < bgData.count, bgData[startIndex].date < oneDayAgo { - startIndex += 1 - } - lastDayOfData = Array(bgData.dropFirst(startIndex)) - } + let periodStart = homeStatsPeriodStart() + let periodData = bgData.filter { $0.date >= periodStart } - let stats = StatsData(bgData: lastDayOfData) + if periodData.isEmpty { + // Nothing in the period yet (e.g. just after midnight): show placeholders + // rather than the previous period's numbers. + statsDisplayModel.lowPercent = "--" + statsDisplayModel.inRangePercent = "--" + statsDisplayModel.highPercent = "--" + statsDisplayModel.avgBG = "--" + statsDisplayModel.estA1C = "--" + statsDisplayModel.stdDev = "--" + statsDisplayModel.pieLow = 0 + statsDisplayModel.pieRange = 0 + statsDisplayModel.pieHigh = 0 + } else { + let stats = StatsData(bgData: periodData) statsDisplayModel.lowPercent = String(format: "%.1f%%", stats.percentLow) statsDisplayModel.inRangePercent = String(format: "%.1f%%", stats.percentRange) @@ -48,5 +61,36 @@ extension MainViewController { statsDisplayModel.pieRange = Double(stats.percentRange) statsDisplayModel.pieHigh = Double(stats.percentHigh) } + updateTIRBand(periodStart: periodStart) + } + + /// Range distribution for the Time in Range band over the same period as the + /// statistics box (last 24 hours, or today since midnight). + /// Low, in range and high follow the Range Mode chosen in Settings (TIR, TITR + /// or Custom). Very low is below 54 mg/dL and very high above 250 mg/dL, or + /// the range's own bounds when a custom range extends past them. + func updateTIRBand(periodStart: TimeInterval) { + let thresholds = UnitSettingsStore.shared.effectiveBandThresholds() + let values = bgData + .filter { $0.date >= periodStart && $0.sgv > 0 } + .map { Double($0.sgv) } + + statsDisplayModel.bandTitle = UnitSettingsStore.shared.timeInRangeTitle + + let percentages = TIRCalculator.calculatePercentages( + readings: values, + veryLowThreshold: thresholds.veryLow, + lowThreshold: thresholds.low, + highThreshold: thresholds.high, + veryHighThreshold: thresholds.veryHigh + ) + + statsDisplayModel.bandVeryLowPct = percentages.veryLow + statsDisplayModel.bandLowPct = percentages.low + statsDisplayModel.bandInRangePct = percentages.inRange + statsDisplayModel.bandHighPct = percentages.high + statsDisplayModel.bandVeryHighPct = percentages.veryHigh + statsDisplayModel.bandHasData = !values.isEmpty + statsDisplayModel.bandPeriod = Storage.shared.statsSinceMidnight.value ? "today" : "last 24h" } } diff --git a/LoopFollow/Controllers/StatsDisplayModel.swift b/LoopFollow/Controllers/StatsDisplayModel.swift index 72e60560a..425a4487b 100644 --- a/LoopFollow/Controllers/StatsDisplayModel.swift +++ b/LoopFollow/Controllers/StatsDisplayModel.swift @@ -15,4 +15,14 @@ class StatsDisplayModel: ObservableObject { @Published var pieLow: Double = 0 @Published var pieRange: Double = 0 @Published var pieHigh: Double = 0 + + // Time in Range band (same period as the statistics box) + @Published var bandTitle: String = "Time in Range" + @Published var bandPeriod: String = "last 24h" + @Published var bandHasData: Bool = false + @Published var bandVeryLowPct: Double = 0 + @Published var bandLowPct: Double = 0 + @Published var bandInRangePct: Double = 0 + @Published var bandHighPct: Double = 0 + @Published var bandVeryHighPct: Double = 0 } diff --git a/LoopFollow/Controllers/TIRBandView.swift b/LoopFollow/Controllers/TIRBandView.swift new file mode 100644 index 000000000..dfb301088 --- /dev/null +++ b/LoopFollow/Controllers/TIRBandView.swift @@ -0,0 +1,99 @@ +// LoopFollow +// TIRBandView.swift + +import SwiftUI + +/// Time in Range band for the home screen: today's in-range +/// percentage above a segmented bar (very low / low / in range / high / very high). +/// The range follows the Range Mode in Settings (TIR, TITR or Custom). +/// Tapping opens the statistics screen. +struct TIRBandView: View { + @ObservedObject var model: StatsDisplayModel + var onTap: (() -> Void)? + + private var percentText: String { + guard model.bandHasData else { return "-- %" } + return model.bandInRangePct.formatted(.number.precision(.fractionLength(0 ... 1))) + " %" + } + + private var segments: [(color: Color, fraction: CGFloat)] { + guard model.bandHasData else { return [(Color.secondary.opacity(0.3), 1)] } + return [ + (TIRGraphView.Band.veryLow.color, CGFloat(model.bandVeryLowPct / 100)), + (TIRGraphView.Band.low.color, CGFloat(model.bandLowPct / 100)), + (TIRGraphView.Band.inRange.color, CGFloat(model.bandInRangePct / 100)), + (TIRGraphView.Band.high.color, CGFloat(model.bandHighPct / 100)), + (TIRGraphView.Band.veryHigh.color, CGFloat(model.bandVeryHighPct / 100)), + ] + } + + var body: some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(percentText) + .font(.title2) + .fontWeight(.bold) + .fontDesign(.rounded) + .foregroundStyle(.primary) + (Text(model.bandTitle).fontWeight(.semibold) + Text(" " + model.bandPeriod)) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + + DistributionBar(segments: segments) + .frame(height: 6) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: "chevron.right") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 16) + .frame(height: 64) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(.secondarySystemBackground)) + ) + .contentShape(Rectangle()) + .onTapGesture { onTap?() } + } +} + +/// Horizontal bar of capsules, one per non-empty segment, sized by share. +/// Every non-empty segment is at least `minSegmentWidth` wide so a single +/// reading stays visible. +private struct DistributionBar: View { + let segments: [(color: Color, fraction: CGFloat)] + + private let spacing: CGFloat = 2 + private let minSegmentWidth: CGFloat = 4 + + var body: some View { + GeometryReader { geo in + let shown = segments.filter { $0.fraction > 0 } + let available = max(geo.size.width - spacing * CGFloat(max(shown.count - 1, 0)), 0) + let widths = segmentWidths(shown.map(\.fraction), available: available) + HStack(spacing: spacing) { + ForEach(Array(shown.enumerated()), id: \.offset) { index, segment in + Capsule() + .fill(segment.color) + .frame(width: widths[index]) + } + } + .frame(maxHeight: .infinity) + } + } + + private func segmentWidths(_ fractions: [CGFloat], available: CGFloat) -> [CGFloat] { + var widths = fractions.map { max($0 * available, minSegmentWidth) } + let excess = widths.reduce(0, +) - available + if excess > 0, let widest = widths.indices.max(by: { widths[$0] < widths[$1] }) { + widths[widest] = max(widths[widest] - excess, minSegmentWidth) + } + return widths + } +} diff --git a/LoopFollow/Helpers/Localizer.swift b/LoopFollow/Helpers/Localizer.swift index b615a4cf6..e36c08153 100644 --- a/LoopFollow/Helpers/Localizer.swift +++ b/LoopFollow/Helpers/Localizer.swift @@ -74,6 +74,25 @@ final class UnitSettingsStore { } } + /// Returns the very low, low, high and very high thresholds (in mg/dL) for + /// the current range mode. Very low is 54 and very high 250, or the range's + /// own bounds when a custom range extends past them. + func effectiveBandThresholds() -> (veryLow: Double, low: Double, high: Double, veryHigh: Double) { + let range = effectiveThresholds() + return (min(54.0, range.low), range.low, range.high, max(250.0, range.high)) + } + + var timeInRangeTitle: String { + switch timeInRangeMode { + case .tir: + return "Time in Range" + case .titr: + return "Time in Tight Range" + case .custom: + return "Custom Range" + } + } + var glycemicMetricMode: GlycemicMetricMode { get { Storage.shared.showGMI.value ? .gmi : .ehba1c diff --git a/LoopFollow/Settings/GeneralSettingsView.swift b/LoopFollow/Settings/GeneralSettingsView.swift index 0ac3acc69..073d9c72d 100644 --- a/LoopFollow/Settings/GeneralSettingsView.swift +++ b/LoopFollow/Settings/GeneralSettingsView.swift @@ -8,6 +8,8 @@ struct GeneralSettingsView: View { @ObservedObject var appBadge = Storage.shared.appBadge @ObservedObject var appearanceMode = Storage.shared.appearanceMode @ObservedObject var showStats = Storage.shared.showStats + @ObservedObject var showTIRBand = Storage.shared.showTIRBand + @ObservedObject var statsSinceMidnight = Storage.shared.statsSinceMidnight @ObservedObject var showSmallGraph = Storage.shared.showSmallGraph @ObservedObject var screenlockSwitchState = Storage.shared.screenlockSwitchState @ObservedObject var showDisplayName = Storage.shared.showDisplayName @@ -44,7 +46,6 @@ struct GeneralSettingsView: View { Text(mode.displayName).tag(mode) } } - Toggle("Display Stats", isOn: $showStats.value) Toggle("Display Small Graph", isOn: $showSmallGraph.value) Toggle("Color BG Text", isOn: $colorBGText.value) Toggle("Keep Screen Active", isOn: $screenlockSwitchState.value) @@ -61,6 +62,24 @@ struct GeneralSettingsView: View { } } + Section { + Toggle("Display Stats", isOn: $showStats.value) + if showStats.value { + Picker("Stats Style", selection: $showTIRBand.value) { + Text("Statistics Box").tag(false) + Text("Time in Range Band").tag(true) + } + Picker("Stats Period", selection: $statsSinceMidnight.value) { + Text("Last 24 Hours").tag(false) + Text("Today (since midnight)").tag(true) + } + } + } header: { + Text("Statistics") + } footer: { + Text("The statistics box and the Time in Range band both cover this period. Today starts at midnight in your time zone, or in the Time Zone Override below when it is on.") + } + Section("Time Zone") { Toggle("Time Zone Override", isOn: $graphTimeZoneEnabled.value) .onChange(of: graphTimeZoneEnabled.value) { _ in markChartSettingsDirty() } diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 93fd0fd93..1f1b28535 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -157,13 +157,15 @@ enum SettingsRoute: Hashable, Identifiable { SettingsLeaf("Display App Badge", ["badge"]), SettingsLeaf("Persistent Notification"), SettingsLeaf("Appearance", ["dark mode", "light mode", "theme"]), - SettingsLeaf("Display Stats"), SettingsLeaf("Display Small Graph"), SettingsLeaf("Color BG Text"), SettingsLeaf("Keep Screen Active", ["screen lock", "screenlock"]), SettingsLeaf("Show Display Name"), SettingsLeaf("Snoozer emoji"), SettingsLeaf("Force portrait mode", ["orientation", "landscape"]), + SettingsLeaf("Display Stats", ["statistics"]), + SettingsLeaf("Stats Style", ["time in range band", "statistics box", "tir band", "progress bar"]), + SettingsLeaf("Stats Period", ["statistics period", "last 24 hours", "since midnight", "today", "midnight"]), SettingsLeaf("Time Zone Override", ["timezone"]), SettingsLeaf("Speak BG", ["voice", "speech"]), SettingsLeaf("Send anonymous usage stats", ["telemetry", "diagnostics"]), diff --git a/LoopFollow/Stats/TIR/TIRCalculator.swift b/LoopFollow/Stats/TIR/TIRCalculator.swift index b387b5702..a27eef414 100644 --- a/LoopFollow/Stats/TIR/TIRCalculator.swift +++ b/LoopFollow/Stats/TIR/TIRCalculator.swift @@ -7,11 +7,11 @@ class TIRCalculator { static func calculate(bgData: [ShareGlucoseData]) -> [TIRDataPoint] { guard !bgData.isEmpty else { return [] } - let thresholds = UnitSettingsStore.shared.effectiveThresholds() - let veryLowThreshold = 54.0 + let thresholds = UnitSettingsStore.shared.effectiveBandThresholds() + let veryLowThreshold = thresholds.veryLow let lowThreshold = thresholds.low let highThreshold = thresholds.high - let veryHighThreshold = 250.0 + let veryHighThreshold = thresholds.veryHigh var periodData: [TIRPeriod: [Double]] = [:] let calendar = dateTimeUtils.displayCalendar() @@ -91,11 +91,11 @@ class TIRCalculator { return tirPoints } - private static func calculatePercentages(readings: [Double], - veryLowThreshold: Double, - lowThreshold: Double, - highThreshold: Double, - veryHighThreshold: Double) -> (veryLow: Double, low: Double, inRange: Double, high: Double, veryHigh: Double) + static func calculatePercentages(readings: [Double], + veryLowThreshold: Double, + lowThreshold: Double, + highThreshold: Double, + veryHighThreshold: Double) -> (veryLow: Double, low: Double, inRange: Double, high: Double, veryHigh: Double) { let total = Double(readings.count) guard total > 0 else { diff --git a/LoopFollow/Stats/TIR/TIRGraphView.swift b/LoopFollow/Stats/TIR/TIRGraphView.swift index 03a84a14b..1eba0f767 100644 --- a/LoopFollow/Stats/TIR/TIRGraphView.swift +++ b/LoopFollow/Stats/TIR/TIRGraphView.swift @@ -7,7 +7,7 @@ import SwiftUI struct TIRGraphView: View { let tirData: [TIRDataPoint] - private enum Band: String, CaseIterable, Plottable { + enum Band: String, CaseIterable, Plottable { case veryLow = "Very Low" case low = "Low" case inRange = "In Range" diff --git a/LoopFollow/Stats/TIR/TIRView.swift b/LoopFollow/Stats/TIR/TIRView.swift index 8ff66e273..c322cf59b 100644 --- a/LoopFollow/Stats/TIR/TIRView.swift +++ b/LoopFollow/Stats/TIR/TIRView.swift @@ -13,7 +13,7 @@ struct TIRView: View { ZStack(alignment: .topTrailing) { VStack(alignment: .leading, spacing: 12) { HStack { - Text(tirTitle) + Text(UnitSettingsStore.shared.timeInRangeTitle) .font(.caption) .foregroundColor(.secondary) Spacer() @@ -42,21 +42,25 @@ struct TIRView: View { label: "Very High (\(veryHighCutoffText))", percentage: average.veryHigh ) - TIRLegendItem( - color: .yellow, - label: "High (\(highCutoffText))", - percentage: average.high - ) + if hasHighBand { + TIRLegendItem( + color: .yellow, + label: "High (\(highCutoffText))", + percentage: average.high + ) + } TIRLegendItem( color: .green, label: "In Range (\(inRangeCutoffText))", percentage: average.inRange ) - TIRLegendItem( - color: .red.opacity(0.5), - label: "Low (\(lowCutoffText))", - percentage: average.low - ) + if hasLowBand { + TIRLegendItem( + color: .red.opacity(0.5), + label: "Low (\(lowCutoffText))", + percentage: average.low + ) + } TIRLegendItem( color: .red.opacity(0.8), label: "Very Low (\(veryLowCutoffText))", @@ -86,17 +90,6 @@ struct TIRView: View { .buttonStyle(PlainButtonStyle()) } - private var tirTitle: String { - switch UnitSettingsStore.shared.timeInRangeMode { - case .tir: - return "Time in Range" - case .titr: - return "Time in Tight Range" - case .custom: - return "Custom Range" - } - } - private func formatRange(_: Double) -> String { let thresholds = UnitSettingsStore.shared.effectiveThresholds() let glucoseUnit = UnitSettingsStore.shared.glucoseUnit @@ -108,19 +101,31 @@ struct TIRView: View { } private var lowThreshold: Double { - UnitSettingsStore.shared.convertMgdlToDisplay(UnitSettingsStore.shared.effectiveThresholds().low) + UnitSettingsStore.shared.convertMgdlToDisplay(UnitSettingsStore.shared.effectiveBandThresholds().low) } private var highThreshold: Double { - UnitSettingsStore.shared.convertMgdlToDisplay(UnitSettingsStore.shared.effectiveThresholds().high) + UnitSettingsStore.shared.convertMgdlToDisplay(UnitSettingsStore.shared.effectiveBandThresholds().high) } private var veryLowThreshold: Double { - UnitSettingsStore.shared.convertMgdlToDisplay(54.0) + UnitSettingsStore.shared.convertMgdlToDisplay(UnitSettingsStore.shared.effectiveBandThresholds().veryLow) } private var veryHighThreshold: Double { - UnitSettingsStore.shared.convertMgdlToDisplay(250.0) + UnitSettingsStore.shared.convertMgdlToDisplay(UnitSettingsStore.shared.effectiveBandThresholds().veryHigh) + } + + /// A custom range that reaches past the very low cutoff leaves no low band. + private var hasLowBand: Bool { + let thresholds = UnitSettingsStore.shared.effectiveBandThresholds() + return thresholds.veryLow < thresholds.low + } + + /// A custom range that reaches past the very high cutoff leaves no high band. + private var hasHighBand: Bool { + let thresholds = UnitSettingsStore.shared.effectiveBandThresholds() + return thresholds.high < thresholds.veryHigh } private var veryLowCutoffText: String { diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 586fc0d28..5417df992 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -78,6 +78,11 @@ class Storage { var colorBGText = StorageValue(key: "colorBGText", defaultValue: true) var appearanceMode = StorageValue(key: "appearanceMode", defaultValue: .dark) var showStats = StorageValue(key: "showStats", defaultValue: true) + /// Show the home screen stats as a Time in Range band instead of the statistics box. + var showTIRBand = StorageValue(key: "showTIRBand", defaultValue: false) + /// Period both home screen stats (statistics box and Time in Range band) cover: + /// the last 24 hours, or today since midnight. + var statsSinceMidnight = StorageValue(key: "statsSinceMidnight", defaultValue: false) var useIFCC = StorageValue(key: "useIFCC", defaultValue: false) var showSmallGraph = StorageValue(key: "showSmallGraph", defaultValue: true) var screenlockSwitchState = StorageValue(key: "screenlockSwitchState", defaultValue: true) diff --git a/LoopFollow/ViewControllers/MainHomeView.swift b/LoopFollow/ViewControllers/MainHomeView.swift index 23ee1e191..63732e65b 100644 --- a/LoopFollow/ViewControllers/MainHomeView.swift +++ b/LoopFollow/ViewControllers/MainHomeView.swift @@ -13,6 +13,7 @@ struct MainHomeView: View { @ObservedObject var showSmallGraph = Storage.shared.showSmallGraph @ObservedObject var showStats = Storage.shared.showStats + @ObservedObject var showTIRBand = Storage.shared.showTIRBand @ObservedObject var hideInfoTable = Storage.shared.hideInfoTable @ObservedObject var smallGraphHeight = Storage.shared.smallGraphHeight @ObservedObject var url = Storage.shared.url @@ -58,7 +59,11 @@ struct MainHomeView: View { // Statistics if showStats.value { - StatsDisplayView(model: statsModel, onTap: onStatsTap) + if showTIRBand.value { + TIRBandView(model: statsModel, onTap: onStatsTap) + } else { + StatsDisplayView(model: statsModel, onTap: onStatsTap) + } } } .padding(8) diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 40c8659be..b8989f28f 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -279,7 +279,12 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { Storage.shared.units.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.useIFCC.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.showGMI.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.showStdDev.$value.map { _ in () }.eraseToAnyPublisher() + Storage.shared.showStdDev.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.statsSinceMidnight.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.lowLine.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.highLine.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.graphTimeZoneEnabled.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.graphTimeZoneIdentifier.$value.map { _ in () }.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -293,6 +298,7 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { self?.updateBGGraphSettings() self?.updateBGGraph() LiveActivityManager.shared.refreshFromCurrentState(reason: "range mode changed") + self?.updateStats() } .store(in: &cancellables) From 570ec0617b2fe0f66fe51170aee00caba19e9782 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 26 Oct 2026 23:45:01 +0000 Subject: [PATCH 61/84] CI: Bump dev version to 7.1.23 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index b3afa7ce1..8e491c777 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.22 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.23 From 9de3e2f718cacdd58244f44d892f827cfccf0a72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Tue, 27 Oct 2026 19:40:52 +0100 Subject: [PATCH 62/84] Step stats day loops by calendar day so DST fall-back days end (#795) --- LoopFollow/Stats/SimpleStatsViewModel.swift | 7 ++++--- LoopFollow/Stats/StatsDataFetcher.swift | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/LoopFollow/Stats/SimpleStatsViewModel.swift b/LoopFollow/Stats/SimpleStatsViewModel.swift index 841f4ca2a..220816ba2 100644 --- a/LoopFollow/Stats/SimpleStatsViewModel.swift +++ b/LoopFollow/Stats/SimpleStatsViewModel.swift @@ -226,9 +226,10 @@ class SimpleStatsViewModel: ObservableObject { var currentTime = startTime while currentTime < endTime { - let currentDate = Date(timeIntervalSince1970: currentTime) - let dayStart = calendar.startOfDay(for: currentDate).timeIntervalSince1970 - let nextDayStart = dayStart + 24 * 60 * 60 + let dayStartDate = calendar.startOfDay(for: Date(timeIntervalSince1970: currentTime)) + guard let nextDayStartDate = calendar.date(byAdding: .day, value: 1, to: dayStartDate) else { break } + let dayStart = dayStartDate.timeIntervalSince1970 + let nextDayStart = nextDayStartDate.timeIntervalSince1970 // Process each profile segment for this day for i in 0 ..< sortedProfile.count { diff --git a/LoopFollow/Stats/StatsDataFetcher.swift b/LoopFollow/Stats/StatsDataFetcher.swift index 71f84b038..a65464d47 100644 --- a/LoopFollow/Stats/StatsDataFetcher.swift +++ b/LoopFollow/Stats/StatsDataFetcher.swift @@ -512,9 +512,10 @@ class StatsDataFetcher { var currentTime = startTime while currentTime < endTime { - let currentDate = Date(timeIntervalSince1970: currentTime) - let dayStart = calendar.startOfDay(for: currentDate).timeIntervalSince1970 - let nextDayStart = dayStart + 24 * 60 * 60 + let dayStartDate = calendar.startOfDay(for: Date(timeIntervalSince1970: currentTime)) + guard let nextDayStartDate = calendar.date(byAdding: .day, value: 1, to: dayStartDate) else { break } + let dayStart = dayStartDate.timeIntervalSince1970 + let nextDayStart = nextDayStartDate.timeIntervalSince1970 for i in 0 ..< sortedProfile.count { let segmentRate = sortedProfile[i].value From 0e4233fb3560b491345735f3d708b873204f481c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 27 Oct 2026 18:41:05 +0000 Subject: [PATCH 63/84] CI: Bump dev version to 7.1.24 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 8e491c777..b10f8f7b5 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.23 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.24 From c3345e09c92ea1d088a576d6a4d57994f08466c7 Mon Sep 17 00:00:00 2001 From: Daniel Mini Johansson <42831533+codebymini@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:13:00 +0100 Subject: [PATCH 64/84] Refactor/glucose range thresholds (#791) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Define glucose range cut-offs in one place and pick the range mode from a full-width list The 54/70/140/180/250 mg/dL cut-offs were repeated across the stats, GRI, the settings footer and the Live Activity. They now live in GlucoseThresholds in Shared, so the app, Live Activity and watch targets all see the same values. Each TimeInRangeDisplayMode carries its title and fixed thresholds, and the range settings, stats title and stats card toggle are built from allCases, so a new mode is its cut-offs plus one case. The segmented TIR/TITR/Custom picker and its footer are replaced by a row showing the selected mode's full name and cut-offs, which opens a list with one line per mode. Choosing Custom Range stays on the list and shows the Low and High pickers there. The onboarding statistics page gets a navigation stack so the list can open from it. * Add Extended Tight Range (TING, 63–140 mg/dL) as a range mode TING is the tight range with its lower cut-off moved down from 70 to 63 mg/dL, also known as Time in Normoglycemia. It is shown as Extended Tight Range, sits between Time in Tight Range and Custom Range in the range list and the stats card toggle, and its cut-offs live in GlucoseThresholds next to TIR and TITR. The settings search finds Range Mode for "ting" and "extended". --- LoopFollow/Charts/BGChartModel.swift | 4 +- .../MainViewController+updateStats.swift | 6 +- LoopFollow/Helpers/Localizer.swift | 62 +++++--- .../Onboarding/Steps/UnitsStepView.swift | 42 +++--- LoopFollow/Settings/SettingsMenuView.swift | 2 +- .../Settings/UnitsConfigurationView.swift | 141 +++++++++++++----- LoopFollow/Stats/GRI/GRICalculator.swift | 8 +- LoopFollow/Stats/TIR/TIRView.swift | 2 +- LoopFollow/Stats/TIR/TIRViewModel.swift | 10 +- .../LoopFollowLiveActivity.swift | 2 +- Shared/GlucoseThresholds.swift | 21 +++ 11 files changed, 206 insertions(+), 94 deletions(-) create mode 100644 Shared/GlucoseThresholds.swift diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index a7089d6dc..525fcfcb2 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -415,8 +415,8 @@ final class BGChartModel: ObservableObject { let maxBGValue = Double(vc.calculateMaxBgGraphValue()) maxBG = max(maxBGValue, Storage.shared.minBGScale.value) - // Same thresholds the stats and main header use: fixed 70–180 / 70–140 - // for the TIR/TITR range modes, the user's lines for custom mode. + // Same thresholds the stats and main header use: the range mode's fixed + // cut-offs, or the user's lines for custom mode. let thresholds = UnitSettingsStore.shared.effectiveThresholds() lowLine = thresholds.low highLine = thresholds.high diff --git a/LoopFollow/Controllers/MainViewController+updateStats.swift b/LoopFollow/Controllers/MainViewController+updateStats.swift index 1f10d25fb..b1a87710c 100644 --- a/LoopFollow/Controllers/MainViewController+updateStats.swift +++ b/LoopFollow/Controllers/MainViewController+updateStats.swift @@ -66,8 +66,8 @@ extension MainViewController { /// Range distribution for the Time in Range band over the same period as the /// statistics box (last 24 hours, or today since midnight). - /// Low, in range and high follow the Range Mode chosen in Settings (TIR, TITR - /// or Custom). Very low is below 54 mg/dL and very high above 250 mg/dL, or + /// Low, in range and high follow the Range Mode chosen in Settings (TIR, TITR, + /// TING or Custom). Very low is below 54 mg/dL and very high above 250 mg/dL, or /// the range's own bounds when a custom range extends past them. func updateTIRBand(periodStart: TimeInterval) { let thresholds = UnitSettingsStore.shared.effectiveBandThresholds() @@ -75,7 +75,7 @@ extension MainViewController { .filter { $0.date >= periodStart && $0.sgv > 0 } .map { Double($0.sgv) } - statsDisplayModel.bandTitle = UnitSettingsStore.shared.timeInRangeTitle + statsDisplayModel.bandTitle = UnitSettingsStore.shared.timeInRangeMode.title let percentages = TIRCalculator.calculatePercentages( readings: values, diff --git a/LoopFollow/Helpers/Localizer.swift b/LoopFollow/Helpers/Localizer.swift index e36c08153..3d32a51da 100644 --- a/LoopFollow/Helpers/Localizer.swift +++ b/LoopFollow/Helpers/Localizer.swift @@ -18,10 +18,41 @@ enum GlucoseDisplayUnit: String, Codable, CaseIterable { } } +/// The selectable range modes. To add one, define its low/high in `GlucoseThresholds`, +/// then add a case here with a title and `fixedThresholds` pointing at those constants; +/// the settings range list, stats card and toggle pick it up from `allCases`. +/// The raw value is persisted, so don't change existing ones. enum TimeInRangeDisplayMode: String, Codable, CaseIterable { case tir = "TIR" case titr = "TITR" + case ting = "TING" case custom = "Custom" + + var title: String { + switch self { + case .tir: return "Time in Range" + case .titr: return "Time in Tight Range" + case .ting: return "Extended Tight Range" + case .custom: return "Custom Range" + } + } + + /// Low/high cut-offs in mg/dL from `GlucoseThresholds`, or nil when the user's own low/high lines apply. + var fixedThresholds: (low: Double, high: Double)? { + switch self { + case .tir: return (GlucoseThresholds.tirLow, GlucoseThresholds.tirHigh) + case .titr: return (GlucoseThresholds.titrLow, GlucoseThresholds.titrHigh) + case .ting: return (GlucoseThresholds.tingLow, GlucoseThresholds.tingHigh) + case .custom: return nil + } + } + + /// The mode after this one in `allCases`, wrapping around. + var next: TimeInRangeDisplayMode { + let all = Self.allCases + let index = all.firstIndex(of: self)! + return all[(index + 1) % all.count] + } } enum GlycemicMetricMode: String, Codable, CaseIterable { @@ -64,33 +95,20 @@ final class UnitSettingsStore { /// Returns the effective low/high thresholds (in mg/dL) for the current range mode. func effectiveThresholds() -> (low: Double, high: Double) { - switch timeInRangeMode { - case .tir: - return (70.0, 180.0) - case .titr: - return (70.0, 140.0) - case .custom: - return (Storage.shared.lowLine.value, Storage.shared.highLine.value) - } + timeInRangeMode.fixedThresholds ?? (Storage.shared.lowLine.value, Storage.shared.highLine.value) } /// Returns the very low, low, high and very high thresholds (in mg/dL) for - /// the current range mode. Very low is 54 and very high 250, or the range's - /// own bounds when a custom range extends past them. + /// the current range mode. Very low and very high come from `GlucoseThresholds`, + /// or the range's own bounds when a custom range extends past them. func effectiveBandThresholds() -> (veryLow: Double, low: Double, high: Double, veryHigh: Double) { let range = effectiveThresholds() - return (min(54.0, range.low), range.low, range.high, max(250.0, range.high)) - } - - var timeInRangeTitle: String { - switch timeInRangeMode { - case .tir: - return "Time in Range" - case .titr: - return "Time in Tight Range" - case .custom: - return "Custom Range" - } + return ( + min(GlucoseThresholds.veryLow, range.low), + range.low, + range.high, + max(GlucoseThresholds.veryHigh, range.high) + ) } var glycemicMetricMode: GlycemicMetricMode { diff --git a/LoopFollow/Onboarding/Steps/UnitsStepView.swift b/LoopFollow/Onboarding/Steps/UnitsStepView.swift index 25138e611..00b4d333a 100644 --- a/LoopFollow/Onboarding/Steps/UnitsStepView.swift +++ b/LoopFollow/Onboarding/Steps/UnitsStepView.swift @@ -19,23 +19,10 @@ struct UnitsStepView: View { var body: some View { VStack(spacing: 0) { - Form { - switch page { - case .units: - header( - systemImage: "ruler", - title: "Units", - subtitle: "Choose how glucose values are displayed. You can change this later in Settings." - ) - UnitsConfigurationView(sections: .units) - case .statistics: - header( - systemImage: "chart.bar.xaxis", - title: "Statistics", - subtitle: "Choose how LoopFollow's statistics are measured and displayed." - ) - UnitsConfigurationView(sections: .statistics) - } + // Lets the range mode row push its list; the bar only shows on that list. + NavigationStack { + form + .toolbar(.hidden, for: .navigationBar) } footer @@ -44,6 +31,27 @@ struct UnitsStepView: View { .onChange(of: page) { _ in reportProgress() } } + private var form: some View { + Form { + switch page { + case .units: + header( + systemImage: "ruler", + title: "Units", + subtitle: "Choose how glucose values are displayed. You can change this later in Settings." + ) + UnitsConfigurationView(sections: .units) + case .statistics: + header( + systemImage: "chart.bar.xaxis", + title: "Statistics", + subtitle: "Choose how LoopFollow's statistics are measured and displayed." + ) + UnitsConfigurationView(sections: .statistics) + } + } + } + private func reportProgress() { onboarding.phaseProgress = .init(page: page.rawValue, count: Page.allCases.count) } diff --git a/LoopFollow/Settings/SettingsMenuView.swift b/LoopFollow/Settings/SettingsMenuView.swift index 1f1b28535..828d36058 100644 --- a/LoopFollow/Settings/SettingsMenuView.swift +++ b/LoopFollow/Settings/SettingsMenuView.swift @@ -194,7 +194,7 @@ enum SettingsRoute: Hashable, Identifiable { + InfoType.allCases.map { SettingsLeaf($0.name) } case .units: return [ SettingsLeaf("Glucose Unit"), - SettingsLeaf("Range Mode", ["tir", "titr", "time in range"]), + SettingsLeaf("Range Mode", ["tir", "titr", "ting", "time in range", "extended"]), SettingsLeaf("Glycemic Metrics", ["hba1c", "ehba1c", "gmi"]), SettingsLeaf("Variability", ["standard deviation", "cv"]), ] diff --git a/LoopFollow/Settings/UnitsConfigurationView.swift b/LoopFollow/Settings/UnitsConfigurationView.swift index 2124077c7..5eb4cf6f8 100644 --- a/LoopFollow/Settings/UnitsConfigurationView.swift +++ b/LoopFollow/Settings/UnitsConfigurationView.swift @@ -30,13 +30,19 @@ struct UnitsConfigurationView: View { @State private var highValue = Storage.shared.highLine.value /// Formats a mg/dL threshold pair in the currently selected glucose unit, - /// e.g. "70–180 mg/dL" or "3.9–10.0 mmol/L". + /// e.g. "70–180" or "3.9–10.0". private func rangeBounds(_ lowMgdl: Double, _ highMgdl: Double) -> String { let factor = glucoseUnit == .mmolL ? GlucoseConversion.mgDlToMmolL : 1.0 let digits = glucoseUnit.fractionDigits let low = Localizer.formatToLocalizedString(lowMgdl * factor, maxFractionDigits: digits, minFractionDigits: digits) let high = Localizer.formatToLocalizedString(highMgdl * factor, maxFractionDigits: digits, minFractionDigits: digits) - return "\(low)–\(high) \(glucoseUnit.rawValue)" + return "\(low)–\(high)" + } + + /// The mode's range in the selected unit; custom uses the user's own low and high. + private func rangeBounds(for mode: TimeInRangeDisplayMode) -> String { + let thresholds = mode.fixedThresholds ?? (lowValue, highValue) + return rangeBounds(thresholds.low, thresholds.high) } var body: some View { @@ -68,43 +74,22 @@ struct UnitsConfigurationView: View { private var statisticsSections: some View { Group { Section { - Picker("Range Mode", selection: $rangeMode) { - Text("TIR").tag(TimeInRangeDisplayMode.tir) - Text("TITR").tag(TimeInRangeDisplayMode.titr) - Text("Custom").tag(TimeInRangeDisplayMode.custom) - } - .pickerStyle(.segmented) - .onChange(of: rangeMode) { newValue in - UnitSettingsStore.shared.timeInRangeMode = newValue - Observable.shared.chartSettingsChanged.value = true - } - - if rangeMode == .custom { - BGPicker( - title: "Low", - range: 40 ... 120, - value: $lowValue + NavigationLink { + RangeModeList( + selection: $rangeMode, + lowValue: $lowValue, + highValue: $highValue, + glucoseUnit: glucoseUnit, + bounds: rangeBounds(for:) ) - .id(glucoseUnit) - .onChange(of: lowValue) { newValue in - Storage.shared.lowLine.value = newValue - Observable.shared.chartSettingsChanged.value = true - } - BGPicker( - title: "High", - range: 120 ... 400, - value: $highValue - ) - .id(glucoseUnit) - .onChange(of: highValue) { newValue in - Storage.shared.highLine.value = newValue - Observable.shared.chartSettingsChanged.value = true - } + } label: { + RangeModeRow(mode: rangeMode, bounds: rangeBounds(for: rangeMode)) } + .accessibilityLabel("Range Mode, \(rangeMode.title)") } header: { Text("Range") } footer: { - Text("TIR β€” Time in Range, the share of readings within \(rangeBounds(70, 180)).\nTITR β€” Time in Tight Range, within \(rangeBounds(70, 140)).\nCustom β€” set your own low and high.") + Text("Cut-offs in \(glucoseUnit.rawValue).") } Section { @@ -149,6 +134,94 @@ struct UnitsConfigurationView: View { } } +/// A range mode's name with its cut-offs trailing, on one line. +private struct RangeModeRow: View { + let mode: TimeInRangeDisplayMode + let bounds: String + + var body: some View { + HStack { + Text(mode.title) + .foregroundStyle(Color.primary) + Spacer() + // Color.secondary, not .secondary, which inside a Button is a shade of the tint. + Text(bounds) + .foregroundStyle(Color.secondary) + .monospacedDigit() + } + .lineLimit(1) + } +} + +/// Full-width list of range modes, so names and cut-offs fit on one line. +/// Picking a fixed mode goes back; picking custom stays to set its low and high. +private struct RangeModeList: View { + @Binding var selection: TimeInRangeDisplayMode + @Binding var lowValue: Double + @Binding var highValue: Double + let glucoseUnit: GlucoseDisplayUnit + let bounds: (TimeInRangeDisplayMode) -> String + + @Environment(\.dismiss) private var dismiss + + var body: some View { + Form { + Section { + ForEach(TimeInRangeDisplayMode.allCases, id: \.self) { mode in + Button { + selection = mode + if mode != .custom { + dismiss() + } + } label: { + HStack { + RangeModeRow(mode: mode, bounds: bounds(mode)) + // Always laid out so the cut-offs line up whichever row is selected. + Image(systemName: "checkmark") + .foregroundStyle(.tint) + .opacity(selection == mode ? 1 : 0) + } + } + .accessibilityAddTraits(selection == mode ? .isSelected : []) + } + } footer: { + Text("Cut-offs in \(glucoseUnit.rawValue).") + } + + if selection == .custom { + Section("Custom Range") { + BGPicker( + title: "Low", + range: 40 ... 120, + value: $lowValue + ) + .id(glucoseUnit) + BGPicker( + title: "High", + range: 120 ... 400, + value: $highValue + ) + .id(glucoseUnit) + } + } + } + .onChange(of: selection) { newValue in + UnitSettingsStore.shared.timeInRangeMode = newValue + Observable.shared.chartSettingsChanged.value = true + } + .onChange(of: lowValue) { newValue in + Storage.shared.lowLine.value = newValue + Observable.shared.chartSettingsChanged.value = true + } + .onChange(of: highValue) { newValue in + Storage.shared.highLine.value = newValue + Observable.shared.chartSettingsChanged.value = true + } + .navigationTitle("Range") + .navigationBarTitleDisplayMode(.inline) + } +} + /// Standalone page for units configuration during onboarding. /// Shows a checkmark button in the toolbar to complete setup. struct UnitsOnboardingView: View { diff --git a/LoopFollow/Stats/GRI/GRICalculator.swift b/LoopFollow/Stats/GRI/GRICalculator.swift index 1d84e17cc..fd170f55b 100644 --- a/LoopFollow/Stats/GRI/GRICalculator.swift +++ b/LoopFollow/Stats/GRI/GRICalculator.swift @@ -15,10 +15,10 @@ class GRICalculator { static func calculate(bgData: [ShareGlucoseData]) -> GRICalculationResult { guard !bgData.isEmpty else { return GRICalculationResult(gri: 0.0, hypoComponent: 0.0, hyperComponent: 0.0) } - let vLowThreshold = 54.0 - let lowThreshold = 70.0 - let highThreshold = 180.0 - let vHighThreshold = 250.0 + let vLowThreshold = GlucoseThresholds.veryLow + let lowThreshold = GlucoseThresholds.tirLow + let highThreshold = GlucoseThresholds.tirHigh + let vHighThreshold = GlucoseThresholds.veryHigh var vLowCount = 0 var lowCount = 0 diff --git a/LoopFollow/Stats/TIR/TIRView.swift b/LoopFollow/Stats/TIR/TIRView.swift index c322cf59b..a31aa5d5e 100644 --- a/LoopFollow/Stats/TIR/TIRView.swift +++ b/LoopFollow/Stats/TIR/TIRView.swift @@ -13,7 +13,7 @@ struct TIRView: View { ZStack(alignment: .topTrailing) { VStack(alignment: .leading, spacing: 12) { HStack { - Text(UnitSettingsStore.shared.timeInRangeTitle) + Text(UnitSettingsStore.shared.timeInRangeMode.title) .font(.caption) .foregroundColor(.secondary) Spacer() diff --git a/LoopFollow/Stats/TIR/TIRViewModel.swift b/LoopFollow/Stats/TIR/TIRViewModel.swift index 6f04bddec..c3b932b28 100644 --- a/LoopFollow/Stats/TIR/TIRViewModel.swift +++ b/LoopFollow/Stats/TIR/TIRViewModel.swift @@ -20,15 +20,7 @@ class TIRViewModel: ObservableObject { } func toggleTIRMode() { - let mode = UnitSettingsStore.shared.timeInRangeMode - switch mode { - case .tir: - UnitSettingsStore.shared.timeInRangeMode = .titr - case .titr: - UnitSettingsStore.shared.timeInRangeMode = .custom - case .custom: - UnitSettingsStore.shared.timeInRangeMode = .tir - } + UnitSettingsStore.shared.timeInRangeMode = UnitSettingsStore.shared.timeInRangeMode.next calculateTIR() } diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 9f6ca7c94..a9018ca94 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -816,7 +816,7 @@ private enum LAColors { let high = t.high if mgdl <= low { - let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / max(low - 54.0, 1)) + let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / max(low - GlucoseThresholds.veryLow, 1)) let opacity = min(max(raw, 0.48), 0.85) return Color(uiColor: UIColor.systemRed).opacity(opacity) } else if mgdl >= high { diff --git a/Shared/GlucoseThresholds.swift b/Shared/GlucoseThresholds.swift new file mode 100644 index 000000000..d4e5d6bc4 --- /dev/null +++ b/Shared/GlucoseThresholds.swift @@ -0,0 +1,21 @@ +// LoopFollow +// GlucoseThresholds.swift + +import Foundation + +/// Fixed glucose cut-offs (mg/dL) shared by the stats, range modes and Live Activity. +/// Range modes read their low/high from here via `TimeInRangeDisplayMode.fixedThresholds`, +/// so a new mode's cut-offs go here first. +enum GlucoseThresholds { + static let veryLow = 54.0 + static let veryHigh = 250.0 + /// Time in Range, also used by GRI regardless of the selected range mode. + static let tirLow = 70.0 + static let tirHigh = 180.0 + /// Time in Tight Range. + static let titrLow = 70.0 + static let titrHigh = 140.0 + /// Extended Tight Range (TING, Time in Normoglycemia). + static let tingLow = 63.0 + static let tingHigh = 140.0 +} From e9d512c09391479c4185ad9017081877edc3c230 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 27 Oct 2026 19:15:01 +0000 Subject: [PATCH 65/84] CI: Bump dev version to 7.1.25 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index b10f8f7b5..24123dbbe 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.24 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.25 From 836fe8f1da7f5cb6fb0719c5f051deb886605ef9 Mon Sep 17 00:00:00 2001 From: Marion Barker <19607791+marionbarker@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:13:15 -0700 Subject: [PATCH 66/84] Fix crashes from forced casts on explicit-null Nightscout fields; copied from PR #794 (#797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Modifications from PR #794 * Treat explicit-null failureReason and pump clock as missing, drop force cast in test helper --------- Co-authored-by: Jonas BjΓΆrkert --- .../Controllers/Nightscout/DeviceStatus.swift | 8 ++--- .../Nightscout/DeviceStatusLoop.swift | 2 +- .../Nightscout/DeviceStatusOpenAPS.swift | 2 +- Tests/DeviceStatusHistoryTests.swift | 31 +++++++++++++------ 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index a4741037b..87c44f676 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -92,7 +92,7 @@ extension MainViewController { Observable.shared.previousAlertLastLoopTime.value = Observable.shared.alertLastLoopTime.value - if let lastPumpRecord = lastDeviceStatus?["pump"] as! [String: AnyObject]? { + if let lastPumpRecord = lastDeviceStatus?["pump"] as? [String: AnyObject] { if let bolusIncrement = lastPumpRecord["bolusIncrement"] as? Double, bolusIncrement > 0 { Storage.shared.bolusIncrement.value = HKQuantity(unit: .internationalUnit(), doubleValue: bolusIncrement) Storage.shared.bolusIncrementDetected.value = true @@ -182,10 +182,10 @@ extension MainViewController { } // Loop - handle new data - if let lastLoopRecord = lastDeviceStatus?["loop"] as! [String: AnyObject]? { + if let lastLoopRecord = lastDeviceStatus?["loop"] as? [String: AnyObject] { // Some pumps report no `pump.clock`; without it alertLastLoopTime stays 0 // and the forecast anchors to epoch 0. Fall back to the loop cycle timestamp. - if (lastDeviceStatus?["pump"] as? [String: AnyObject])?["clock"] == nil, + if (lastDeviceStatus?["pump"] as? [String: AnyObject])?["clock"] as? String == nil, let loopTimestampString = lastLoopRecord["timestamp"] as? String, let loopTimestamp = formatter.date(from: loopTimestampString)?.timeIntervalSince1970, loopTimestamp > (Observable.shared.alertLastLoopTime.value ?? 0) @@ -223,7 +223,7 @@ extension MainViewController { } // OpenAPS - handle new data - if let lastLoopRecord = lastDeviceStatus?["openaps"] as! [String: AnyObject]? { + if let lastLoopRecord = lastDeviceStatus?["openaps"] as? [String: AnyObject] { DeviceStatusOpenAPS(formatter: formatter, lastDeviceStatus: lastDeviceStatus, lastLoopRecord: lastLoopRecord) } diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index 8414b2457..1727c0729 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -32,7 +32,7 @@ extension MainViewController { Storage.shared.lastIOB.value = latestIOB?.value Storage.shared.lastCOB.value = latestCOB?.value - if lastLoopRecord["failureReason"] != nil { + if let failureReason = lastLoopRecord["failureReason"], !(failureReason is NSNull) { Observable.shared.loopStatusText.value = "X" latestLoopStatusString = "X" } else { diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index e9cb08ba5..ceaef95c5 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -7,7 +7,7 @@ import HealthKit extension MainViewController { func DeviceStatusOpenAPS(formatter: ISO8601DateFormatter, lastDeviceStatus: [String: AnyObject]?, lastLoopRecord: [String: AnyObject]) { Storage.shared.device.value = lastDeviceStatus?["device"] as? String ?? "" - if lastLoopRecord["failureReason"] != nil { + if let failureReason = lastLoopRecord["failureReason"], !(failureReason is NSNull) { Observable.shared.loopStatusText.value = "X" latestLoopStatusString = "X" } else { diff --git a/Tests/DeviceStatusHistoryTests.swift b/Tests/DeviceStatusHistoryTests.swift index b2b9f87f2..39d3f1770 100644 --- a/Tests/DeviceStatusHistoryTests.swift +++ b/Tests/DeviceStatusHistoryTests.swift @@ -6,13 +6,13 @@ import Foundation import Testing struct DeviceStatusHistoryParserTests { - private func json(_ text: String) -> [String: Any] { - try! JSONSerialization.jsonObject(with: Data(text.utf8)) as! [String: Any] + private func json(_ text: String) throws -> [String: Any] { + try #require(JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any]) } @Test("Loop record yields IOB, COB and the forecast from its start date") func loopRecord() throws { - let record = json(""" + let record = try json(""" {"created_at": "2026-09-27T10:00:30.000Z", "loop": {"timestamp": "2026-09-27T10:00:05Z", "iob": {"iob": 1.25, "timestamp": "2026-09-27T10:00:00Z"}, @@ -32,7 +32,7 @@ struct DeviceStatusHistoryParserTests { @Test("Trio record yields smoothed BG, ISF, ratio and the four forecast curves") func trioRecord() throws { - let record = json(""" + let record = try json(""" {"created_at": "2026-09-27T10:00:30.000Z", "device": "Trio", "pump": {"reservoir": 120.5, "battery": {"percent": 80}}, "uploader": {"battery": 55}, "openaps": {"iob": {"iob": 2.5, "time": "2026-09-27T10:00:00Z"}, @@ -61,7 +61,7 @@ struct DeviceStatusHistoryParserTests { @Test("Loop's active override is kept") func loopOverride() throws { - let record = json(""" + let record = try json(""" {"created_at": "2026-09-27T10:00:30Z", "override": {"active": true, "multiplier": 1.5, "currentCorrectionRange": {"minValue": 90, "maxValue": 100}}, "loop": {"timestamp": "2026-09-27T10:00:05Z", "recommendedBolus": 0.4}} @@ -73,7 +73,7 @@ struct DeviceStatusHistoryParserTests { @Test("A zero ISF is a placeholder, COB falls back to the reason string") func openAPSFallbacks() throws { - let record = json(""" + let record = try json(""" {"created_at": "2026-09-27T10:00:30Z", "openaps": {"iob": [{"iob": 0.8}, {"iob": 0.6}], "enacted": {"timestamp": "2026-09-27T10:00:00Z", "ISF": 0, @@ -87,7 +87,7 @@ struct DeviceStatusHistoryParserTests { @Test("Out-of-range forecast values are clamped") func hugeForecastValue() throws { - let record = json(""" + let record = try json(""" {"created_at": "2026-09-27T10:00:30Z", "loop": {"timestamp": "2026-09-27T10:00:05Z", "predicted": {"values": [1e30, -5, 120]}}} """) @@ -96,13 +96,26 @@ struct DeviceStatusHistoryParserTests { } @Test("Uploader-only records carry nothing to graph") - func uploaderOnly() { - let record = json(""" + func uploaderOnly() throws { + let record = try json(""" {"created_at": "2026-09-27T10:00:30Z", "uploader": {"battery": 80}} """) #expect(DeviceStatusHistoryParser.parse(record) == nil) } + @Test("Explicit nulls are treated as missing fields") + func explicitNulls() throws { + let record = try json(""" + {"created_at": "2026-09-27T10:00:30Z", "loop": null, "override": null, "pump": {"clock": null}, + "openaps": {"iob": [{"iob": 0.8}], "suggested": null, + "enacted": {"timestamp": "2026-09-27T10:00:00Z", "COB": 12, "ISF": null}}} + """) + let (sample, _) = try #require(DeviceStatusHistoryParser.parse(record)) + #expect(sample.iob == 0.8) + #expect(sample.cob == 12) + #expect(sample.isf == nil) + } + @Test("Offsets are honored") func offsetDates() { let utc = DeviceStatusHistoryParser.parseDate("2026-09-27T10:00:00Z") From aa80c45598eb24dd658de71c6e4f61705727dc5b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 28 Oct 2026 19:13:56 +0000 Subject: [PATCH 67/84] CI: Bump dev version to 7.1.26 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 24123dbbe..b8ccce7dc 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.25 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.26 From 3c6117c5d81dd0ccb0b72cb1681136d122c6237b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 30 Oct 2026 13:34:27 +0100 Subject: [PATCH 68/84] Switch Loop profiles from the Loop remote screen (#790) * Switch Loop profiles from the Loop remote screen Adds a Profiles button that lists the profiles Loop uploads to Nightscout, previews one the way Loop does and sends an OTP-protected profile switch with a fingerprint of the previewed values. Loop's return push is matched by profile name. The button is greyed out with an explanation when Loop does not list profile-switch in remoteCommands. * Point the unavailable Profiles button at the profiles customization * Say the Profiles button needs the profiles customization * Require device authentication before sending a profile switch --- .../Nightscout/ProfileManager.swift | 2 + .../Remote/LoopAPNS/LoopAPNSRemoteView.swift | 11 + .../Remote/LoopAPNS/LoopAPNSService.swift | 8 +- LoopFollow/Remote/LoopAPNS/LoopProfile.swift | 108 +++++++ .../Remote/LoopAPNS/LoopProfilesView.swift | 272 ++++++++++++++++++ LoopFollow/Remote/RemoteCommandTracker.swift | 60 +++- LoopFollow/Storage/Observable.swift | 3 + .../ViewControllers/MainViewController.swift | 9 +- RemoteShared/LoopAPNSCommands.swift | 8 + Tests/LoopAPNSCommandPayloadTests.swift | 9 + Tests/LoopProfileTests.swift | 46 +++ Tests/RemoteCommandTrackerTests.swift | 23 ++ 12 files changed, 550 insertions(+), 9 deletions(-) create mode 100644 LoopFollow/Remote/LoopAPNS/LoopProfile.swift create mode 100644 LoopFollow/Remote/LoopAPNS/LoopProfilesView.swift create mode 100644 Tests/LoopProfileTests.swift diff --git a/LoopFollow/Controllers/Nightscout/ProfileManager.swift b/LoopFollow/Controllers/Nightscout/ProfileManager.swift index 13134f8b6..57010f961 100644 --- a/LoopFollow/Controllers/Nightscout/ProfileManager.swift +++ b/LoopFollow/Controllers/Nightscout/ProfileManager.swift @@ -123,6 +123,7 @@ final class ProfileManager { Storage.shared.teamId.value = profileData.teamID ?? Storage.shared.teamId.value ?? "" Storage.shared.remoteCommands.value = profileData.remoteCommands ?? [] + Observable.shared.loopProfiles.set(LoopProfileSet(profileData)) } func currentISF() -> HKQuantity? { @@ -227,5 +228,6 @@ final class ProfileManager { units = .millimolesPerLiter timezone = TimeZone.current defaultProfile = "" + Observable.shared.loopProfiles.set(nil) } } diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift index 73de2aae4..aedeb8e3e 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSRemoteView.swift @@ -7,6 +7,7 @@ struct LoopAPNSRemoteView: View { @Environment(\.presentationMode) var presentationMode @StateObject private var viewModel = RemoteSettingsViewModel() @ObservedObject private var activeOverrideNote = Observable.shared.override + @ObservedObject private var loopRemoteCommands = Storage.shared.loopRemoteCommands var body: some View { NavigationView { @@ -22,6 +23,16 @@ struct LoopAPNSRemoteView: View { CommandButtonView(command: "Meal", iconName: "fork.knife", destination: LoopAPNSCarbsView()) CommandButtonView(command: "Bolus", iconName: "syringe", destination: LoopAPNSBolusView()) CommandButtonView(command: "Overrides", iconName: "slider.horizontal.3", destination: OverridePresetsView(), isActive: activeOverrideNote.value != nil) + if loopRemoteCommands.value.contains(LoopProfile.switchCommand) { + CommandButtonView(command: "Profiles", iconName: "arrow.triangle.2.circlepath", destination: LoopProfilesView()) + } else { + UnavailableCommandButtonView( + command: "Profiles", + iconName: "arrow.triangle.2.circlepath", + title: "Profiles Not Available", + message: "Switching Loop profiles from LoopFollow needs Loop built with the Profile Save & Load customization, added with the Loop and Learn Customization Select script." + ) + } } .padding(.horizontal) } else { diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index 0bd67521a..2938affba 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -92,7 +92,7 @@ class LoopAPNSService { return hasFullSetup } - private static let returnNotificationRequiredMessage = "Editing or deleting a carb entry needs LoopFollow's own APNS credentials so Loop can confirm the result. Configure them in App Settings β†’ APN." + private static let returnNotificationRequiredMessage = "This command needs LoopFollow's own APNS credentials so Loop can confirm the result. Configure them in App Settings β†’ APN." /// The return-notification block encrypted with the OTP, when LoopFollow's own credentials are configured. private func encryptedReturnNotification(otp: String) -> String? { @@ -161,6 +161,12 @@ class LoopAPNSService { sendConfirmedCommand(payload, otp: otp, completion: completion) } + /// Switches Loop to the saved profile `name`. Requires the Loop remote profile patch. + func sendProfileSwitch(name: String, fingerprint: String, otp: String, completion: @escaping (Bool, String?) -> Void) { + LogManager.shared.log(category: .apns, message: "Sending profile switch to \(name) fingerprint=\(fingerprint)") + sendConfirmedCommand(LoopAPNSCommands.profileSwitchCommandPayload(name: name, fingerprint: fingerprint, otp: otp), otp: otp, completion: completion) + } + /// Sends carbs via APNS push notification /// - Parameters: /// - payload: The carbs payload to send diff --git a/LoopFollow/Remote/LoopAPNS/LoopProfile.swift b/LoopFollow/Remote/LoopAPNS/LoopProfile.swift new file mode 100644 index 000000000..99b9c7a5b --- /dev/null +++ b/LoopFollow/Remote/LoopAPNS/LoopProfile.swift @@ -0,0 +1,108 @@ +// LoopFollow +// LoopProfile.swift + +import CryptoKit +import Foundation +import HealthKit + +/// A saved Loop profile from the Nightscout profile store, offered for a remote switch. +struct LoopProfile: Identifiable, Equatable { + struct Item: Equatable { + let seconds: Int + let value: Double + } + + struct RangeItem: Equatable { + let seconds: Int + let low: Double + let high: Double + } + + /// Loop lists this in `remoteCommands` when it accepts remote profile switches. + static let switchCommand = "profile-switch" + + let name: String + let units: HKUnit + let basal: [Item] + let carbRatio: [Item] + let sensitivity: [Item] + let correctionRange: [RangeItem] + /// Sent with the switch command. Loop only switches when its saved profile has the same fingerprint. + let fingerprint: String + + var id: String { name } + + init(name: String, store: NSProfile.Store) { + func items(_ entries: [(Double, Double)]) -> [Item] { + entries.map { Item(seconds: Int($0.0), value: $0.1) } + } + let basal = items(store.basal.map { ($0.timeAsSeconds, $0.value) }) + let carbRatio = items(store.carbratio.map { ($0.timeAsSeconds, $0.value) }) + let sensitivity = items(store.sens.map { ($0.timeAsSeconds, $0.value) }) + let targetLow = items((store.target_low ?? []).map { ($0.timeAsSeconds, $0.value) }) + let targetHigh = items((store.target_high ?? []).map { ($0.timeAsSeconds, $0.value) }) + + self.name = name + units = store.units.lowercased() == "mg/dl" ? .milligramsPerDeciliter : .millimolesPerLiter + self.basal = basal + self.carbRatio = carbRatio + self.sensitivity = sensitivity + correctionRange = zip(targetLow, targetHigh).map { RangeItem(seconds: $0.seconds, low: $0.value, high: $1.value) } + fingerprint = Self.fingerprint(basal: basal, carbRatio: carbRatio, sensitivity: sensitivity, targetLow: targetLow, targetHigh: targetHigh) + } + + /// Scheduled basal over a full day, in units. + var basalTotal: Double { + basal.enumerated().reduce(0) { total, entry in + let end = entry.offset + 1 < basal.count ? basal[entry.offset + 1].seconds : 24 * 3600 + return total + entry.element.value * Double(end - entry.element.seconds) / 3600 + } + } + + /// Saved profiles in a Loop profile document, by name. `Default` mirrors the active settings and is left out. + static func profiles(from profile: NSProfile) -> [LoopProfile] { + profile.store + .filter { $0.key != "Default" } + .map { LoopProfile(name: $0.key, store: $0.value) } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + + /// Matches Loop's fingerprint of a profile store entry: + /// `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` with values to three + /// decimals, hashed with SHA-256, first 16 hex characters. + static func fingerprint(basal: [Item], carbRatio: [Item], sensitivity: [Item], targetLow: [Item], targetHigh: [Item]) -> String { + func encode(_ items: [Item]) -> String { + items.map { "\($0.seconds):" + String(format: "%.3f", $0.value) }.joined(separator: ",") + } + let canonical = [ + "basal=" + encode(basal), + "carbratio=" + encode(carbRatio), + "sens=" + encode(sensitivity), + "target_low=" + encode(targetLow), + "target_high=" + encode(targetHigh), + ].joined(separator: ";") + let digest = SHA256.hash(data: Data(canonical.utf8)) + return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) + } +} + +/// The saved profiles from the latest Loop profile upload. +struct LoopProfileSet: Equatable { + let profiles: [LoopProfile] + let activeName: String + let uploadedAt: Date? + + init(profiles: [LoopProfile], activeName: String, uploadedAt: Date?) { + self.profiles = profiles + self.activeName = activeName + self.uploadedAt = uploadedAt + } + + init(_ profile: NSProfile) { + self.init( + profiles: LoopProfile.profiles(from: profile), + activeName: profile.defaultProfile, + uploadedAt: profile.startDate.flatMap { NightscoutUtils.parseDate($0) } + ) + } +} diff --git a/LoopFollow/Remote/LoopAPNS/LoopProfilesView.swift b/LoopFollow/Remote/LoopAPNS/LoopProfilesView.swift new file mode 100644 index 000000000..05733b530 --- /dev/null +++ b/LoopFollow/Remote/LoopAPNS/LoopProfilesView.swift @@ -0,0 +1,272 @@ +// LoopFollow +// LoopProfilesView.swift + +import HealthKit +import SwiftUI + +/// Lists the profiles saved in Loop. Selecting one shows its values before it can be activated. +struct LoopProfilesView: View { + @ObservedObject private var loopProfiles = Observable.shared.loopProfiles + + var body: some View { + List { + if let profileSet = loopProfiles.value, !profileSet.profiles.isEmpty { + Section( + header: Text("Saved profiles"), + footer: uploadedFooter(profileSet.uploadedAt) + ) { + ForEach(profileSet.profiles) { profile in + NavigationLink(destination: LoopProfilePreviewView(profile: profile)) { + HStack { + Text(profile.name) + Spacer() + if profile.name == profileSet.activeName { + Text("Active") + .font(.caption) + .foregroundColor(.green) + } + } + } + } + } + } else { + Text("No saved profiles found. Profiles are saved in Loop under Settings β†’ Profiles.") + .foregroundColor(.secondary) + .italic() + } + } + .navigationBarTitle("Remote Profiles", displayMode: .inline) + } + + private func uploadedFooter(_ date: Date?) -> Text { + guard let date else { return Text("") } + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return Text("Uploaded by Loop \(formatter.string(from: date)).") + } +} + +/// Shows the values a profile switch would activate, the same sections Loop shows before loading a profile. +struct LoopProfilePreviewView: View { + let profile: LoopProfile + + @Environment(\.presentationMode) private var presentationMode + @ObservedObject private var loopProfiles = Observable.shared.loopProfiles + @ObservedObject private var tracker = RemoteCommandTracker.shared + @State private var alertType: AlertType? + + private enum AlertType: Identifiable { + case confirm + case result(success: Bool, message: String) + + var id: String { + switch self { + case .confirm: return "confirm" + case let .result(success, message): return "result-\(success)-\(message)" + } + } + } + + private var commandKey: String { + LoopProfileAckAdapter.key(for: profile.name) + } + + private var isActive: Bool { + loopProfiles.value?.activeName == profile.name + } + + private var isSending: Bool { + tracker.isBusy(key: commandKey) + } + + /// Only one profile switch is in flight at a time. + private var otherSwitchPending: Bool { + !isSending && tracker.isBusy(keyPrefix: LoopProfileAckAdapter.keyPrefix) + } + + private var commandState: RemoteCommandTracker.State? { + tracker.states[commandKey] + } + + var body: some View { + List { + Section(footer: Text("Loop writes the basal rates to the pump when the profile is activated.")) { + Text("Review the profile before activating it in Loop.") + } + + Section(header: Text("Correction Range")) { + ForEach(profile.correctionRange, id: \.seconds) { item in + LabeledValueRow( + label: Self.timeText(item.seconds), + value: "\(glucoseText(item.low)) – \(glucoseText(item.high)) \(glucoseUnitText)" + ) + } + } + + Section(header: Text("Carb Ratios")) { + ForEach(profile.carbRatio, id: \.seconds) { item in + LabeledValueRow( + label: Self.timeText(item.seconds), + value: "\(Localizer.formatToLocalizedString(item.value, maxFractionDigits: 1)) g/U" + ) + } + } + + Section(header: Text("Basal Rates")) { + ForEach(profile.basal, id: \.seconds) { item in + LabeledValueRow( + label: Self.timeText(item.seconds), + value: "\(Localizer.formatToLocalizedString(item.value, maxFractionDigits: 3)) U/hr" + ) + } + HStack { + Text("Total").bold() + Spacer() + Text("\(Localizer.formatToLocalizedString(profile.basalTotal, maxFractionDigits: 2, minFractionDigits: 2)) U/day") + .foregroundColor(.secondary) + } + } + + Section(header: Text("Insulin Sensitivities")) { + ForEach(profile.sensitivity, id: \.seconds) { item in + LabeledValueRow( + label: Self.timeText(item.seconds), + value: "\(glucoseText(item.value)) \(glucoseUnitText)/U" + ) + } + } + } + .safeAreaInset(edge: .bottom) { + Button { + alertType = .confirm + } label: { + if isSending { + HStack { + ProgressView().scaleEffect(0.8) + Text("Waiting for Loop...") + } + .frame(maxWidth: .infinity) + } else if otherSwitchPending { + Text("Another Switch Is Pending").frame(maxWidth: .infinity) + } else { + Text(isActive ? "Active Profile" : "Activate Profile").frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(isActive || isSending || otherSwitchPending) + .padding(.horizontal) + .padding(.vertical, 8) + .background(.bar) + } + .navigationBarTitle(profile.name, displayMode: .inline) + .onAppear { showResult(commandState) } + .onChange(of: commandState) { _, state in showResult(state) } + .alert(item: $alertType) { type in + switch type { + case .confirm: + return Alert( + title: Text("Activate \(profile.name) in Loop?"), + message: Text("Loop replaces its correction range, carb ratios, basal rates and insulin sensitivities with these values and writes the basal rates to the pump."), + primaryButton: .default(Text("Activate"), action: authenticateAndSend), + secondaryButton: .cancel() + ) + case let .result(success, message): + return Alert( + title: Text(success ? "Profile Activated" : message == LoopProfileAckAdapter.timeoutMessage ? "No Reply From Loop" : "Profile Not Activated"), + message: Text(message), + dismissButton: .default(Text("OK")) { + if success { presentationMode.wrappedValue.dismiss() } + } + ) + } + } + } + + /// Shows a finished command once, also when the view was left while it was pending. + private func showResult(_ state: RemoteCommandTracker.State?) { + guard case let .done(success, message) = state else { return } + tracker.consume(key: commandKey) + presentResult(success: success, message: message) + } + + /// A result that arrives at once comes while the confirmation alert is still closing. + private func presentResult(success: Bool, message: String) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + alertType = .result(success: success, message: message) + } + } + + private func authenticateAndSend() { + AuthService.authenticate(reason: "Confirm your identity to activate \(profile.name) in Loop.") { result in + switch result { + case .success: + send() + case let .unavailable(message): + presentResult(success: false, message: message) + case .failed: + presentResult(success: false, message: "Authentication failed") + case .canceled: + break + } + } + } + + private func send() { + RemoteCommandTracker.shared.sendLoopProfileSwitch(profile: profile) { success, error in + if !success { + LogManager.shared.log(category: .apns, message: "Profile switch not sent: \(error ?? "unknown error")") + } + } + } + + private var glucoseUnitText: String { + Localizer.getPreferredUnit() == .milligramsPerDeciliter ? "mg/dL" : "mmol/L" + } + + private func glucoseText(_ value: Double) -> String { + Localizer.formatQuantity(HKQuantity(unit: profile.units, doubleValue: value)) + } + + /// Schedule start times are offsets from midnight in the profile's time zone. + static func timeText(_ seconds: Int) -> String { + let formatter = DateFormatter() + formatter.timeStyle = .short + formatter.timeZone = TimeZone(identifier: "UTC") + return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(seconds))) + } +} + +/// A remote command button for a command the connected Loop build does not offer. +struct UnavailableCommandButtonView: View { + let command: String + let iconName: String + let title: String + let message: String + + @State private var showInfo = false + + var body: some View { + Button { + showInfo = true + } label: { + VStack { + Image(systemName: iconName) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 50, height: 50) + Text(command) + } + .frame(maxWidth: .infinity, minHeight: 100) + .padding() + .background(Color.gray.opacity(0.35)) + .foregroundColor(.white) + .cornerRadius(8) + } + .buttonStyle(PlainButtonStyle()) + .alert(isPresented: $showInfo) { + Alert(title: Text(title), message: Text(message), dismissButton: .default(Text("OK"))) + } + } +} diff --git a/LoopFollow/Remote/RemoteCommandTracker.swift b/LoopFollow/Remote/RemoteCommandTracker.swift index 1870812ec..be772414b 100644 --- a/LoopFollow/Remote/RemoteCommandTracker.swift +++ b/LoopFollow/Remote/RemoteCommandTracker.swift @@ -4,7 +4,7 @@ import Foundation extension Notification.Name { - /// Posted when a remote meal edit/delete reaches a terminal state, so treatment lists can refresh. + /// Posted when a tracked remote command reaches a terminal state, with its key in `userInfo["key"]`, so lists can refresh. static let remoteMealCommandDidComplete = Notification.Name("LoopFollow.remoteMealCommandDidComplete") } @@ -40,15 +40,23 @@ final class RemoteCommandTracker: ObservableObject { return false } - func begin(key: String) { + func begin(key: String, timeout: TimeInterval? = nil, timeoutMessage: String = RemoteCommandTracker.timeoutMessage) { onMain { self.timeouts[key]?.cancel() self.states[key] = .pending(since: Date()) let work = DispatchWorkItem { [weak self] in - self?.resolve(key: key, success: false, message: Self.timeoutMessage) + self?.resolve(key: key, success: false, message: timeoutMessage) } self.timeouts[key] = work - DispatchQueue.main.asyncAfter(deadline: .now() + self.timeout, execute: work) + DispatchQueue.main.asyncAfter(deadline: .now() + (timeout ?? self.timeout), execute: work) + } + } + + /// True while any command whose key starts with `prefix` waits for its reply. + func isBusy(keyPrefix prefix: String) -> Bool { + states.contains { key, state in + if case .pending = state { return key.hasPrefix(prefix) } + return false } } @@ -73,7 +81,10 @@ final class RemoteCommandTracker: ObservableObject { /// Returns true when the push resolved a pending command. @discardableResult func handleNotification(userInfo: [AnyHashable: Any]) -> Bool { - guard let resolution = trioAcks.resolution(for: userInfo) ?? LoopCarbAckAdapter.resolution(for: userInfo) else { return false } + guard let resolution = trioAcks.resolution(for: userInfo) + ?? LoopCarbAckAdapter.resolution(for: userInfo) + ?? LoopProfileAckAdapter.resolution(for: userInfo) + else { return false } guard isBusy(key: resolution.key) else { LogManager.shared.log(category: .apns, message: "Ack for \(LogRedactor.tail(resolution.key)) matched no pending command") return false @@ -148,6 +159,34 @@ enum LoopCarbAckAdapter { } } +/// Matches Loop's return push for a profile switch by the profile name in its message +/// ("Profile '' activated" or "... Profile '' not activated: "). +enum LoopProfileAckAdapter { + static let keyPrefix = "loop-profile-switch:" + /// Pump programming and both pushes can take a while; a missing reply does not mean the switch failed. + static let timeout: TimeInterval = 180 + static let timeoutMessage = "Loop has not replied. The switch may still complete; the list shows the active profile once Loop uploads it. Check it before sending again." + + static func key(for name: String) -> String { + keyPrefix + name + } + + static func resolution(for userInfo: [AnyHashable: Any]) -> RemoteCommandTracker.Resolution? { + guard userInfo["command_type"] as? String == "profile_switch", + let status = userInfo["command_status"] as? String, + let body = alertBody(userInfo), + let name = profileName(in: body) + else { return nil } + LogManager.shared.log(category: .apns, message: "Loop ack: type=profile_switch status=\(status)") + return RemoteCommandTracker.Resolution(key: key(for: name), success: status == "success", message: body) + } + + static func profileName(in message: String) -> String? { + guard let match = message.firstMatch(of: /Profile '(.+)' (?:not )?activated/) else { return nil } + return String(match.1) + } +} + private func alertBody(_ userInfo: [AnyHashable: Any]) -> String? { let alert = (userInfo["aps"] as? [String: Any])?["alert"] as? [String: Any] guard let body = alert?["body"] as? String, !body.isEmpty else { return nil } @@ -223,6 +262,15 @@ extension RemoteCommandTracker { } } + func sendLoopProfileSwitch(profile: LoopProfile, completion: @escaping (Bool, String?) -> Void) { + let key = LoopProfileAckAdapter.key(for: profile.name) + begin(key: key, timeout: LoopProfileAckAdapter.timeout, timeoutMessage: LoopProfileAckAdapter.timeoutMessage) + guard let otp = loopOTP(key: key, completion: completion) else { return } + LoopAPNSService().sendProfileSwitch(name: profile.name, fingerprint: profile.fingerprint, otp: otp) { [weak self] success, error in + self?.finishLoopSend(key: key, success: success, error: error, completion: completion) + } + } + private func loopOTP(key: String, completion: (Bool, String?) -> Void) -> String? { let qrCodeURL = Storage.shared.loopAPNSQrCodeURL.value let otp = TOTPGenerator.extractOTPFromURL(qrCodeURL) @@ -244,7 +292,7 @@ extension RemoteCommandTracker { DispatchQueue.main.async { if success { TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) - LogManager.shared.log(category: .apns, message: "Carb command sent for syncIdentifier=\(LogRedactor.tail(key))") + LogManager.shared.log(category: .apns, message: "Loop command sent for \(LogRedactor.tail(key))") } else { self.resolve(key: key, success: false, message: error ?? "The command could not be sent.") } diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index f65268762..1fbd067f2 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -58,6 +58,9 @@ class Observable { var loopFollowDeviceToken = ObservableValue(default: "") + /// Saved profiles from the latest Loop profile upload; nil until one is loaded. + var loopProfiles = ObservableValue(default: nil) + var isNotLooping = ObservableValue(default: false) /// CGM state reported after the newest reading; nil while readings flow. diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index b8989f28f..dce0b6611 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -698,8 +698,13 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { runMigrationsIfNeeded() } - @objc func handleRemoteMealCommandDidComplete() { - TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date()) + @objc func handleRemoteMealCommandDidComplete(_ notification: Notification) { + if (notification.userInfo?["key"] as? String)?.hasPrefix(LoopProfileAckAdapter.keyPrefix) == true { + // Loop uploads the switched profile right after it confirms the command. + TaskScheduler.shared.rescheduleTask(id: .profile, to: Date().addingTimeInterval(10)) + } else { + TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date()) + } } @objc func handleBFUReloadCompleted() { diff --git a/RemoteShared/LoopAPNSCommands.swift b/RemoteShared/LoopAPNSCommands.swift index 0473df59e..e709e0989 100644 --- a/RemoteShared/LoopAPNSCommands.swift +++ b/RemoteShared/LoopAPNSCommands.swift @@ -70,6 +70,14 @@ enum LoopAPNSCommands { return fields } + /// Keys are prefixed so an unpatched Loop rejects the command. The fingerprint identifies the previewed profile values. + static func profileSwitchCommandPayload(name: String, fingerprint: String, otp: String, now: Date = Date()) -> [String: Any] { + var fields = commandHeader(otp: otp, alert: "Remote Profile Switch: \(name)", now: now) + fields["profile-switch"] = name + fields["profile-switch-fingerprint"] = fingerprint + return fields + } + /// Creates a properly formatted ISO8601 date string with milliseconds (matching Nightscout's format) /// - Parameter date: The date to format /// - Returns: Formatted date string like "2022-12-24T21:34:02.090Z" diff --git a/Tests/LoopAPNSCommandPayloadTests.swift b/Tests/LoopAPNSCommandPayloadTests.swift index 374509bc6..2040bb127 100644 --- a/Tests/LoopAPNSCommandPayloadTests.swift +++ b/Tests/LoopAPNSCommandPayloadTests.swift @@ -66,4 +66,13 @@ struct LoopAPNSCommandPayloadTests { ) #expect(withFood["carbs-edit-food-type"] as? String == "πŸ•") } + + @Test("profile switch names the profile and carries its fingerprint") + func profileSwitchKeys() { + let json = LoopAPNSCommands.profileSwitchCommandPayload(name: "Sport", fingerprint: "fec4f066b5a54ecd", otp: "123456", now: now) + #expect(Set(json.keys) == headerKeys.union(["profile-switch", "profile-switch-fingerprint"])) + #expect(json["profile-switch"] as? String == "Sport") + #expect(json["profile-switch-fingerprint"] as? String == "fec4f066b5a54ecd") + #expect(json["alert"] as? String == "Remote Profile Switch: Sport") + } } diff --git a/Tests/LoopProfileTests.swift b/Tests/LoopProfileTests.swift new file mode 100644 index 000000000..5a70f0941 --- /dev/null +++ b/Tests/LoopProfileTests.swift @@ -0,0 +1,46 @@ +// LoopFollow +// LoopProfileTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct LoopProfileTests { + private func store(units: String, sens: Double, low: Double, high: Double) throws -> NSProfile.Store { + let json = """ + {"timezone": "ETC/GMT-1", "units": "\(units)", + "basal": [{"time": "00:00", "value": 0.85, "timeAsSeconds": 0}, {"time": "06:00", "value": 1.1, "timeAsSeconds": 21600}], + "carbratio": [{"time": "00:00", "value": 10, "timeAsSeconds": 0}], + "sens": [{"time": "00:00", "value": \(sens), "timeAsSeconds": 0}], + "target_low": [{"time": "00:00", "value": \(low), "timeAsSeconds": 0}], + "target_high": [{"time": "00:00", "value": \(high), "timeAsSeconds": 0}]} + """ + return try JSONDecoder().decode(NSProfile.Store.self, from: Data(json.utf8)) + } + + @Test("fingerprint matches the value Loop computes for the same store entry") + func fingerprint() throws { + let mgdl = try LoopProfile(name: "Sport", store: store(units: "mg/dL", sens: 45, low: 100, high: 110)) + #expect(mgdl.fingerprint == "fec4f066b5a54ecd") + let mmol = try LoopProfile(name: "Sport", store: store(units: "mmol/L", sens: 2.497_646_904_752_9, low: 5.550_747_991_045_533, high: 6.105_822_790_150_087)) + #expect(mmol.fingerprint == "f7d9520ff02af97c") + } + + @Test("basal total weights each rate by how long it runs") + func basalTotal() throws { + let profile = try LoopProfile(name: "Sport", store: store(units: "mg/dL", sens: 45, low: 100, high: 110)) + #expect(abs(profile.basalTotal - (0.85 * 6 + 1.1 * 18)) < 0.000_1) + #expect(profile.correctionRange == [LoopProfile.RangeItem(seconds: 0, low: 100, high: 110)]) + } + + @Test("the Default entry is left out and names are sorted") + func savedProfiles() throws { + let entry = try store(units: "mg/dL", sens: 45, low: 100, high: 110) + let profile = NSProfile(store: ["Default": entry, "Work": entry, "Sport": entry], defaultProfile: "Work", units: "mg/dL", + bundleIdentifier: nil, isAPNSProduction: nil, deviceToken: nil, teamID: nil, expirationDate: nil, + startDate: nil, createdAt: nil, remoteCommands: nil, trioOverrides: nil, loopSettings: nil) + let set = LoopProfileSet(profile) + #expect(set.profiles.map(\.name) == ["Sport", "Work"]) + #expect(set.activeName == "Work") + } +} diff --git a/Tests/RemoteCommandTrackerTests.swift b/Tests/RemoteCommandTrackerTests.swift index a7ec75dd2..5381ec1d0 100644 --- a/Tests/RemoteCommandTrackerTests.swift +++ b/Tests/RemoteCommandTrackerTests.swift @@ -82,4 +82,27 @@ struct RemoteCommandTrackerTests { #expect(LoopCarbAckAdapter.resolution(for: ["command_type": "meal", "command_status": "success", "sync_identifier": "SYNC-1"]) == nil) #expect(LoopCarbAckAdapter.resolution(for: ["command_type": "carbs_delete", "command_status": "failed"]) == nil) } + + @Test("Loop profile switch acks resolve the profile named in the message") + func loopProfileAdapter() { + let tracker = RemoteCommandTracker(timeout: 60) + let sickDay = LoopProfileAckAdapter.key(for: "Sick Day") + tracker.begin(key: sickDay) + let lateSport: [AnyHashable: Any] = [ + "command_type": "profile_switch", + "command_status": "success", + "aps": ["alert": ["title": "Command Successful", "body": "Profile 'Sport' activated"]], + ] + #expect(tracker.handleNotification(userInfo: lateSport) == false) + #expect(tracker.isBusy(key: sickDay)) + let failed: [AnyHashable: Any] = [ + "command_type": "profile_switch", + "command_status": "failed", + "aps": ["alert": ["title": "Command Failed", "body": "Command failed: Profile 'Sick Day' not activated: Communication Failure"]], + ] + #expect(tracker.handleNotification(userInfo: failed) == true) + #expect(tracker.states[sickDay] == .done(success: false, message: "Command failed: Profile 'Sick Day' not activated: Communication Failure")) + #expect(LoopProfileAckAdapter.resolution(for: ["command_type": "profile_switch", "command_status": "failed"]) == nil) + #expect(LoopProfileAckAdapter.resolution(for: ["command_type": "carbs_edit", "command_status": "success"]) == nil) + } } From b5788f99f499770d2286d18a7627ab2bed8f63ed Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 30 Oct 2026 12:34:38 +0000 Subject: [PATCH 69/84] CI: Bump dev version to 7.1.27 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index b8ccce7dc..675794c3b 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.26 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.27 From f5ad131a0ed9fc25dc7d6e482cf363103b88edfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 31 Oct 2026 15:10:00 +0100 Subject: [PATCH 70/84] Ignore the previous pod's reservoir volume after a pod change (#783) * Ignore the previous pod's reservoir volume after a pod change Loop keeps uploading its last reservoir reading for 15 minutes after a pod change, under the new pod's ID. LoopFollow now ignores volumes for a new Omnipod until it has uploaded a record without one or 15 minutes have passed, and dates readings by the record's pump clock. * Treat a volume in an Omnipod record with no pod paired as the previous pod's * Show no reservoir volume for an Omnipod with no pod paired * Show no reservoir volume while Loop shows a pump status in its place * Show the status Loop uploads in place of the reservoir --- .../Controllers/Nightscout/DeviceStatus.swift | 7 +- .../Nightscout/PumpReservoir.swift | 116 ++++++++++++----- Tests/PumpReservoirTests.swift | 117 ++++++++++++++++-- 3 files changed, 196 insertions(+), 44 deletions(-) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift index 87c44f676..0c23ccc73 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatus.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatus.swift @@ -117,8 +117,9 @@ extension MainViewController { pumpID: lastPumpRecord["pumpID"] as? String, manufacturer: lastPumpRecord["manufacturer"] as? String, model: lastPumpRecord["model"] as? String, + displayOverride: lastPumpRecord["reservoir_display_override"] as? String, cache: Storage.shared.pumpReservoirCache.value, - now: Date() + date: Date(timeIntervalSince1970: lastPumpTime) ) Storage.shared.pumpReservoirCache.value = reservoir.cache @@ -133,6 +134,10 @@ extension MainViewController { latestPumpVolume = 50.0 infoManager.updateInfoData(type: .pump, value: "50+U", numericValue: 50.0) Storage.shared.lastPumpReservoirU.value = nil + case let .status(status): + latestPumpVolume = nil + infoManager.updateInfoData(type: .pump, value: status) + Storage.shared.lastPumpReservoirU.value = nil case .unknown: // The row stays cleared, which the info table renders as an em dash. latestPumpVolume = nil diff --git a/LoopFollow/Controllers/Nightscout/PumpReservoir.swift b/LoopFollow/Controllers/Nightscout/PumpReservoir.swift index ad843a296..b65ce50bd 100644 --- a/LoopFollow/Controllers/Nightscout/PumpReservoir.swift +++ b/LoopFollow/Controllers/Nightscout/PumpReservoir.swift @@ -14,6 +14,24 @@ struct PumpReservoirCache: Codable, Equatable { /// When this pump first appeared in a device status record LoopFollow fetched. let pumpSince: Date var reading: Reading? + /// Whether a volume in a record is this pump's own. Right after a pod change Loop + /// still uploads the previous pod's final volume. + var isSettled: Bool + + init(pumpID: String, pumpSince: Date, reading: Reading? = nil, isSettled: Bool = true) { + self.pumpID = pumpID + self.pumpSince = pumpSince + self.reading = reading + self.isSettled = isSettled + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + pumpID = try container.decode(String.self, forKey: .pumpID) + pumpSince = try container.decode(Date.self, forKey: .pumpSince) + reading = try container.decodeIfPresent(Reading.self, forKey: .reading) + isSettled = try container.decodeIfPresent(Bool.self, forKey: .isSettled) ?? true + } } /// What a device status record says about the reservoir. @@ -22,6 +40,8 @@ enum PumpReservoirState: Equatable { case units(Double) /// A pump that reports a volume only once it drops below 50U, and is above it. case aboveReportingLimit + /// The status the uploader shows in place of the volume, such as "No Pod". + case status(String) /// No volume to show. case unknown } @@ -31,8 +51,9 @@ enum PumpReservoirResolver { /// while the pod is below 50U. A reading carries across those gaps for this long. static let maxReadingAge: TimeInterval = 30 * 60 - /// A volume reported within this long of a pump first appearing can still be the - /// previous pod's final reading, so it says nothing about the pump now on. + /// Loop uploads its last reservoir reading until it is 15 minutes old, whichever pod it + /// came from, so the previous pod's final volume can appear for up to this long after a + /// pod change. A new pod reports no volume of its own until it drops below 50U. static let pumpSettleTime: TimeInterval = 15 * 60 struct Resolution: Equatable { @@ -41,59 +62,94 @@ enum PumpReservoirResolver { let cache: PumpReservoirCache? } + /// - Parameters: + /// - displayOverride: The status Loop shows in place of the reservoir, such as + /// "Finish Setup" or "No Pod", in the uploader's language. + /// - date: When the record was made, from the pump clock it carries. static func resolve( + reservoir: Double?, + pumpID: String?, + manufacturer: String?, + model: String?, + displayOverride: String? = nil, + cache storedCache: PumpReservoirCache?, + date: Date + ) -> Resolution { + let resolution = resolveVolume( + reservoir: reservoir, + pumpID: pumpID, + manufacturer: manufacturer, + model: model, + cache: storedCache, + date: date + ) + // The pump keeps being tracked while its status replaces the volume. + if let status = displayOverride?.trimmingCharacters(in: .whitespacesAndNewlines), !status.isEmpty { + return Resolution(state: .status(status), cache: resolution.cache) + } + return resolution + } + + private static func resolveVolume( reservoir: Double?, pumpID: String?, manufacturer: String?, model: String?, cache storedCache: PumpReservoirCache?, - now: Date + date: Date ) -> Resolution { - let withoutReading: PumpReservoirState = reportsVolumeOnlyWhenLow(manufacturer: manufacturer, model: model) - ? .aboveReportingLimit - : .unknown + let onlyWhenLow = reportsVolumeOnlyWhenLow(manufacturer: manufacturer, model: model) + let withoutReading: PumpReservoirState = onlyWhenLow ? .aboveReportingLimit : .unknown + + let pumpID = pumpID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + // Loop reports an Omnipod as "Unknown" while no pod is paired, so there is no volume + // to show and one in the record is the previous pod's. Medtrum is always "Unknown" + // on Loop. + if pumpID == "Unknown", onlyWhenLow { + return Resolution(state: .unknown, cache: storedCache) + } // A reading is carried between records only when the uploader names the pump it - // came from, so that a pod change discards it. - guard let pumpID = identifiedPump(pumpID) else { - guard let reservoir else { return Resolution(state: withoutReading, cache: nil) } - return Resolution(state: .units(reservoir), cache: nil) + // came from, so that a pod change discards it. The stored pump is kept across + // records that name none, so the next one reads as a change. + guard !pumpID.isEmpty, pumpID != "Unknown" else { + guard let reservoir else { return Resolution(state: withoutReading, cache: storedCache) } + return Resolution(state: .units(reservoir), cache: storedCache) } - var cache = storedCache?.pumpID == pumpID - ? storedCache! - : PumpReservoirCache(pumpID: pumpID, pumpSince: now, reading: nil) + var cache: PumpReservoirCache + if let storedCache, storedCache.pumpID == pumpID { + cache = storedCache + } else { + // With no pump stored there is no change to have seen, so the pump is taken + // as one already in use. + cache = PumpReservoirCache(pumpID: pumpID, pumpSince: date, isSettled: storedCache == nil || !onlyWhenLow) + } + + if !cache.isSettled, reservoir == nil || date.timeIntervalSince(cache.pumpSince) >= pumpSettleTime { + cache.isSettled = true + } if let reservoir { - cache.reading = PumpReservoirCache.Reading(units: reservoir, date: now) + guard cache.isSettled else { return Resolution(state: withoutReading, cache: cache) } + cache.reading = PumpReservoirCache.Reading(units: reservoir, date: date) return Resolution(state: .units(reservoir), cache: cache) } if let reading = cache.reading { - let age = now.timeIntervalSince(reading.date) + let age = date.timeIntervalSince(reading.date) if age >= 0, age <= maxReadingAge { return Resolution(state: .units(reading.units), cache: cache) } - if reading.date.timeIntervalSince(cache.pumpSince) >= pumpSettleTime { - // The pump has reported a volume long enough after coming online for that - // to be its own, so it is below its reporting limit and the only thing - // missing is a fresh number. A pump first seen mid-pod has no such gap, - // so its first reading is treated as a pod change's and dropped below. - return Resolution(state: .unknown, cache: cache) - } - cache.reading = nil + // The pump has reported a volume of its own, so it is below its reporting limit + // and the only thing missing is a fresh number. + return Resolution(state: .unknown, cache: cache) } return Resolution(state: withoutReading, cache: cache) } - private static func identifiedPump(_ pumpID: String?) -> String? { - let trimmed = pumpID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - // Loop reports "Unknown" while no pod is paired. - guard !trimmed.isEmpty, trimmed != "Unknown" else { return nil } - return trimmed - } - private static func reportsVolumeOnlyWhenLow(manufacturer: String?, model: String?) -> Bool { let pump = [manufacturer, model].compactMap { $0 }.joined(separator: " ").lowercased() // An uploader that names no pump cannot be told apart from one that reports its diff --git a/Tests/PumpReservoirTests.swift b/Tests/PumpReservoirTests.swift index 625e43802..8ebabab6f 100644 --- a/Tests/PumpReservoirTests.swift +++ b/Tests/PumpReservoirTests.swift @@ -14,6 +14,7 @@ struct PumpReservoirTests { pumpID: String? = "17CB71F7", manufacturer: String? = "Insulet", model: String? = "Omnipod DASH", + displayOverride: String? = nil, cache: PumpReservoirCache? = nil, at date: Date? = nil ) -> PumpReservoirResolver.Resolution { @@ -22,8 +23,9 @@ struct PumpReservoirTests { pumpID: pumpID, manufacturer: manufacturer, model: model, + displayOverride: displayOverride, cache: cache, - now: date ?? now + date: date ?? now ) } @@ -70,17 +72,59 @@ struct PumpReservoirTests { #expect(resolve(cache: cache(units: 9.9, readingAge: -60 * 60)).state == .unknown) } - @Test("a volume reported right after a pod change is shown but not trusted once stale") - func podChangeCarryoverIsDiscarded() { + @Test("a volume reported right after a pod change is the previous pod's and is not shown") + func podChangeCarryoverIsIgnored() { // Loop's first records for a new pod still carry the previous pod's final volume. - let carryover = resolve(reservoir: 9.9, cache: PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60), reading: nil)) - #expect(carryover.state == .units(9.9)) - #expect(carryover.cache?.pumpID == pump) + let previousPod = PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60), reading: .init(units: 25.6, date: now.addingTimeInterval(-5 * 60))) + var result = resolve(reservoir: 25.5, cache: previousPod) + #expect(result.state == .aboveReportingLimit) + #expect(result.cache?.pumpID == pump) + #expect(result.cache?.reading == nil) + + result = resolve(reservoir: 25.5, cache: result.cache, at: now.addingTimeInterval(10 * 60)) + #expect(result.state == .aboveReportingLimit) + #expect(result.cache?.reading == nil) + + result = resolve(cache: result.cache, at: now.addingTimeInterval(15 * 60)) + #expect(result.state == .aboveReportingLimit) + #expect(result.cache?.isSettled == true) + } + + @Test("a new pod's volumes count once it has uploaded a record without one") + func podChangeSettlesOnMissingVolume() { + let changed = resolve(reservoir: 25.5, cache: PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60))) + let gap = resolve(cache: changed.cache, at: now.addingTimeInterval(5 * 60)) + let reported = resolve(reservoir: 48.5, cache: gap.cache, at: now.addingTimeInterval(10 * 60)) + #expect(reported.state == .units(48.5)) + #expect(reported.cache?.reading?.units == 48.5) + } + + @Test("a new pod's volumes count 15 minutes after it appeared") + func podChangeSettlesAfterTime() { + let changed = resolve(reservoir: 25.5, cache: PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60))) + #expect(resolve(reservoir: 25.5, cache: changed.cache, at: now.addingTimeInterval(14 * 60)).state == .aboveReportingLimit) + #expect(resolve(reservoir: 48.5, cache: changed.cache, at: now.addingTimeInterval(15 * 60)).state == .units(48.5)) + } + + @Test("a pump change is seen across records that name no pump") + func podChangeAcrossUnknown() { + let previousPod = PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60), reading: .init(units: 43.3, date: now.addingTimeInterval(-10 * 60))) + let pairing = resolve(pumpID: "Unknown", cache: previousPod, at: now.addingTimeInterval(-5 * 60)) + #expect(pairing.cache == previousPod) + #expect(resolve(reservoir: 42.9, cache: pairing.cache).state == .aboveReportingLimit) + } + + @Test("a pump seen with nothing stored has its volume taken as its own") + func firstSeenPumpIsSettled() { + let result = resolve(reservoir: 25.5) + #expect(result.state == .units(25.5)) + #expect(result.cache?.isSettled == true) + } - let laterOnTheSamePod = resolve(cache: carryover.cache, at: now.addingTimeInterval(31 * 60)) - #expect(laterOnTheSamePod.state == .aboveReportingLimit) - #expect(laterOnTheSamePod.cache?.pumpID == pump) - #expect(laterOnTheSamePod.cache?.reading == nil) + @Test("a pump that reports its volume in every record is used right after a change") + func otherPumpChangeIsSettled() { + let previous = PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60)) + #expect(resolve(reservoir: 180, manufacturer: "Medtronic", model: "723", cache: previous).state == .units(180)) } @Test("a pod change drops the previous pod's reading") @@ -119,10 +163,49 @@ struct PumpReservoirTests { #expect(withoutVolume.cache == nil) } - @Test("a pump with no pod paired is not an identity to cache against") + @Test("an Omnipod with no pod paired shows no volume and keeps the stored pump") func unknownPumpID() { - let result = resolve(pumpID: "Unknown", cache: cache(units: 9.9, readingAge: 5 * 60)) - #expect(result.state == .aboveReportingLimit) + let stored = cache(units: 9.9, readingAge: 5 * 60) + let result = resolve(pumpID: "Unknown", cache: stored) + #expect(result.state == .unknown) + #expect(result.cache == stored) + } + + @Test("a volume uploaded while no pod is paired is the previous pod's") + func unknownPumpIDWithVolume() { + let stored = cache(units: 12.6, readingAge: 5 * 60) + let result = resolve(reservoir: 12.5, pumpID: "Unknown", cache: stored) + #expect(result.state == .unknown) + #expect(result.cache == stored) + } + + @Test("a status Loop shows in place of the reservoir is shown instead of a volume") + func displayOverride() { + let stored = cache(units: 9.9, readingAge: 5 * 60) + let result = resolve(displayOverride: " Signal Loss ", cache: stored) + #expect(result.state == .status("Signal Loss")) + #expect(result.cache == stored) + } + + @Test("a new pod finishing setup shows its status and is still seen as a change") + func displayOverrideDuringSetup() { + let previousPod = PumpReservoirCache(pumpID: "17CB71F6", pumpSince: now.addingTimeInterval(-3 * 24 * 60 * 60), reading: .init(units: 21.0, date: now.addingTimeInterval(-5 * 60))) + let setup = resolve(reservoir: 21.0, displayOverride: "Finish Setup", cache: previousPod) + #expect(setup.state == .status("Finish Setup")) + #expect(setup.cache?.pumpID == pump) + #expect(setup.cache?.reading == nil) + #expect(resolve(reservoir: 21.0, cache: setup.cache, at: now.addingTimeInterval(5 * 60)).state == .aboveReportingLimit) + } + + @Test("an empty status shows the volume") + func emptyDisplayOverride() { + #expect(resolve(reservoir: 21.0, displayOverride: " ", cache: cache(units: 21.0, readingAge: 5 * 60)).state == .units(21.0)) + } + + @Test("a pump Loop names Unknown that reports its volume in every record shows it") + func unknownPumpIDOtherPump() { + let result = resolve(reservoir: 185.6, pumpID: "Unknown", manufacturer: "Medtrum", model: "MD8201") + #expect(result.state == .units(185.6)) #expect(result.cache == nil) } @@ -148,4 +231,12 @@ struct PumpReservoirTests { let decoded = try JSONDecoder().decode(PumpReservoirCache.self, from: JSONEncoder().encode(original)) #expect(decoded == original) } + + @Test("a cache stored without the settled flag decodes as settled") + func cacheDecodesWithoutSettledFlag() throws { + let json = #"{"pumpID":"17CB71F7","pumpSince":0}"# + let decoded = try JSONDecoder().decode(PumpReservoirCache.self, from: Data(json.utf8)) + #expect(decoded.isSettled) + #expect(decoded.reading == nil) + } } From 3cff89c34ff6972acf61498dd111244f94c7ca3e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 31 Oct 2026 14:10:10 +0000 Subject: [PATCH 71/84] CI: Bump dev version to 7.1.28 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 675794c3b..036883e9c 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.27 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.28 From 6eb0b84c6acaf19e7529a94474140ed2d20f6f41 Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:57:26 -0400 Subject: [PATCH 72/84] Watch bolus: open on the amount, start at the recommendation; fit the meal and bolus buttons (#792) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Open the watch bolus on the amount, start at the recommendation, and fit the meal and bolus buttons The bolus screen opens on the amount instead of a list that, without Quick-Pick boluses or a recent recommendation, held only Other Amount. Loop / Trio's recommendation and the Quick-Pick boluses move behind a Presets button under Continue, shown only when there are any, with the Old Calculation Warning as before. While the recommendation is under 5 minutes old the amount starts there, with an icon line showing the amount and its age. It is filled in once, so coming back from the confirmation keeps what was set. The Max line is gone, since the crown and + stop at the maximum, and βˆ’ and + click like a crown detent. The meal and bolus buttons use a compact capsule whose height scales with the watch, so the amount and two buttons fit without scrolling; the system buttons pushed Log off the bottom of the meal screen. On the meal screen, Time and Log are both centered, so they line up. * Warn when the watch's starting recommendation goes stale, and drop it from Presets while fresh * Follow and check the latest bolus recommendation on the watch, and mark recommended amounts on the confirmation * Offer a newer watch bolus recommendation with a tap instead of applying it * Revert "Offer a newer watch bolus recommendation with a tap instead of applying it" This reverts commit dda326a88b2a5a070c2c2d0f8b0c3478e7d213ca. * Follow a lower watch bolus recommendation, offer a higher one with a tap, and catch up after the confirmation * Offer a recommendation that arrives after the watch bolus opened, follow only untouched amounts while on top, clear an expired starting recommendation, and let compact buttons wrap A loop cycle without a recommendation no longer counts as 0 U. The Presets button appears once the recommendation is old enough to be listed there, and meal Quick-Picks are centered like the other buttons. * Don't offer a bolus recommendation calculated before the last bolus After a bolus, the recommendation stays the same until Loop or Trio runs a new cycle and LoopFollow fetches it, which can take a few minutes. During that time the watch filled the old amount in again, so Continue and the crown would have given the bolus twice. The phone's bolus screens offered it too, with a tap. The phone now leaves out a recommendation when a manual bolus came after its loop cycle: from Nightscout, or sent from LoopFollow or the watch, alone or with a meal. The watch also ignores one from before insulin it sent itself, including when it sent directly. For Loop, the recommendation's time is now the cycle's own timestamp. The pump clock it used before also moves on failed cycles, which keep the old recommendation, so after a bolus that recommendation looked newer than the bolus. * Order the watch's last-insulin key modifiers the way SwiftFormat expects --------- Co-authored-by: Jonas BjΓΆrkert --- .../Nightscout/DeviceStatusLoop.swift | 3 + .../Nightscout/DeviceStatusOpenAPS.swift | 2 + .../Remote/LoopAPNS/LoopAPNSBolusView.swift | 5 +- LoopFollow/Remote/RecommendedBolus.swift | 28 ++ LoopFollow/Remote/TRC/BolusView.swift | 14 +- LoopFollow/Storage/Observable.swift | 3 + LoopFollow/Watch/WatchStateBuilder.swift | 9 +- LoopFollowWatch/ContentView.swift | 8 +- LoopFollowWatch/Remote/BolusEntryView.swift | 280 ++++++++++++++---- .../Remote/CommandConfirmView.swift | 60 +++- LoopFollowWatch/Remote/MealEntryView.swift | 9 +- LoopFollowWatch/WatchSession.swift | 12 + 12 files changed, 354 insertions(+), 79 deletions(-) create mode 100644 LoopFollow/Remote/RecommendedBolus.swift diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index 1727c0729..93940397c 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -108,9 +108,12 @@ extension MainViewController { if let recBolus = lastLoopRecord["recommendedBolus"] as? Double { infoManager.updateInfoData(type: .recBolus, value: InsulinFormatter.shared.string(recBolus), numericValue: recBolus) Observable.shared.deviceRecBolus.value = recBolus + let cycleTime = (lastLoopRecord["timestamp"] as? String).flatMap { formatter.date(from: $0)?.timeIntervalSince1970 } + Observable.shared.deviceRecBolusAt.value = cycleTime ?? lastLoopTime } else { infoManager.clearInfoData(type: .recBolus) Observable.shared.deviceRecBolus.value = nil + Observable.shared.deviceRecBolusAt.value = nil } if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] { if let tempBasalString = loopStatus["timestamp"] as? String, diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index ceaef95c5..ff4178b34 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -121,9 +121,11 @@ extension MainViewController { if let rec = lastLoopRecord["recommendedBolus"] as? Double { infoManager.updateInfoData(type: .recBolus, value: InsulinFormatter.shared.string(rec)) Observable.shared.deviceRecBolus.value = rec + Observable.shared.deviceRecBolusAt.value = updatedTime ?? Observable.shared.enactedOrSuggested.value } else { infoManager.clearInfoData(type: .recBolus) Observable.shared.deviceRecBolus.value = nil + Observable.shared.deviceRecBolusAt.value = nil } // Eventual BG diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift index 09222270c..59260eb55 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift @@ -307,8 +307,9 @@ struct LoopAPNSBolusView: View { private func loadRecommendedBolus() { // Load recommended bolus from Observable - recommendedBolus = Observable.shared.deviceRecBolus.value - lastLoopTime = Observable.shared.alertLastLoopTime.value + let recommended = RecommendedBolus.current() + recommendedBolus = recommended?.units + lastLoopTime = recommended?.at // Reset warning state when new data is loaded showOldCalculationWarning = false diff --git a/LoopFollow/Remote/RecommendedBolus.swift b/LoopFollow/Remote/RecommendedBolus.swift new file mode 100644 index 000000000..9d4ac9015 --- /dev/null +++ b/LoopFollow/Remote/RecommendedBolus.swift @@ -0,0 +1,28 @@ +// LoopFollow +// RecommendedBolus.swift + +import Foundation + +/// The AID's latest bolus recommendation, offered only while no bolus came +/// after the loop cycle that calculated it. Until the next cycle, an earlier +/// recommendation leaves out that bolus, and using it could double it. +enum RecommendedBolus { + static func current() -> (units: Double, at: TimeInterval)? { + guard let units = Observable.shared.deviceRecBolus.value, + let at = Observable.shared.deviceRecBolusAt.value + else { return nil } + if let lastBolus = lastBolusAt(), lastBolus >= at { return nil } + return (units, at) + } + + /// The latest manual bolus in Nightscout, or sent from LoopFollow or the + /// watch, which Nightscout may not show yet. Automatic boluses are left + /// out: the cycle that recommends a bolus can also give one. + static func lastBolusAt() -> TimeInterval? { + let nightscout = MainViewController.shared?.bolusData.map(\.date).max() + let sentBolus = Storage.shared.remoteBolusHistory.value.map(\.date).max() + let sentMeal = Storage.shared.remoteMealHistory.value.filter { $0.bolus > 0 }.map(\.date).max() + let sent = [sentBolus, sentMeal].compactMap { $0?.timeIntervalSince1970 }.max() + return [nightscout, sent].compactMap { $0 }.max() + } +} diff --git a/LoopFollow/Remote/TRC/BolusView.swift b/LoopFollow/Remote/TRC/BolusView.swift index e41ab89ce..c0be6e408 100644 --- a/LoopFollow/Remote/TRC/BolusView.swift +++ b/LoopFollow/Remote/TRC/BolusView.swift @@ -12,8 +12,6 @@ struct BolusView: View { @ObservedObject private var maxBolus = Storage.shared.maxBolus @ObservedObject private var bolusIncrement = Storage.shared.bolusIncrement - @ObservedObject private var deviceRecBolus = Observable.shared.deviceRecBolus - @ObservedObject private var enactedOrSuggested = Observable.shared.enactedOrSuggested @ObservedObject private var quickPickBoluses = QuickPickBolusesManager.shared @FocusState private var bolusFieldIsFocused: Bool @@ -203,8 +201,8 @@ struct BolusView: View { title: Text("Old Calculation Warning"), message: Text(alertMessage ?? ""), primaryButton: .default(Text("Use Anyway")) { - if let rec = deviceRecBolus.value { - applyRecommendedBolus(rec) + if let rec = RecommendedBolus.current() { + applyRecommendedBolus(rec.units) } }, secondaryButton: .cancel() @@ -220,10 +218,10 @@ struct BolusView: View { @ViewBuilder private func recommendedBlocks(now: Date) -> some View { - if let rec = deviceRecBolus.value, - let t = enactedOrSuggested.value - { - let ageSec = max(0, now.timeIntervalSince1970 - t) + // The TimelineView redraws this every second, so a new loop cycle or bolus shows up here. + if let recommended = RecommendedBolus.current() { + let rec = recommended.units + let ageSec = max(0, now.timeIntervalSince1970 - recommended.at) if ageSec < 12 * 60 { let maxU = maxBolus.value.doubleValue(for: .internationalUnit()) diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 1fbd067f2..229ee6c65 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -48,6 +48,9 @@ class Observable { var alertLastLoopTime = ObservableValue(default: nil) var previousAlertLastLoopTime = ObservableValue(default: nil) var deviceRecBolus = ObservableValue(default: nil) + /// When the loop cycle behind `deviceRecBolus` ran. Unlike `alertLastLoopTime`, + /// a failed cycle, which keeps the previous recommendation, does not move it. + var deviceRecBolusAt = ObservableValue(default: nil) var deviceBatteryLevel = ObservableValue(default: nil) var deviceBatteryIsCharging = ObservableValue(default: nil) var pumpBatteryLevel = ObservableValue(default: nil) diff --git a/LoopFollow/Watch/WatchStateBuilder.swift b/LoopFollow/Watch/WatchStateBuilder.swift index 21d89021c..12a379c99 100644 --- a/LoopFollow/Watch/WatchStateBuilder.swift +++ b/LoopFollow/Watch/WatchStateBuilder.swift @@ -138,10 +138,7 @@ enum WatchStateBuilder { let maxCarbs = Storage.shared.maxCarbs.value.doubleValue(for: .gram()) let mealWithFatProtein = kind == .trio && Storage.shared.mealWithFatProtein.value let now = Date() - - let recommendedAt: TimeInterval? = kind == .trio - ? Observable.shared.enactedOrSuggested.value - : Observable.shared.alertLastLoopTime.value + let recommended = RecommendedBolus.current() return WatchRemoteConfig( kind: kind, @@ -152,8 +149,8 @@ enum WatchStateBuilder { maxProtein: Storage.shared.maxProtein.value.doubleValue(for: .gram()), mealWithBolus: kind == .trio && Storage.shared.mealWithBolus.value, mealWithFatProtein: mealWithFatProtein, - recommendedBolus: Observable.shared.deviceRecBolus.value, - recommendedBolusAt: recommendedAt.map { Date(timeIntervalSince1970: $0) }, + recommendedBolus: recommended?.units, + recommendedBolusAt: recommended.map { Date(timeIntervalSince1970: $0.at) }, overridePresets: kind == .trio ? trioOverridePresets() : loopOverridePresets(), tempTargetPresets: kind == .trio ? tempTargetPresets() : [], quickPickBoluses: QuickPickBolusesManager.computeQuickPickBoluses( diff --git a/LoopFollowWatch/ContentView.swift b/LoopFollowWatch/ContentView.swift index f8abc2df1..5db091911 100644 --- a/LoopFollowWatch/ContentView.swift +++ b/LoopFollowWatch/ContentView.swift @@ -9,7 +9,7 @@ enum WatchRoute: Hashable { case treatments case meal case bolus - case bolusAmount + case bolusPresets case overrides case tempTargets case confirm(CommandRequest) @@ -21,6 +21,8 @@ struct CommandRequest: Hashable { let lines: [String] let action: WatchCommand.Action let deliversInsulin: Bool + /// Set when a bolus amount is the AID's recommendation, so it can be checked again before sending. + var recommendation: BolusRecommendation? = nil } /// Owns the navigation path so a finished command can return to the glucose page. @@ -75,9 +77,9 @@ struct ContentView: View { case .meal: MealEntryView(remote: remote) case .bolus: - BolusPickerView(remote: remote) - case .bolusAmount: BolusAmountView(remote: remote) + case .bolusPresets: + BolusPresetsView(remote: remote) case .overrides: PresetListView(kind: .override, state: state, presets: remote.overridePresets) case .tempTargets: diff --git a/LoopFollowWatch/Remote/BolusEntryView.swift b/LoopFollowWatch/Remote/BolusEntryView.swift index 7129b1d67..211d32eca 100644 --- a/LoopFollowWatch/Remote/BolusEntryView.swift +++ b/LoopFollowWatch/Remote/BolusEntryView.swift @@ -2,89 +2,130 @@ // BolusEntryView.swift import SwiftUI +import WatchKit -/// Pick an amount: the AID's recommendation, the phone's Quick-Pick boluses, or -/// any amount with the Digital Crown. Every choice still needs the crown confirmation. -struct BolusPickerView: View { +/// The AID's recommendation at the watch's bolus step. +struct BolusRecommendation: Hashable { + let units: Double + let at: Date + + var age: TimeInterval { Date().timeIntervalSince(at) } +} + +/// The AID's recommendation and the phone's Quick-Pick boluses, opened from the +/// amount screen. Every choice still needs the crown confirmation. +struct BolusPresetsView: View { let remote: WatchRemoteConfig @EnvironmentObject private var navigator: WatchNavigator - @State private var staleRecommendation: Double? + @State private var staleRecommendation: BolusRecommendation? + + /// Same cut-offs and warning as the phone's bolus screens. + static let recommendationMaxAge: TimeInterval = 12 * 60 + static let recommendationWarnAge: TimeInterval = 5 * 60 + static let oldCalculationWarning = "This recommended bolus was calculated more than 5 minutes ago. New treatments may have occurred since then. Proceed with caution." + + static func quickPicks(_ remote: WatchRemoteConfig) -> [Double] { + remote.quickPickBoluses.filter { $0 > 0 && $0 <= remote.maxBolus } + } + + /// The latest recommendation whatever its age, rounded down to the watch's + /// step; nil when the loop cycle carries none, or ran before insulin was + /// last sent from the watch and so leaves that insulin out. The phone drops + /// one from before any other bolus. + static func latestRecommendation(_ remote: WatchRemoteConfig) -> BolusRecommendation? { + guard let at = remote.recommendedBolusAt, let recommended = remote.recommendedBolus else { return nil } + if let sent = WatchSession.lastInsulinSentAt, at <= sent { return nil } + let rec = max(recommended, 0) + return BolusRecommendation(units: BolusAmountView.roundedDown(min(rec, remote.maxBolus), step: BolusAmountView.step(remote)), at: at) + } - /// Same cut-offs as the phone's bolus screens. - private let recommendationMaxAge: TimeInterval = 12 * 60 - private let recommendationWarnAge: TimeInterval = 5 * 60 + /// A recommendation recent enough to offer. + static func freshRecommendation(_ remote: WatchRemoteConfig) -> BolusRecommendation? { + guard let rec = latestRecommendation(remote), rec.units > 0, rec.age < recommendationMaxAge else { return nil } + return rec + } + + /// Under 5 minutes old the recommendation is already the starting amount, + /// so it is only listed here once it needs the warning. + static func presetRecommendation(_ remote: WatchRemoteConfig) -> BolusRecommendation? { + guard let rec = freshRecommendation(remote), rec.age >= recommendationWarnAge else { return nil } + return rec + } - private var step: Double { BolusAmountView.step(remote) } + static func hasPresets(_ remote: WatchRemoteConfig) -> Bool { + presetRecommendation(remote) != nil || !quickPicks(remote).isEmpty + } var body: some View { List { recommendation - ForEach(remote.quickPickBoluses.filter { $0 > 0 && $0 <= remote.maxBolus }, id: \.self) { units in + ForEach(Self.quickPicks(remote), id: \.self) { units in Button { - confirm(units) + navigator.push(.confirm(BolusAmountView.request(units))) } label: { Text("\(WatchFormat.insulin(units)) U") } } - Button { - navigator.push(.bolusAmount) - } label: { - Text("Other Amount") - } } - .navigationTitle("Bolus") + .navigationTitle("Presets") .alert( "Old Calculation Warning", isPresented: Binding(get: { staleRecommendation != nil }, set: { if !$0 { staleRecommendation = nil } }) ) { Button("Use Anyway") { - if let staleRecommendation { confirm(staleRecommendation) } + if let staleRecommendation { + navigator.push(.confirm(BolusAmountView.request(staleRecommendation.units, recommendation: staleRecommendation))) + } } Button("Cancel", role: .cancel) {} } message: { - Text("This recommended bolus was calculated more than 5 minutes ago. New treatments may have occurred since then. Proceed with caution.") + Text(Self.oldCalculationWarning) } } @ViewBuilder private var recommendation: some View { - if let rec = remote.recommendedBolus, rec > 0, let at = remote.recommendedBolusAt { - let age = Date().timeIntervalSince(at) - if age < recommendationMaxAge { - let stepped = BolusAmountView.roundedDown(min(rec, remote.maxBolus), step: step) - Section { - Button { - if age >= recommendationWarnAge { - staleRecommendation = stepped - } else { - confirm(stepped) - } - } label: { - VStack(alignment: .leading, spacing: 2) { - Text("Recommended \(WatchFormat.insulin(stepped)) U") - Text("Calculated \(Int(age / 60)) min ago") - .font(.caption2) - .foregroundStyle(age >= recommendationWarnAge ? .red : .secondary) - } + if let rec = Self.presetRecommendation(remote) { + Section { + Button { + staleRecommendation = rec + } label: { + VStack(alignment: .leading, spacing: 2) { + Text("Recommended \(WatchFormat.insulin(rec.units)) U") + Text("Calculated \(Int(rec.age / 60)) min ago") + .font(.caption2) + .foregroundStyle(.red) } - .disabled(stepped <= 0) } } } } - - private func confirm(_ units: Double) { - navigator.push(.confirm(BolusAmountView.request(units))) - } } -/// Any amount, set with the Digital Crown. +/// Any amount, set with the Digital Crown or βˆ’ and +, starting at the AID's +/// recommendation while it is under 5 minutes old. Until the amount is changed +/// by hand, a newer recommendation that is lower or the same replaces it, and a +/// higher one is offered with a tap. A starting recommendation past 12 minutes +/// is cleared at Continue. Presets are one tap away when the phone has any. struct BolusAmountView: View { let remote: WatchRemoteConfig + @EnvironmentObject private var session: WatchSession @EnvironmentObject private var navigator: WatchNavigator @State private var units = 0.0 + /// Seeded once, so coming back from the confirmation keeps the user's amount. + @State private var seeded = false + @State private var seededRecommendation: BolusRecommendation? + /// True until the amount is changed by hand. + @State private var followsRecommendation = true + /// A newer, higher recommendation, only applied when tapped. + @State private var higherRecommendation: BolusRecommendation? + /// Recommendations are followed only while this screen is on top; the + /// confirmation checks its own amount. + @State private var isVisible = false + @State private var showOldCalculationWarning = false + @State private var showExpiredRecommendation = false @FocusState private var amountFocused: Bool var body: some View { @@ -92,27 +133,141 @@ struct BolusAmountView: View { VStack(spacing: 8) { AmountField(value: $units, unit: "U", step: Self.step(remote), maximum: remote.maxBolus, isHighlighted: amountFocused, sensitivity: .low, format: WatchFormat.insulin) .focused($amountFocused) - Text("Max \(WatchFormat.insulin(remote.maxBolus)) U") - .font(.footnote) - .foregroundStyle(.secondary) + if let higher = higherRecommendation, higher.units != units { + Button { + seededRecommendation = higher + units = higher.units + followsRecommendation = true + higherRecommendation = nil + amountFocused = true + } label: { + Label("New \(WatchFormat.insulin(higher.units)) U, tap to use", systemImage: "wand.and.stars") + .font(.footnote) + } + .buttonStyle(.plain) + .foregroundStyle(.tint) + .accessibilityLabel("Use the new, higher recommendation, \(WatchFormat.insulin(higher.units)) U") + } else if let rec = seededRecommendation { + // Redrawn every few seconds so the age keeps counting while the screen stays open. + TimelineView(.periodic(from: .now, by: 10)) { context in + let age = context.date.timeIntervalSince(rec.at) + Label("\(WatchFormat.insulin(rec.units)) U, \(Int(age / 60)) min ago", systemImage: "wand.and.stars") + .font(.footnote) + .foregroundStyle(age >= BolusPresetsView.recommendationWarnAge ? .red : .secondary) + .accessibilityLabel("Recommended \(WatchFormat.insulin(rec.units)) U, \(Int(age / 60)) min ago") + } + } Button("Continue") { - navigator.push(.confirm(Self.request(units))) + if let age = startingRecommendationAge, age >= BolusPresetsView.recommendationMaxAge { + showExpiredRecommendation = true + } else if let age = startingRecommendationAge, age >= BolusPresetsView.recommendationWarnAge { + showOldCalculationWarning = true + } else { + continueToConfirm() + } } - .buttonStyle(.borderedProminent) + .buttonStyle(CompactButtonStyle(prominent: true)) .tint(.blue) .disabled(units <= 0) + // Redrawn so the button appears once the recommendation is old enough to be listed there. + TimelineView(.periodic(from: .now, by: 10)) { _ in + if BolusPresetsView.hasPresets(remote) { + Button("Presets") { + navigator.push(.bolusPresets) + } + .buttonStyle(CompactButtonStyle()) + } + } } } .navigationTitle("Bolus") - .onAppear { amountFocused = true } + .alert("Old Calculation Warning", isPresented: $showOldCalculationWarning) { + Button("Use Anyway") { + continueToConfirm() + } + Button("Cancel", role: .cancel) {} + } message: { + Text(BolusPresetsView.oldCalculationWarning) + } + .alert("Recommendation Expired", isPresented: $showExpiredRecommendation) { + Button("OK") { + seededRecommendation = nil + units = 0 + followsRecommendation = true + } + } message: { + Text("This recommended bolus was calculated more than 12 minutes ago. Set the amount again.") + } + .onAppear { + isVisible = true + amountFocused = true + guard !seeded else { + // Catches up on a cycle that came in while another screen was on top. + followNewerRecommendation() + return + } + seeded = true + // The phone also sends new loop cycles as they come; this covers one it has not sent yet. + session.requestState() + if let rec = BolusPresetsView.freshRecommendation(remote), rec.age < BolusPresetsView.recommendationWarnAge { + seededRecommendation = rec + units = rec.units + } + } + .onDisappear { isVisible = false } + .onChange(of: units) { _, newUnits in + if newUnits != (seededRecommendation?.units ?? 0) { followsRecommendation = false } + } + .onChange(of: session.state?.remote?.recommendedBolusAt) { _, _ in + followNewerRecommendation() + } + } + + /// An untouched amount moves down to a newer, lower recommendation; a + /// higher one, or the first fresh one when nothing was filled in, is only + /// offered, so insulin is never raised without a tap. + private func followNewerRecommendation() { + guard isVisible, followsRecommendation, + let latest = session.state?.remote.flatMap(BolusPresetsView.latestRecommendation), + latest != higherRecommendation + else { return } + if let seededRecommendation { + guard latest.at > seededRecommendation.at else { return } + } else { + guard latest.units > 0, latest.age < BolusPresetsView.recommendationWarnAge else { return } + } + let previousUnits = units + if latest.units > units { + higherRecommendation = latest + } else { + seededRecommendation = latest + units = latest.units + higherRecommendation = nil + } + if latest.units != previousUnits { + WKInterfaceDevice.current().play(.notification) + } + } + + /// Age of the starting recommendation while it is still the amount. A newer, + /// higher recommendation means the old one no longer risks too much insulin. + private var startingRecommendationAge: TimeInterval? { + guard let rec = seededRecommendation, units == rec.units, higherRecommendation == nil else { return nil } + return rec.age } static func step(_ remote: WatchRemoteConfig) -> Double { max(remote.bolusIncrement, 0.025) } - static func request(_ units: Double) -> CommandRequest { - CommandRequest(title: "Bolus", lines: ["\(WatchFormat.insulin(units)) U"], action: .bolus(units: units), deliversInsulin: true) + /// The recommendation goes along only while the amount is still the recommendation. + private func continueToConfirm() { + let basis = seededRecommendation.flatMap { $0.units == units ? $0 : nil } + navigator.push(.confirm(Self.request(units, recommendation: basis))) + } + + static func request(_ units: Double, recommendation: BolusRecommendation? = nil) -> CommandRequest { + CommandRequest(title: "Bolus", lines: ["\(WatchFormat.insulin(units)) U"], action: .bolus(units: units), deliversInsulin: true, recommendation: recommendation) } static func roundedDown(_ value: Double, step: Double) -> Double { @@ -174,6 +329,7 @@ struct AmountField: View { private func nudge(_ symbol: String, by delta: Double) -> some View { Button { steps.wrappedValue += delta + WKInterfaceDevice.current().play(.click) } label: { Image(systemName: symbol) .font(.footnote.weight(.semibold)) @@ -184,3 +340,27 @@ struct AmountField: View { .foregroundStyle(.tint) } } + +/// A full-width capsule button shorter than the system's, scaled to the screen, +/// so an amount and two buttons fit without scrolling on every watch size. +struct CompactButtonStyle: ButtonStyle { + var prominent = false + + @Environment(\.isEnabled) private var isEnabled + + /// About 34 pt on a 40 mm watch up to about 43 pt on the largest. + private static let height = min(max(WKInterfaceDevice.current().screenBounds.height * 0.17, 32), 44) + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .lineLimit(2) + .minimumScaleFactor(0.7) + .multilineTextAlignment(.center) + .foregroundStyle(prominent ? AnyShapeStyle(Color.white) : AnyShapeStyle(.primary)) + .padding(.horizontal, 12) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, minHeight: Self.height) + .background(Capsule().fill(prominent ? AnyShapeStyle(.tint) : AnyShapeStyle(Color.gray.opacity(0.3)))) + .opacity(isEnabled ? (configuration.isPressed ? 0.7 : 1) : 0.4) + } +} diff --git a/LoopFollowWatch/Remote/CommandConfirmView.swift b/LoopFollowWatch/Remote/CommandConfirmView.swift index d7e8cbbb7..c7b5c3038 100644 --- a/LoopFollowWatch/Remote/CommandConfirmView.swift +++ b/LoopFollowWatch/Remote/CommandConfirmView.swift @@ -7,10 +7,17 @@ import WatchKit /// Final step for every command: shows what will be sent, asks for confirmation /// and shows the phone's reply. Insulin needs a full turn of the Digital Crown -/// and an unlocked watch on the wrist. A sent command is not yet delivered: -/// Loop or Trio confirms it later with a notification. +/// and an unlocked watch on the wrist. A bolus taken from the recommendation +/// says so, and is checked against the latest one the watch has before +/// sending. A sent command is not yet delivered: Loop or Trio confirms it +/// later with a notification. struct CommandConfirmView: View { - let request: CommandRequest + /// Replaced when the user switches to a lowered recommendation. + @State private var request: CommandRequest + + init(request: CommandRequest) { + _request = State(initialValue: request) + } @EnvironmentObject private var session: WatchSession @EnvironmentObject private var navigator: WatchNavigator @@ -22,6 +29,8 @@ struct CommandConfirmView: View { @State private var phase: Phase = .confirm @State private var crownProgress = 0.0 @State private var lastCrownChange = Date() + @State private var lowerRecommendation: BolusRecommendation? + @State private var showLowerRecommendation = false @FocusState private var crownFocused: Bool enum Phase: Equatable { @@ -73,6 +82,23 @@ struct CommandConfirmView: View { } .navigationTitle(request.title) .navigationBarBackButtonHidden(phase != .confirm) + .onAppear { + if request.recommendation != nil { session.requestState() } + } + .alert("Recommendation Lowered", isPresented: $showLowerRecommendation, presenting: lowerRecommendation) { latest in + if latest.units > 0 { + // A new amount needs its own turn of the crown. + Button("Change to \(WatchFormat.insulin(latest.units)) U") { + request = BolusAmountView.request(latest.units, recommendation: latest) + } + } + Button("Send Anyway") { + Task { await authenticateAndSend() } + } + Button("Cancel", role: .cancel) {} + } message: { latest in + Text("The recommendation is now \(WatchFormat.insulin(latest.units)) U, down from \(WatchFormat.insulin(request.recommendation?.units ?? 0)) U. New treatments may have occurred.") + } } private var summary: some View { @@ -82,6 +108,14 @@ struct CommandConfirmView: View { .font(.headline) .multilineTextAlignment(.center) } + if let rec = request.recommendation { + TimelineView(.periodic(from: .now, by: 10)) { context in + let age = context.date.timeIntervalSince(rec.at) + Label("Recommended, \(Int(age / 60)) min ago", systemImage: "wand.and.stars") + .font(.footnote) + .foregroundStyle(age >= BolusPresetsView.recommendationWarnAge ? .red : .secondary) + } + } } } @@ -111,7 +145,7 @@ struct CommandConfirmView: View { lastCrownChange = Date() if value >= 1 { WKInterfaceDevice.current().play(.success) - Task { await authenticateAndSend() } + Task { await checkRecommendationThenSend() } } else { resetCrownIfIdle() } @@ -128,6 +162,24 @@ struct CommandConfirmView: View { } } + /// Stops before sending when a newer loop cycle lowered the recommendation + /// this amount came from. The screens ask the phone for fresh state when + /// they open, so this uses what the watch already has and never waits. + private func checkRecommendationThenSend() async { + if let basis = request.recommendation { + let latest = session.state?.remote.flatMap(BolusPresetsView.latestRecommendation) + if let latest, latest.at > basis.at, latest.units < basis.units { + crownProgress = 0 + phase = .confirm + lowerRecommendation = latest + showLowerRecommendation = true + WKInterfaceDevice.current().play(.notification) + return + } + } + await authenticateAndSend() + } + private func authenticateAndSend() async { switch await Self.authenticate() { case .success: diff --git a/LoopFollowWatch/Remote/MealEntryView.swift b/LoopFollowWatch/Remote/MealEntryView.swift index 504e06d0a..3be27fdd7 100644 --- a/LoopFollowWatch/Remote/MealEntryView.swift +++ b/LoopFollowWatch/Remote/MealEntryView.swift @@ -57,18 +57,16 @@ struct MealEntryView: View { } label: { Label("Time", systemImage: "clock") .font(.footnote) - .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity) + .buttonStyle(CompactButtonStyle()) } Button { navigator.push(.confirm(request(carbs: carbs, fat: fat, protein: protein, bolus: bolus, offsetMinutes: offsetMinutes))) } label: { Text(logTitle) - .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) + .buttonStyle(CompactButtonStyle(prominent: true)) .tint(.blue) .disabled(carbs <= 0 && fat <= 0 && protein <= 0) } @@ -89,9 +87,8 @@ struct MealEntryView: View { navigator.push(.confirm(request(carbs: pick.carbs, fat: fat, protein: protein, bolus: bolus, offsetMinutes: 0))) } label: { Text(pickTitle(carbs: pick.carbs, fat: fat, protein: protein, bolus: bolus)) - .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity) + .buttonStyle(CompactButtonStyle()) } } } diff --git a/LoopFollowWatch/WatchSession.swift b/LoopFollowWatch/WatchSession.swift index c6101921b..2a0c8892c 100644 --- a/LoopFollowWatch/WatchSession.swift +++ b/LoopFollowWatch/WatchSession.swift @@ -85,11 +85,23 @@ final class WatchSession: NSObject, ObservableObject { } } + private nonisolated static let lastInsulinSentKey = "lastInsulinSentAt" + + /// When insulin was last sent from the watch, through the phone or directly. + /// Set before sending, since a command without a clear reply may still have + /// gone through. + nonisolated static var lastInsulinSentAt: Date? { + UserDefaults.standard.object(forKey: lastInsulinSentKey) as? Date + } + /// Sends a command through the phone when it is reachable, otherwise directly. /// Falls back to sending directly only when the phone turned out to be out of /// reach before the command was delivered; a command that may have reached /// the phone is never sent a second time. func send(_ action: WatchCommand.Action) async -> WatchCommandReply { + if action.deliversInsulin { + UserDefaults.standard.set(Date(), forKey: Self.lastInsulinSentKey) + } if session.activationState == .activated, session.isReachable { switch await sendThroughPhone(action) { case let .replied(reply): return reply From 31e6a51b7ab53dd29eaf21f4ce88a55e4d8dc6ae Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 31 Oct 2026 15:57:36 +0000 Subject: [PATCH 73/84] CI: Bump dev version to 7.1.29 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 036883e9c..8639b2110 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.28 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.29 From 33d6e56aa4a9e85a1383d9a113b76791b1c45fec Mon Sep 17 00:00:00 2001 From: Phil A <76601115+MtlPhil@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:39:43 -0500 Subject: [PATCH 74/84] Show remote command results sooner on the phone, watch and Live Activity (#805) * Don't send the watch a state that hasn't changed Every Nightscout fetch rebuilds the chart, and every rebuild sent the full 24-hour state to the watch, even when nothing in it had changed. Each send can wake the watch app. The phone now skips a state equal to the last one sent, apart from its build time. It still sends at least every 5 minutes, well inside the 10 minutes after which the watch treats the phone's data as stale and fetches on its own. It always sends after the session activates or the watch changes (new install or pairing), and retries after a failed send. * Fetch from the watch after a command it sends without the phone When the phone is out of reach, the watch sends commands directly to Loop or Trio. Nothing then fetched the result: the watch only fetches on its own once the phone's last state is more than 10 minutes old, so a command sent soon after losing the phone could take up to 10 minutes to show. After a successful direct command, the watch now fetches 10, 20, 40 and 60 seconds after the send, even while the phone's last state is still fresh, and stops at the first fetch that shows the change: the preset's override or temp target, its cancellation, or a new bolus or carb entry with the sent amount. A check that never matches only means all four fetches run. The follow-up stops as soon as a newer state arrives from the phone, so a fetch that was already under way does not replace it. While the phone's last state is still fresh, its alarm stays shown, since the watch's own fetch has no alarms. The watch's own fetch now reads the override name from "notes" before "reason", as the phone does, so Trio overrides show the same name on both. * Fetch Nightscout early on the phone after a remote command Loop and Trio apply a command within seconds, but the phone only saw the result on its next regular fetch: up to 2 minutes for treatments (overrides, temp targets, boluses, carbs) and about 5 minutes for device status (IOB, recommended bolus). The phone, the watch and the Live Activity showed the old state until then. After a command is sent from the phone or the watch, the phone now fetches treatments and device status 10, 20, 30, 60, 90 and 120 seconds later. It stops as soon as the command's change is visible: the active override or temp target differs from before the send, or a new bolus or carb entry with the sent amount appears. A Trio meal sent with a bolus waits for both. A newer command restarts the schedule. Each fetch schedules the next, so when the app was suspended past several of them, as in the Bluetooth heartbeat modes, it fetches once on resuming instead of all the overdue ones at once. * Don't offer the watch a recommendation from before a manual bolus When the watch fetches on its own, it took the recommended bolus from the latest device status as is. If a manual bolus came after that loop cycle, the recommendation leaves that bolus out, and the watch could start the bolus screen at it. The phone already drops such a recommendation, and the watch only dropped one from before insulin it sent itself. The watch's own fetch now drops it the same way as the phone: when a manual bolus (not automatic, not an SMB) is at or after the loop cycle. This matters more now that the watch also fetches right after a command it sends without the phone, while the phone's last state is still fresh. --- .../Remote/LoopAPNS/LoopAPNSService.swift | 8 +- LoopFollow/Remote/RemoteCommandRefresh.swift | 136 ++++++++++++++++++ .../Remote/TRC/PushNotificationManager.swift | 15 +- LoopFollow/Watch/WatchSessionManager.swift | 36 ++++- LoopFollowWatch/StandaloneFetcher.swift | 34 ++++- LoopFollowWatch/WatchSession.swift | 81 ++++++++++- 6 files changed, 292 insertions(+), 18 deletions(-) create mode 100644 LoopFollow/Remote/RemoteCommandRefresh.swift diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift index 2938affba..e5a641cca 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -177,7 +177,7 @@ class LoopAPNSService { finalPayload["encrypted_return_notification"] = encryptedReturnInfo } LogManager.shared.log(category: .apns, message: "Sending carbs: \(String(format: "%.1f", payload.carbsAmount ?? 0.0))g, absorption: \(String(format: "%.1f", payload.absorptionTime ?? 3.0))h") - sendRemoteCommand(finalPayload, completion: completion) + sendRemoteCommand(finalPayload, completion: RemoteCommandRefresh.following(.meal(grams: payload.carbsAmount ?? 0, bolus: 0), completion)) } /// Sends bolus via APNS push notification @@ -190,7 +190,7 @@ class LoopAPNSService { finalPayload["encrypted_return_notification"] = encryptedReturnInfo } LogManager.shared.log(category: .apns, message: "Sending bolus: \(String(format: "%.2f", payload.bolusAmount ?? 0.0))U") - sendRemoteCommand(finalPayload, completion: completion) + sendRemoteCommand(finalPayload, completion: RemoteCommandRefresh.following(.bolus(units: payload.bolusAmount ?? 0), completion)) } /// Validates APNS credentials similar to PushNotificationManager @@ -479,7 +479,7 @@ class LoopAPNSService { apnsKey: creds.apnsKey, teamId: creds.teamId, payload: payload, - completion: completion + completion: RemoteCommandRefresh.following(.override, completion) ) } @@ -511,7 +511,7 @@ class LoopAPNSService { apnsKey: creds.apnsKey, teamId: creds.teamId, payload: payload, - completion: completion + completion: RemoteCommandRefresh.following(.override, completion) ) } } diff --git a/LoopFollow/Remote/RemoteCommandRefresh.swift b/LoopFollow/Remote/RemoteCommandRefresh.swift new file mode 100644 index 000000000..f1773f86d --- /dev/null +++ b/LoopFollow/Remote/RemoteCommandRefresh.swift @@ -0,0 +1,136 @@ +// LoopFollow +// RemoteCommandRefresh.swift + +import Foundation +import HealthKit + +/// Loop and Trio apply a remote command within seconds, but LoopFollow only sees +/// the result on its next regular Nightscout fetch, which can be minutes away. +/// After a command is sent, treatments and device status are fetched a few extra +/// times, stopping as soon as the change the command makes shows up, so the new +/// override, temp target, bolus or carbs reach the phone, the watch and the Live +/// Activity sooner. +final class RemoteCommandRefresh { + /// What a command changes, and so what ends the follow-up fetches. + enum Change { + case override + case tempTarget + case bolus(units: Double) + /// A bolus sent with the meal (Trio) also has to show up; 0 g of carbs + /// (fat and protein only) matches any new carb entry. + case meal(grams: Double, bolus: Double) + + var name: String { + switch self { + case .override: "override" + case .tempTarget: "temp target" + case .bolus: "bolus" + case .meal: "meal" + } + } + } + + static let shared = RemoteCommandRefresh() + + /// Seconds after the send. Most uploads land in the first half minute; the + /// later fetches cover a pump that is slow to deliver or an upload that is retried. + static let delays: [TimeInterval] = [10, 20, 30, 60, 90, 120] + + private var pending: DispatchWorkItem? + + private init() {} + + /// Wraps a send's completion so that a successful send starts the follow-up fetches. + static func following(_ change: Change, _ completion: @escaping (Bool, String?) -> Void) -> (Bool, String?) -> Void { + { success, error in + if success { shared.commandSent(change) } + completion(success, error) + } + } + + /// Restarts the follow-up fetches; a newer command replaces the remaining ones. + func commandSent(_ change: Change) { + DispatchQueue.main.async { + self.cancel() + LogManager.shared.log(category: .apns, message: "Fetching Nightscout early until the \(change.name) shows up") + self.schedule(step: 0, after: Self.delays[0], arrived: Self.arrivalCheck(for: change), change: change) + } + } + + /// Each fetch schedules the next one, so when the app was suspended past + /// several of them (Bluetooth heartbeat modes), it fetches once on resuming + /// instead of all the overdue ones at once. + private func schedule(step: Int, after delay: TimeInterval, arrived: @escaping () -> Bool, change: Change) { + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + if arrived() { + LogManager.shared.log(category: .apns, message: "The \(change.name) showed up; \(Self.delays.count - step) early fetches skipped") + self.pending = nil + return + } + TaskScheduler.shared.rescheduleTask(id: .treatments, to: Date()) + TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: Date()) + let next = step + 1 + guard next < Self.delays.count else { + self.pending = nil + return + } + self.schedule(step: next, after: Self.delays[next] - Self.delays[step], arrived: arrived, change: change) + } + pending = work + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + } + + private func cancel() { + pending?.cancel() + pending = nil + } + + /// Captures the phone's data now and returns a check that is true once the + /// command's change is in it. A change that never matches (a partial bolus, + /// a renamed preset) only means the fetches run their full two minutes. + private static func arrivalCheck(for change: Change) -> () -> Bool { + switch change { + case .override: + let before = (Observable.shared.override.value, Observable.shared.overrideEndAt.value) + return { (Observable.shared.override.value, Observable.shared.overrideEndAt.value) != before } + case .tempTarget: + // Compared as numbers: each treatments fetch creates a new HKQuantity. + func current() -> (Double?, TimeInterval?) { + (Observable.shared.tempTarget.value?.doubleValue(for: .milligramsPerDeciliter), Observable.shared.tempTargetEndAt.value) + } + let before = current() + return { current() != before } + case let .bolus(units): + return newEntry(in: boluses, matching: units, tolerance: 0.01) + case let .meal(grams, bolus): + let carbsArrived = newEntry(in: carbs, matching: grams > 0 ? grams : nil, tolerance: 0.5) + guard bolus > 0 else { return carbsArrived } + let bolusArrived = newEntry(in: boluses, matching: bolus, tolerance: 0.01) + return { carbsArrived() && bolusArrived() } + } + } + + /// True once an entry that was not there at the send appears, with `value` when given. + private static func newEntry( + in entries: @escaping () -> [(date: TimeInterval, value: Double)], + matching value: Double?, + tolerance: Double + ) -> () -> Bool { + let known = Set(entries().map { $0.date }) + return { + entries().contains { entry in + !known.contains(entry.date) && value.map { abs(entry.value - $0) < tolerance } ?? true + } + } + } + + private static func boluses() -> [(date: TimeInterval, value: Double)] { + let vc = MainViewController.shared + return ((vc?.bolusData ?? []) + (vc?.smbData ?? [])).map { (date: $0.date, value: $0.value) } + } + + private static func carbs() -> [(date: TimeInterval, value: Double)] { + (MainViewController.shared?.carbData ?? []).map { (date: $0.date, value: $0.value) } + } +} diff --git a/LoopFollow/Remote/TRC/PushNotificationManager.swift b/LoopFollow/Remote/TRC/PushNotificationManager.swift index 7a6513a15..532d9bf27 100644 --- a/LoopFollow/Remote/TRC/PushNotificationManager.swift +++ b/LoopFollow/Remote/TRC/PushNotificationManager.swift @@ -40,7 +40,7 @@ class PushNotificationManager { overrideName: override.name, returnNotification: ReturnNotificationInfo.loopFollow() ) - sendEncryptedCommand(payload: payload, completion: completion) + sendEncryptedCommand(payload: payload, completion: RemoteCommandRefresh.following(.override, completion)) } func sendCancelOverridePushNotification(completion: @escaping (Bool, String?) -> Void) { @@ -51,7 +51,7 @@ class PushNotificationManager { overrideName: nil, returnNotification: ReturnNotificationInfo.loopFollow() ) - sendEncryptedCommand(payload: payload, completion: completion) + sendEncryptedCommand(payload: payload, completion: RemoteCommandRefresh.following(.override, completion)) } func sendBolusPushNotification(bolusAmount: HKQuantity, completion: @escaping (Bool, String?) -> Void) { @@ -63,7 +63,7 @@ class PushNotificationManager { bolusAmount: bolusAmountDecimal, returnNotification: ReturnNotificationInfo.loopFollow() ) - sendEncryptedCommand(payload: payload, completion: completion) + sendEncryptedCommand(payload: payload, completion: RemoteCommandRefresh.following(.bolus(units: bolusAmount.doubleValue(for: .internationalUnit())), completion)) } func sendTempTargetPushNotification(target: HKQuantity, duration: HKQuantity, completion: @escaping (Bool, String?) -> Void) { @@ -77,7 +77,7 @@ class PushNotificationManager { duration: durationValue, returnNotification: ReturnNotificationInfo.loopFollow() ) - sendEncryptedCommand(payload: payload, completion: completion) + sendEncryptedCommand(payload: payload, completion: RemoteCommandRefresh.following(.tempTarget, completion)) } func sendCancelTempTargetPushNotification(completion: @escaping (Bool, String?) -> Void) { @@ -87,7 +87,7 @@ class PushNotificationManager { timestamp: Date().timeIntervalSince1970, returnNotification: ReturnNotificationInfo.loopFollow() ) - sendEncryptedCommand(payload: payload, completion: completion) + sendEncryptedCommand(payload: payload, completion: RemoteCommandRefresh.following(.tempTarget, completion)) } func sendMealPushNotification( @@ -127,7 +127,10 @@ class PushNotificationManager { scheduledTime: scheduledTimeInterval, returnNotification: ReturnNotificationInfo.loopFollow() ) - sendEncryptedCommand(payload: payload, completion: completion) + sendEncryptedCommand(payload: payload, completion: RemoteCommandRefresh.following( + .meal(grams: Double(carbsValue ?? 0), bolus: bolusAmount.doubleValue(for: .internationalUnit())), + completion + )) } /// Trio confirms edit/delete through a return notification, so LoopFollow's own APNs credentials are required. diff --git a/LoopFollow/Watch/WatchSessionManager.swift b/LoopFollow/Watch/WatchSessionManager.swift index 08a9aacb1..041169524 100644 --- a/LoopFollow/Watch/WatchSessionManager.swift +++ b/LoopFollow/Watch/WatchSessionManager.swift @@ -15,7 +15,12 @@ final class WatchSessionManager: NSObject { /// are only used when a new reading arrives and this long has passed. private let complicationTransferInterval: TimeInterval = 15 * 60 + /// A state equal to the last one is still sent this often, well inside the + /// watch's 10 minutes before it treats the phone's data as stale. + private let unchangedResendInterval: TimeInterval = 5 * 60 + private var lastSentState: WatchState? + private var lastContextUpdate: Date = .distantPast private var lastSentCredentials: WatchCredentials? private var lastComplicationTransfer: Date = .distantPast private var cancellables = Set() @@ -121,11 +126,21 @@ final class WatchSessionManager: NSObject { private func publish(reason: String) { guard let session, session.activationState == .activated else { return } let state = WatchStateBuilder.build() + // Most refreshes rebuild the chart without changing anything the watch shows; + // sending those would only wake the watch. + if let lastSentState, Self.sameContent(state, lastSentState), + Date().timeIntervalSince(lastContextUpdate) < unchangedResendInterval + { + LogManager.shared.log(category: .general, message: "[Watch] state unchanged, not sent reason=\(reason)", isDebug: true) + return + } guard let data = state.encoded() else { return } do { try session.updateApplicationContext([WatchState.contextKey: data]) + lastContextUpdate = Date() } catch { + lastContextUpdate = .distantPast LogManager.shared.log(category: .general, message: "[Watch] context update failed: \(error.localizedDescription)") } @@ -142,6 +157,14 @@ final class WatchSessionManager: NSObject { lastSentState = state LogManager.shared.log(category: .general, message: "[Watch] state sent (\(data.count) bytes) reason=\(reason)", isDebug: true) } + + /// Equal apart from when the two states were built. + private static func sameContent(_ lhs: WatchState, _ rhs: WatchState) -> Bool { + var lhs = lhs + lhs.builtAt = rhs.builtAt + lhs.phoneBuiltAt = rhs.phoneBuiltAt + return lhs == rhs + } } // MARK: - WCSessionDelegate @@ -153,7 +176,7 @@ extension WatchSessionManager: WCSessionDelegate { return } if state == .activated { - requestUpdate(reason: "activated") + resendFullState(reason: "activated") DispatchQueue.main.async { self.sendCredentialsIfChanged(force: false) } } } @@ -166,7 +189,16 @@ extension WatchSessionManager: WCSessionDelegate { } func sessionWatchStateDidChange(_: WCSession) { - requestUpdate(reason: "watchStateChanged") + resendFullState(reason: "watchStateChanged") + } + + /// A newly activated session or a newly installed or paired watch may not have + /// the last state, so the next one is sent even if it has not changed. + private func resendFullState(reason: String) { + DispatchQueue.main.async { + self.lastContextUpdate = .distantPast + self.requestUpdate(reason: reason) + } } func session(_: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { diff --git a/LoopFollowWatch/StandaloneFetcher.swift b/LoopFollowWatch/StandaloneFetcher.swift index ea13ad588..0617371c0 100644 --- a/LoopFollowWatch/StandaloneFetcher.swift +++ b/LoopFollowWatch/StandaloneFetcher.swift @@ -98,6 +98,15 @@ enum StandaloneFetcher { let latest = glucose.last let previous = glucose.dropLast().last + // As on the phone (RecommendedBolus), a recommendation from a loop cycle + // that ran before the latest manual bolus leaves that bolus out, and + // offering it could double it. Insulin sent from the watch is left out + // on the bolus screen. + var recommendedBolus = status?.recommendedBolus + if let loopAt = status?.loopAt, let lastBolus = lastManualBolus(treatments), lastBolus >= loopAt { + recommendedBolus = nil + } + let active = activeAdjustments(treatments, now: now) var snapshot: GlucoseSnapshot? if let latest { @@ -112,7 +121,7 @@ enum StandaloneFetcher { projected: nil, override: active.override, tempTargetMgdl: active.tempTargetMgdl, - recBolus: status?.recommendedBolus, + recBolus: recommendedBolus, unit: base?.snapshot?.unit ?? .mgdl, isNotLooping: loopAge.map { $0 >= 15 * 60 } ?? false ) @@ -120,7 +129,7 @@ enum StandaloneFetcher { var remote = base?.remote if remote != nil { - remote?.recommendedBolus = status?.recommendedBolus + remote?.recommendedBolus = recommendedBolus remote?.recommendedBolusAt = status?.loopAt } @@ -153,6 +162,24 @@ enum StandaloneFetcher { return WatchReading(t: at.timeIntervalSince1970, v: value) } + /// The latest manual bolus, classified as the phone does (Treatments.swift): + /// automatic boluses and SMBs are left out, since the cycle that recommends + /// a bolus can also give one. + private static func lastManualBolus(_ treatments: [[String: Any]]) -> Date? { + treatments.compactMap { entry -> Date? in + guard let insulin = entry["insulin"] as? Double, insulin > 0 else { return nil } + switch entry["eventType"] as? String { + case "Correction Bolus", "Bolus", "External Insulin": + if entry["automatic"] as? Bool == true { return nil } + case "Meal Bolus": + break + default: + return nil + } + return DeviceStatusSummary.date(entry["timestamp"] ?? entry["created_at"]) + }.max() + } + /// The override and temp target that are running now, from the latest of each in the treatments. private static func activeAdjustments(_ treatments: [[String: Any]], now: Date) -> (override: String?, tempTargetMgdl: Double?) { func latest(_ eventType: String) -> (start: Date, entry: [String: Any])? { @@ -167,7 +194,8 @@ enum StandaloneFetcher { let minutes = entry["duration"] as? Double ?? 0 let indefinite = minutes == 0 || (entry["durationType"] as? String) == "indefinite" if start <= now, indefinite || now < start.addingTimeInterval(minutes * 60) { - override = entry["reason"] as? String ?? entry["notes"] as? String ?? "Override" + // Same order as the phone: Trio keeps the name in "notes", Loop in "reason". + override = entry["notes"] as? String ?? entry["reason"] as? String ?? "Override" } } diff --git a/LoopFollowWatch/WatchSession.swift b/LoopFollowWatch/WatchSession.swift index 2a0c8892c..1c7d97eac 100644 --- a/LoopFollowWatch/WatchSession.swift +++ b/LoopFollowWatch/WatchSession.swift @@ -18,6 +18,9 @@ final class WatchSession: NSObject, ObservableObject { static let phoneStaleAfter: TimeInterval = 10 * 60 /// Minimum time between two fetches made by the watch itself. private static let standaloneInterval: TimeInterval = 60 + /// After a command sent without the phone, the watch fetches this many seconds + /// after the send instead of waiting for its regular minute, until the change shows up. + private static let directFollowUpDelays: [TimeInterval] = [10, 20, 40, 60] @Published private(set) var state: WatchState? @Published private(set) var isReachable = false @@ -26,6 +29,7 @@ final class WatchSession: NSObject, ObservableObject { private var credentials: WatchCredentials? private var lastPhoneBuiltAt: Date private var lastStandaloneAttempt: Date = .distantPast + private var directFollowUp: Task? override private init() { let stored = WatchStateStore.load() @@ -74,14 +78,19 @@ final class WatchSession: NSObject, ObservableObject { return } guard Date().timeIntervalSince(lastStandaloneAttempt) >= Self.standaloneInterval else { return } + guard let fetched = await fetchStandalone(credentials), !isPhoneDataFresh else { return } + store(fetched) + } + + private func fetchStandalone(_ credentials: WatchCredentials) async -> WatchState? { lastStandaloneAttempt = Date() do { let fetched = try await StandaloneFetcher.fetch(credentials: credentials, base: state) - guard !isPhoneDataFresh else { return } - store(fetched) LogManager.shared.log(category: .nightscout, message: "Standalone fetch: \(fetched.readings.count) readings") + return fetched } catch { LogManager.shared.log(category: .nightscout, message: "Standalone fetch failed: \(error.localizedDescription)") + return nil } } @@ -114,7 +123,73 @@ final class WatchSession: NSObject, ObservableObject { guard let credentials, let config = state?.remote else { return WatchCommandReply(success: false, message: "iPhone not reachable. Open LoopFollow on the iPhone and keep it near the watch.") } - return await DirectCommandSender.send(action, credentials: credentials, config: config) + let reply = await DirectCommandSender.send(action, credentials: credentials, config: config) + if reply.success { + followUpDirectCommand(action, credentials: credentials, config: config) + } + return reply + } + + /// The phone did not send this command, so it does not fetch early for it. + /// The watch fetches a few times itself, even while the phone's last state is + /// still fresh, and stops at the first fetch that shows the change. + private func followUpDirectCommand(_ action: WatchCommand.Action, credentials: WatchCredentials, config: WatchRemoteConfig) { + guard credentials.hasDataSource, + let arrived = Self.arrivalCheck(for: action, config: config, sentAt: Date()) + else { return } + let phoneBuiltAtSend = lastPhoneBuiltAt + directFollowUp?.cancel() + directFollowUp = Task { + var elapsed: TimeInterval = 0 + for delay in Self.directFollowUpDelays { + try? await Task.sleep(nanoseconds: UInt64((delay - elapsed) * 1_000_000_000)) + elapsed = delay + guard !Task.isCancelled else { return } + guard var fetched = await fetchStandalone(credentials) else { continue } + // Once the phone is back, its states take over again; a fetch started + // before one arrived would replace it with the watch's own. + guard !Task.isCancelled, lastPhoneBuiltAt == phoneBuiltAtSend else { return } + // The watch's own fetch has no alarms. While the phone's last state is + // still fresh, its alarm stays shown, as it would without this fetch. + if isPhoneDataFresh { + fetched.alarm = state?.alarm + } + store(fetched) + if arrived(fetched) { + LogManager.shared.log(category: .nightscout, message: "Direct \(action.name) showed up after \(Int(delay)) s") + return + } + } + } + } + + /// True once a fetched state shows what the command changes. A check that never + /// matches (a partial bolus, a renamed preset) only means every follow-up fetch runs. + private static func arrivalCheck(for action: WatchCommand.Action, config: WatchRemoteConfig, sentAt: Date) -> ((WatchState) -> Bool)? { + let sent = sentAt.timeIntervalSince1970 + switch action { + case let .startOverride(presetID): + let name = config.overridePresets.first { $0.id == presetID }?.name ?? presetID + return { $0.snapshot?.override?.contains(name) == true } + case .cancelOverride: + return { $0.snapshot.map { $0.override == nil } ?? false } + case let .startTempTarget(presetID): + let target = config.tempTargetPresets.first { $0.id == presetID }?.targetMgdl + return { state in + guard let current = state.snapshot?.tempTargetMgdl else { return false } + return target.map { abs(current - $0) < 1 } ?? true + } + case .cancelTempTarget: + return { $0.snapshot.map { $0.tempTargetMgdl == nil } ?? false } + case let .bolus(units): + return { $0.boluses.contains { $0.t >= sent - 60 && abs($0.v - units) < 0.01 } } + case let .meal(carbs, _, _, _, consumedAt): + guard carbs > 0 else { return { _ in false } } + let at = consumedAt.timeIntervalSince1970 + return { $0.carbs.contains { abs($0.t - at) < 5 * 60 && abs($0.v - carbs) < 0.5 } } + case .snoozeAlarm: + return nil + } } private enum PhoneSendResult { From e8c515730c6f6ccefd201b03faf43b92ccc6b9ad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Nov 2026 09:39:53 +0000 Subject: [PATCH 75/84] CI: Bump dev version to 7.1.30 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 8639b2110..9f59e0031 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.29 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.30 From 008fb8f7d53e66bf4093c19d97d5b6d3566c9150 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 1 Nov 2026 19:27:11 +0100 Subject: [PATCH 76/84] Use current scheduled basal after the last temp basal ends (#799) When no temp basal is running, the Basal info value is the scheduled rate at the current time, and the basal graph from the end of the last temp basal follows the schedule in the profile time zone, stepping at each schedule entry. --- .../Nightscout/ProfileManager.swift | 39 +++++++++- .../Nightscout/Treatments/Basals.swift | 29 ++++---- Tests/ScheduledBasalSegmentsTests.swift | 73 +++++++++++++++++++ 3 files changed, 125 insertions(+), 16 deletions(-) create mode 100644 Tests/ScheduledBasalSegmentsTests.swift diff --git a/LoopFollow/Controllers/Nightscout/ProfileManager.swift b/LoopFollow/Controllers/Nightscout/ProfileManager.swift index 57010f961..10f5087c1 100644 --- a/LoopFollow/Controllers/Nightscout/ProfileManager.swift +++ b/LoopFollow/Controllers/Nightscout/ProfileManager.swift @@ -152,11 +152,48 @@ final class ProfileManager { } func basal(at date: Date) -> String? { - value(from: basalSchedule, at: date).map { + basalRate(at: date).map { Localizer.formatToLocalizedString($0, maxFractionDigits: 2, minFractionDigits: 0) } } + func basalRate(at date: Date) -> Double? { + value(from: basalSchedule, at: date) + } + + /// Scheduled basal from `start` to `end`, one segment per schedule entry crossed. + func scheduledBasalSegments(from start: Date, to end: Date) -> [(start: Date, rate: Double)] { + guard start < end, let firstRate = basalRate(at: start) else { return [] } + + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timezone + + var boundaries: [Date] = [] + var day = calendar.startOfDay(for: start) + while day < end { + for entry in basalSchedule { + guard let boundary = calendar.date(bySettingHour: entry.timeAsSeconds / 3600, + minute: entry.timeAsSeconds % 3600 / 60, + second: entry.timeAsSeconds % 60, + of: day) + else { continue } + if boundary > start, boundary < end { + boundaries.append(boundary) + } + } + guard let nextDay = calendar.date(byAdding: .day, value: 1, to: day) else { break } + day = nextDay + } + + var segments = [(start: start, rate: firstRate)] + for boundary in boundaries.sorted() { + if let rate = basalRate(at: boundary), rate != segments.last?.rate { + segments.append((start: boundary, rate: rate)) + } + } + return segments + } + func carbRatio(at date: Date) -> Double? { value(from: carbRatioSchedule, at: date) } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift index 405281926..0359d0336 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift @@ -114,27 +114,26 @@ extension MainViewController { lastEndDot = lastDot } - // If last basal was prior to right now, we need to create one last scheduled entry + // If last basal was prior to right now, fill up to now with the scheduled basal if lastEndDot <= dateTimeUtils.getNowTimeIntervalUTC() { - var scheduled = 0.0 - for b in 0 ..< basalProfile.count { - let scheduleTimeToday = basalProfile[b].timeAsSeconds - + dateTimeUtils.getTimeIntervalMidnightToday() - if lastEndDot >= scheduleTimeToday { - scheduled = basalProfile[b].value - } - } + let now = Date() + let tailStart = Date(timeIntervalSince1970: max(lastEndDot, dateTimeUtils.getTimeIntervalNHoursAgo(N: 24 * Storage.shared.downloadDays.value))) + let tailEnd = now.addingTimeInterval(60 * 10) - latestBasal = Localizer.formatToLocalizedString(scheduled, + latestBasal = Localizer.formatToLocalizedString(profileManager.basalRate(at: now) ?? 0.0, maxFractionDigits: 2, minFractionDigits: 0) - let startDot = basalGraphStruct(basalRate: scheduled, date: lastEndDot) - basalData.append(startDot) + var segments = profileManager.scheduledBasalSegments(from: tailStart, to: tailEnd) + if segments.isEmpty { + segments = [(start: tailStart, rate: 0.0)] + } - let endDot = basalGraphStruct(basalRate: scheduled, - date: Date().timeIntervalSince1970 + (60 * 10)) - basalData.append(endDot) + for (index, segment) in segments.enumerated() { + let segmentEnd = index < segments.count - 1 ? segments[index + 1].start : tailEnd + basalData.append(basalGraphStruct(basalRate: segment.rate, date: segment.start.timeIntervalSince1970)) + basalData.append(basalGraphStruct(basalRate: segment.rate, date: segmentEnd.timeIntervalSince1970)) + } } if Storage.shared.graphBasal.value { diff --git a/Tests/ScheduledBasalSegmentsTests.swift b/Tests/ScheduledBasalSegmentsTests.swift new file mode 100644 index 000000000..2d70c8029 --- /dev/null +++ b/Tests/ScheduledBasalSegmentsTests.swift @@ -0,0 +1,73 @@ +// LoopFollow +// ScheduledBasalSegmentsTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@Suite(.serialized) +struct ScheduledBasalSegmentsTests { + private let schedule: [ProfileManager.TimeValue] = [ + .init(timeAsSeconds: 0, value: 0.2), + .init(timeAsSeconds: 4 * 3600, value: 0.2), + .init(timeAsSeconds: 6 * 3600 + 1800, value: 0.6), + .init(timeAsSeconds: 14 * 3600 + 1800, value: 0.65), + .init(timeAsSeconds: 16 * 3600 + 1800, value: 0.5), + .init(timeAsSeconds: 20 * 3600 + 1800, value: 0.25), + ] + + private func withSchedule(_ body: (ProfileManager) -> Void) { + let manager = ProfileManager.shared + let savedSchedule = manager.basalSchedule + let savedTimeZone = manager.timezone + defer { + manager.basalSchedule = savedSchedule + manager.timezone = savedTimeZone + } + manager.basalSchedule = schedule + manager.timezone = TimeZone(secondsFromGMT: 3 * 3600)! + body(manager) + } + + private func utc(_ string: String) -> Date { + ISO8601DateFormatter().date(from: string)! + } + + @Test("splits at a schedule entry between start and end") + func splitsAtScheduleEntry() { + withSchedule { manager in + let segments = manager.scheduledBasalSegments(from: utc("2026-10-28T13:27:55Z"), to: utc("2026-10-28T13:48:00Z")) + #expect(segments.map(\.rate) == [0.65, 0.5]) + #expect(segments.map(\.start) == [utc("2026-10-28T13:27:55Z"), utc("2026-10-28T13:30:00Z")]) + #expect(manager.basalRate(at: utc("2026-10-28T13:38:00Z")) == 0.5) + } + } + + @Test("splits at midnight in the profile time zone") + func splitsAtMidnight() { + withSchedule { manager in + let segments = manager.scheduledBasalSegments(from: utc("2026-10-28T20:58:00Z"), to: utc("2026-10-28T21:10:00Z")) + #expect(segments.map(\.rate) == [0.25, 0.2]) + #expect(segments.map(\.start) == [utc("2026-10-28T20:58:00Z"), utc("2026-10-28T21:00:00Z")]) + } + } + + @Test("splits at the schedule's clock time on a daylight saving change day") + func splitsAtClockTimeAcrossDST() { + withSchedule { manager in + manager.timezone = TimeZone(identifier: "Europe/Stockholm")! + // 2026-10-25 falls back from UTC+2 to UTC+1 at 03:00, so 16:30 local is 15:30Z + let segments = manager.scheduledBasalSegments(from: utc("2026-10-25T15:00:00Z"), to: utc("2026-10-25T16:00:00Z")) + #expect(segments.map(\.rate) == [0.65, 0.5]) + #expect(segments.map(\.start) == [utc("2026-10-25T15:00:00Z"), utc("2026-10-25T15:30:00Z")]) + } + } + + @Test("skips entries that keep the same rate") + func skipsUnchangedRate() { + withSchedule { manager in + let segments = manager.scheduledBasalSegments(from: utc("2026-10-28T00:00:00Z"), to: utc("2026-10-28T02:00:00Z")) + #expect(segments.map(\.rate) == [0.2]) + } + } +} From 2151417dd550b10b350acbfe26011177d2d2a0bf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Nov 2026 18:27:20 +0000 Subject: [PATCH 77/84] CI: Bump dev version to 7.1.31 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 9f59e0031..4d55adf42 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.30 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.31 From 2bced0d27a43db4bee29339b24b973041e13ee90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 1 Nov 2026 21:11:58 +0100 Subject: [PATCH 78/84] Draw basal as 0 while a Trio pump is suspended (#800) --- .../Controllers/Nightscout/Treatments.swift | 9 +- .../Nightscout/Treatments/Basals.swift | 7 +- .../Treatments/PumpSuspension.swift | 86 ++++++++++++++ Tests/PumpSuspensionTests.swift | 108 ++++++++++++++++++ 4 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 LoopFollow/Controllers/Nightscout/Treatments/PumpSuspension.swift create mode 100644 Tests/PumpSuspensionTests.swift diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index 430751bc4..2c2ef0cb6 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -138,7 +138,14 @@ extension MainViewController { } if tempBasal.count > 0 { - processNSBasals(entries: tempBasal) + let suspensions = PumpSuspension.intervals( + notes: note.compactMap { entry in + guard let date = NightscoutUtils.treatmentDate(from: entry), let text = entry["notes"] as? String else { return nil } + return (date, text) + }, + tempBasalStarts: tempBasal.compactMap { NightscoutUtils.treatmentDate(from: $0) } + ) + processNSBasals(entries: tempBasal, suspensions: suspensions) } else { if basalData.count > 0 { clearOldTempBasal() diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift index 0359d0336..7846de081 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Basals.swift @@ -5,7 +5,7 @@ import Foundation extension MainViewController { // NS Temp Basal Response Processor - func processNSBasals(entries: [[String: AnyObject]]) { + func processNSBasals(entries: [[String: AnyObject]], suspensions: [DateInterval] = []) { infoManager.clearInfoData(type: .basal) basalData.removeAll() @@ -136,6 +136,11 @@ extension MainViewController { } } + basalData = PumpSuspension.applying(suspensions, to: basalData) + if suspensions.contains(where: { $0.contains(Date()) }) { + latestBasal = Localizer.formatToLocalizedString(0.0, maxFractionDigits: 2, minFractionDigits: 0) + } + if Storage.shared.graphBasal.value { updateBasalGraph() } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/PumpSuspension.swift b/LoopFollow/Controllers/Nightscout/Treatments/PumpSuspension.swift new file mode 100644 index 000000000..b31789773 --- /dev/null +++ b/LoopFollow/Controllers/Nightscout/Treatments/PumpSuspension.swift @@ -0,0 +1,86 @@ +// LoopFollow +// PumpSuspension.swift + +import Foundation + +/// Periods when the pump delivers no basal, from Trio's `PumpSuspend` and `PumpResume` notes. +enum PumpSuspension { + static let suspendNote = "PumpSuspend" + static let resumeNote = "PumpResume" + + /// Pairs each suspend with the next resume. A suspend without a resume lasts until the next + /// temp basal starts, as Trio sets no temp basal while the pump is suspended. Without either it + /// is still ongoing and ends at `.distantFuture`. + static func intervals(notes: [(date: Date, note: String)], tempBasalStarts: [Date]) -> [DateInterval] { + let events = notes + .filter { $0.note == suspendNote || $0.note == resumeNote } + .sorted { $0.date < $1.date } + let tempStarts = tempBasalStarts.sorted() + + var result: [DateInterval] = [] + var suspendStart: Date? + + func close(at resume: Date?) { + guard let start = suspendStart else { return } + let nextTemp = tempStarts.first { $0 > start } + let end = [resume, nextTemp].compactMap { $0 }.min() ?? .distantFuture + if end > start { + result.append(DateInterval(start: start, end: end)) + } + suspendStart = nil + } + + for event in events { + if event.note == suspendNote { + if let start = suspendStart, let nextTemp = tempStarts.first(where: { $0 > start }), nextTemp <= event.date { + close(at: nil) + } + if suspendStart == nil { + suspendStart = event.date + } + } else { + close(at: event.date) + } + } + close(at: nil) + return result + } + + /// The basal steps with the rate set to 0 inside the intervals. Each point's rate holds until + /// the next point, as the chart draws them. + static func applying(_ intervals: [DateInterval], to basal: [MainViewController.basalGraphStruct]) -> [MainViewController.basalGraphStruct] { + guard !intervals.isEmpty else { return basal } + let points = basal.sorted { $0.date < $1.date } + let suspensions = intervals.sorted { $0.start < $1.start } + var result: [MainViewController.basalGraphStruct] = [] + + for (index, point) in points.enumerated() where index + 1 < points.count { + let end = points[index + 1].date + guard end > point.date else { continue } + + var cursor = point.date + for suspension in suspensions { + let suspendStart = max(suspension.start.timeIntervalSince1970, cursor) + let suspendEnd = min(suspension.end.timeIntervalSince1970, end) + guard suspendStart < suspendEnd else { continue } + if cursor < suspendStart { + result.append(.init(basalRate: point.basalRate, date: cursor)) + result.append(.init(basalRate: point.basalRate, date: suspendStart)) + } + result.append(.init(basalRate: 0, date: suspendStart)) + result.append(.init(basalRate: 0, date: suspendEnd)) + cursor = suspendEnd + } + if cursor < end { + result.append(.init(basalRate: point.basalRate, date: cursor)) + result.append(.init(basalRate: point.basalRate, date: end)) + } + } + if let last = points.last { + let lastDate = Date(timeIntervalSince1970: last.date) + let suspended = suspensions.contains { $0.start <= lastDate && lastDate < $0.end } + result.append(.init(basalRate: suspended ? 0 : last.basalRate, date: last.date)) + } + return result + } +} diff --git a/Tests/PumpSuspensionTests.swift b/Tests/PumpSuspensionTests.swift new file mode 100644 index 000000000..75efb56cd --- /dev/null +++ b/Tests/PumpSuspensionTests.swift @@ -0,0 +1,108 @@ +// LoopFollow +// PumpSuspensionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct PumpSuspensionTests { + private let t0 = Date(timeIntervalSince1970: 1_790_000_000) + + private func at(_ minutes: Double) -> Date { + t0.addingTimeInterval(minutes * 60) + } + + private func note(_ text: String, _ minutes: Double) -> (date: Date, note: String) { + (at(minutes), text) + } + + private func dot(_ rate: Double, _ minutes: Double) -> MainViewController.basalGraphStruct { + .init(basalRate: rate, date: at(minutes).timeIntervalSince1970) + } + + /// The rate the chart draws at a time: each point holds until the next one. + private func rate(at minutes: Double, in basal: [MainViewController.basalGraphStruct]) -> Double? { + let time = at(minutes).timeIntervalSince1970 + return basal.sorted { $0.date < $1.date }.last { $0.date <= time }?.basalRate + } + + // MARK: - Intervals + + @Test("A suspend pairs with the next resume") + func pairsSuspendAndResume() { + let intervals = PumpSuspension.intervals( + notes: [note("PumpSuspend", 10), note("Pizza", 15), note("PumpResume", 40)], + tempBasalStarts: [at(0), at(45)] + ) + #expect(intervals == [DateInterval(start: at(10), end: at(40))]) + } + + @Test("A suspend without a resume ends at the next temp basal") + func endsAtNextTempBasal() { + let intervals = PumpSuspension.intervals( + notes: [note("PumpSuspend", 10)], + tempBasalStarts: [at(0), at(30)] + ) + #expect(intervals == [DateInterval(start: at(10), end: at(30))]) + } + + @Test("A suspend without a resume or a later temp basal is ongoing") + func ongoingSuspend() { + let intervals = PumpSuspension.intervals(notes: [note("PumpSuspend", 10)], tempBasalStarts: [at(0)]) + #expect(intervals == [DateInterval(start: at(10), end: .distantFuture)]) + } + + @Test("A repeated suspend keeps the first start") + func repeatedSuspend() { + let intervals = PumpSuspension.intervals( + notes: [note("PumpSuspend", 10), note("PumpSuspend", 20), note("PumpResume", 40)], + tempBasalStarts: [] + ) + #expect(intervals == [DateInterval(start: at(10), end: at(40))]) + } + + @Test("A suspend ended by a temp basal is separate from a later suspend") + func suspendAfterTempBasal() { + let intervals = PumpSuspension.intervals( + notes: [note("PumpSuspend", 10), note("PumpSuspend", 50), note("PumpResume", 60)], + tempBasalStarts: [at(30)] + ) + #expect(intervals == [DateInterval(start: at(10), end: at(30)), DateInterval(start: at(50), end: at(60))]) + } + + @Test("A resume without a suspend is ignored") + func resumeWithoutSuspend() { + let intervals = PumpSuspension.intervals(notes: [note("PumpResume", 10)], tempBasalStarts: []) + #expect(intervals.isEmpty) + } + + // MARK: - Basal steps + + @Test("Basal inside a suspension is drawn as 0") + func zeroInsideSuspension() { + let basal = [dot(1.2, 0), dot(1.2, 30), dot(0.8, 30), dot(0.8, 60)] + let result = PumpSuspension.applying([DateInterval(start: at(20), end: at(40))], to: basal) + #expect(rate(at: 10, in: result) == 1.2) + #expect(rate(at: 25, in: result) == 0) + #expect(rate(at: 35, in: result) == 0) + #expect(rate(at: 50, in: result) == 0.8) + #expect(result.map(\.date).max() == at(60).timeIntervalSince1970) + } + + @Test("An ongoing suspension holds 0 to the end of the basal") + func ongoingSuspensionToEnd() { + let basal = [dot(1.0, 0), dot(1.0, 60)] + let result = PumpSuspension.applying([DateInterval(start: at(30), end: .distantFuture)], to: basal) + #expect(rate(at: 15, in: result) == 1.0) + #expect(rate(at: 45, in: result) == 0) + #expect(result.map(\.date).max() == at(60).timeIntervalSince1970) + } + + @Test("Basal without suspensions is unchanged") + func noSuspensions() { + let basal = [dot(1.0, 0), dot(1.0, 60)] + let result = PumpSuspension.applying([], to: basal) + #expect(result.map(\.date) == basal.map(\.date)) + #expect(result.map(\.basalRate) == basal.map(\.basalRate)) + } +} From 75fb7df852ca74012bcf06160fbfdcb655a6bcea Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Nov 2026 20:12:08 +0000 Subject: [PATCH 79/84] CI: Bump dev version to 7.1.32 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 4d55adf42..e155c1049 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.31 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.32 From 310cab68c7b051c31eb1c6567bcaa9eee82ccf38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 1 Nov 2026 21:18:41 +0100 Subject: [PATCH 80/84] Show open loop status and IOB/basal for open-loop Trio and Loop (#802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trio in open loop uploads dosingMode "open" with only a suggested block and no temp basals, and Loop reports its Closed Loop setting as loopSettings.dosingEnabled in the profile. The loop status shows ⏀ in both cases, Trio IOB falls back to suggested.IOB, and the Basal row shows the scheduled profile basal when there are no temp basals. --- .../Controllers/Nightscout/DeviceStatusLoop.swift | 6 +++++- .../Controllers/Nightscout/DeviceStatusOpenAPS.swift | 10 ++++++++-- LoopFollow/Controllers/Nightscout/ProfileManager.swift | 3 +++ LoopFollow/Controllers/Nightscout/Treatments.swift | 4 ++++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index 93940397c..f2056040a 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -115,7 +115,11 @@ extension MainViewController { Observable.shared.deviceRecBolus.value = nil Observable.shared.deviceRecBolusAt.value = nil } - if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] { + // Loop uploads its Closed Loop toggle as loopSettings.dosingEnabled in the profile + if profileManager.loopDosingEnabled == false { + Observable.shared.loopStatusText.value = "⏀" + latestLoopStatusString = "⏀" + } else if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] { if let tempBasalString = loopStatus["timestamp"] as? String, let tempBasalTime = formatter.date(from: tempBasalString)?.timeIntervalSince1970 { diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index ff4178b34..f2885952d 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -72,7 +72,9 @@ extension MainViewController { } // IOB - if let iobMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") { + if let iobMetric = InsulinMetric(from: lastLoopRecord["iob"], key: "iob") + ?? InsulinMetric(from: enactedOrSuggested, key: "IOB") + { infoManager.updateInfoData(type: .iob, value: iobMetric) latestIOB = iobMetric Observable.shared.iobText.value = iobMetric.formattedValue() @@ -226,7 +228,11 @@ extension MainViewController { openAPSPredUpdatedTime = nil } - if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] { + // Trio uploads dosingMode "open" while Closed Loop is off + if lastLoopRecord["dosingMode"] as? String == "open" { + Observable.shared.loopStatusText.value = "⏀" + latestLoopStatusString = "⏀" + } else if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] { if let tempBasalString = loopStatus["timestamp"] as? String, let tempBasalTime = formatter.date(from: tempBasalString)?.timeIntervalSince1970 { diff --git a/LoopFollow/Controllers/Nightscout/ProfileManager.swift b/LoopFollow/Controllers/Nightscout/ProfileManager.swift index 10f5087c1..5f53d9abb 100644 --- a/LoopFollow/Controllers/Nightscout/ProfileManager.swift +++ b/LoopFollow/Controllers/Nightscout/ProfileManager.swift @@ -18,6 +18,7 @@ final class ProfileManager { var targetHighSchedule: [TimeValue] var loopOverrides: [LoopOverride] var trioOverrides: [TrioOverride] + var loopDosingEnabled: Bool? var units: HKUnit var timezone: TimeZone var defaultProfile: String @@ -94,6 +95,8 @@ final class ProfileManager { loopOverrides = [] } + loopDosingEnabled = profileData.loopSettings?.dosingEnabled + if let trioOverrides = profileData.trioOverrides { self.trioOverrides = trioOverrides.map { entry in let targetQuantity = entry.target != nil ? HKQuantity(unit: .milligramsPerDeciliter, doubleValue: entry.target!) : nil diff --git a/LoopFollow/Controllers/Nightscout/Treatments.swift b/LoopFollow/Controllers/Nightscout/Treatments.swift index 2c2ef0cb6..969039ab6 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments.swift @@ -150,6 +150,10 @@ extension MainViewController { if basalData.count > 0 { clearOldTempBasal() } + if let profileBasal = profileManager.currentBasal() { + infoManager.updateInfoData(type: .basal, value: profileBasal) + Storage.shared.lastBasal.value = profileBasal + } } if bolus.count > 0 { processNSBolus(entries: bolus) From 1a63abe9f80e65ed63a36a62e37018f31f988b5e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 1 Nov 2026 20:18:50 +0000 Subject: [PATCH 81/84] CI: Bump dev version to 7.1.33 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index e155c1049..d05548b29 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.32 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.33 From 3075dd04ce2f7af548b40f3b96218719ac569744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Mon, 2 Nov 2026 02:02:45 +0100 Subject: [PATCH 82/84] Base the Loop predictive low alarm on the full forecast and remove the Download Prediction setting (#809) --- LoopFollow/Charts/BGChartStubs.swift | 2 ++ .../Controllers/Nightscout/DeviceStatusLoop.swift | 6 +++++- LoopFollow/Settings/AdvancedSettingsView.swift | 1 - .../Settings/AdvancedSettingsViewModel.swift | 7 ------- LoopFollow/Storage/Storage.swift | 1 - LoopFollow/Task/AlarmTask.swift | 15 +++++++++------ .../ViewControllers/MainViewController.swift | 2 ++ 7 files changed, 18 insertions(+), 16 deletions(-) diff --git a/LoopFollow/Charts/BGChartStubs.swift b/LoopFollow/Charts/BGChartStubs.swift index 700821a66..8ecab09dc 100644 --- a/LoopFollow/Charts/BGChartStubs.swift +++ b/LoopFollow/Charts/BGChartStubs.swift @@ -90,6 +90,8 @@ extension MainViewController { // Removes the Loop forecast line. Used when the active system switches away from Loop. func clearLoopPredictionGraph() { + loopPredBGs = nil + loopPredUpdatedTime = nil guard !predictionData.isEmpty else { return } predictionData.removeAll() updatePredictionGraph() diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index f2056040a..9333fad4a 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -73,7 +73,9 @@ extension MainViewController { { Observable.shared.predictionText.value = Localizer.toDisplayUnits(String(Int(round(lastPrediction)))) Observable.shared.predictionColor.value = .purple - if Storage.shared.downloadPrediction.value, previousLastLoopTime < lastLoopTime || predictionData.isEmpty { + loopPredBGs = prediction + loopPredUpdatedTime = lastLoopTime + if previousLastLoopTime < lastLoopTime || predictionData.isEmpty { predictionData.removeAll() var predictionTime = lastLoopTime let toLoad = Int(Storage.shared.predictionToLoad.value * 12) @@ -101,6 +103,8 @@ extension MainViewController { updatePredictionGraph() } } else { + loopPredBGs = nil + loopPredUpdatedTime = nil predictionData.removeAll() infoManager.clearInfoData(type: .minMax) updatePredictionGraph() diff --git a/LoopFollow/Settings/AdvancedSettingsView.swift b/LoopFollow/Settings/AdvancedSettingsView.swift index 9665df882..9f3c00fee 100644 --- a/LoopFollow/Settings/AdvancedSettingsView.swift +++ b/LoopFollow/Settings/AdvancedSettingsView.swift @@ -10,7 +10,6 @@ struct AdvancedSettingsView: View { Form { Section(header: Text("Advanced Settings")) { Toggle("Download Treatments", isOn: $viewModel.downloadTreatments) - Toggle("Download Prediction", isOn: $viewModel.downloadPrediction) Toggle("Graph Basal", isOn: $viewModel.graphBasal) Toggle("Graph Bolus", isOn: $viewModel.graphBolus) Toggle("Graph Carbs", isOn: $viewModel.graphCarbs) diff --git a/LoopFollow/Settings/AdvancedSettingsViewModel.swift b/LoopFollow/Settings/AdvancedSettingsViewModel.swift index 078e62288..38d68b993 100644 --- a/LoopFollow/Settings/AdvancedSettingsViewModel.swift +++ b/LoopFollow/Settings/AdvancedSettingsViewModel.swift @@ -10,12 +10,6 @@ class AdvancedSettingsViewModel: ObservableObject { } } - @Published var downloadPrediction: Bool { - didSet { - Storage.shared.downloadPrediction.value = downloadPrediction - } - } - @Published var graphBasal: Bool { didSet { Storage.shared.graphBasal.value = graphBasal @@ -58,7 +52,6 @@ class AdvancedSettingsViewModel: ObservableObject { init() { downloadTreatments = Storage.shared.downloadTreatments.value - downloadPrediction = Storage.shared.downloadPrediction.value graphBasal = Storage.shared.graphBasal.value graphBolus = Storage.shared.graphBolus.value graphCarbs = Storage.shared.graphCarbs.value diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 5417df992..4f533740a 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -183,7 +183,6 @@ class Storage { // MARK: - Advanced settings --------------------------------------------------- var downloadTreatments = StorageValue(key: "downloadTreatments", defaultValue: true) - var downloadPrediction = StorageValue(key: "downloadPrediction", defaultValue: true) var graphOtherTreatments = StorageValue(key: "graphOtherTreatments", defaultValue: true) var graphBasal = StorageValue(key: "graphBasal", defaultValue: true) var graphBolus = StorageValue(key: "graphBolus", defaultValue: true) diff --git a/LoopFollow/Task/AlarmTask.swift b/LoopFollow/Task/AlarmTask.swift index 53bea67ba..d74289ecf 100644 --- a/LoopFollow/Task/AlarmTask.swift +++ b/LoopFollow/Task/AlarmTask.swift @@ -79,14 +79,17 @@ extension MainViewController { /// Builds the forward glucose series the low alarm looks ahead in, /// oldest .. newest at 5-minute spacing. /// - /// Loop reports a single forecast, already stored in `predictionData`. Trio - /// reports four forecasts, collapsed into the lowest value per point in time - /// by `lowestForecast(forecasts:start:cap:)`. + /// Both series come from the full forecast as uploaded, independent of the + /// graph settings. Loop reports a single forecast; Trio reports four, + /// collapsed into the lowest value per point in time by + /// `lowestForecast(forecasts:start:cap:)`. func alarmPredictionData() -> [GlucoseValue] { if Storage.shared.device.value == "Loop" { - return predictionData - .prefix(MainViewController.alarmForecastPointCap) - .map { GlucoseValue(sgv: $0.sgv, date: Date(timeIntervalSince1970: $0.date)) } + guard let predBGs = loopPredBGs else { return [] } + return MainViewController.lowestForecast( + forecasts: [predBGs], + start: loopPredUpdatedTime ?? Date().timeIntervalSince1970 + ) } guard let predBGs = openAPSPredBGs else { return [] } diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index dce0b6611..b4af72f72 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -91,6 +91,8 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { var iobPredictionData: [ShareGlucoseData] = [] var cobPredictionData: [ShareGlucoseData] = [] var uamPredictionData: [ShareGlucoseData] = [] + var loopPredBGs: [Double]? + var loopPredUpdatedTime: TimeInterval? var openAPSPredBGs: [String: [Double]]? var openAPSPredUpdatedTime: TimeInterval? var bgCheckData: [ShareGlucoseData] = [] From 39838177be0e319f2aa91b69e648243c18084ec0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 2 Nov 2026 01:02:55 +0000 Subject: [PATCH 83/84] CI: Bump dev version to 7.1.34 [skip ci] --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index d05548b29..7e9cb5f7b 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.33 +LOOP_FOLLOW_MARKETING_VERSION = 7.1.34 From 17010e384311138d57f98aa5faca137f5496dc58 Mon Sep 17 00:00:00 2001 From: marionbarker Date: Mon, 2 Nov 2026 10:14:33 -0800 Subject: [PATCH 84/84] update version to 8.0.0 --- Config.xcconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Config.xcconfig b/Config.xcconfig index 7e9cb5f7b..906e04a70 100644 --- a/Config.xcconfig +++ b/Config.xcconfig @@ -6,4 +6,4 @@ unique_id = ${DEVELOPMENT_TEAM} //Version (DEFAULT) -LOOP_FOLLOW_MARKETING_VERSION = 7.1.34 +LOOP_FOLLOW_MARKETING_VERSION = 8.0.0