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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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