diff --git a/LoopFollow/Application/AppDelegate.swift b/LoopFollow/Application/AppDelegate.swift index 3c364fc2c..3cc65b736 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)") + RemoteCommandTracker.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) { + 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 UIApplication.shared.topMost?.dismiss(animated: true) @@ -267,6 +270,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { let content = notification.request.content + 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/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/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/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/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/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 38dc4fb23..e825ed88e 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSService.swift @@ -122,139 +122,168 @@ class LoopAPNSService { return hasFullSetup } - /// Sends carbs via APNS push notification - /// - Parameters: - /// - 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 - } + 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." - let deviceToken = Storage.shared.deviceToken.value - let bundleIdentifier = Storage.shared.bundleId.value - let creds = effectiveCredentials() + // MARK: - Command payloads (matching Nightscout's loop.js format) - // 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), + /// Fields every Loop remote command carries. + static func commandHeader(otp: String, alert: String, now: Date) -> [String: Any] { + [ + "otp": otp, "remote-address": "LoopFollow", "notes": "Sent via LoopFollow APNS", "entered-by": "LoopFollow", "sent-at": formatDateForAPNS(now), - "expiration": formatDateForAPNS(expiration), - "start-time": formatDateForAPNS(startTime), - "alert": "Remote Carbs Entry: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTime)) hours", - ] as [String: Any] + "expiration": formatDateForAPNS(now.addingTimeInterval(5 * 60)), + "alert": alert, + ] + } - // 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") - } + 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 + } - // 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)") + 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 + } - // Log carbs entry attempt - LogManager.shared.log(category: .apns, message: "Sending carbs: \(String(format: "%.1f", carbsAmount))g, absorption: \(String(format: "%.1f", absorptionTime))h") + 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 + } - sendAPNSNotification( - deviceToken: deviceToken, - bundleIdentifier: bundleIdentifier, - keyId: creds.keyId, - apnsKey: creds.apnsKey, - teamId: creds.teamId, - payload: finalPayload, - completion: completion + /// Keys are prefixed so an unpatched Loop rejects the command. Amount, absorption time and start time are always sent; food type only when set. + static func carbsEditCommandPayload( + syncIdentifier: String, + carbsAmount: Double, + absorptionTimeHours: Double, + foodType: String?, + consumedDate: Date, + otp: String, + now: Date = Date() + ) -> [String: Any] { + var fields = commandHeader( + otp: otp, + alert: "Remote Carbs Edit: \(String(format: "%.1f", carbsAmount)) grams\nAbsorption Time: \(String(format: "%.1f", absorptionTimeHours)) hours", + now: now ) + fields["carbs-edit"] = syncIdentifier + fields["carbs-edit-entry"] = carbsAmount + fields["carbs-edit-absorption-time"] = absorptionTimeHours + fields["carbs-edit-start-time"] = formatDateForAPNS(consumedDate) + if let foodType, !foodType.isEmpty { + fields["carbs-edit-food-type"] = foodType + } + return fields } - /// Sends bolus via APNS push notification - /// - Parameters: - /// - payload: The bolus payload to send - /// - completion: Completion handler with success status and error message - func sendBolusViaAPNS(payload: LoopAPNSPayload, completion: @escaping (Bool, String?) -> Void) { + /// 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 encryptReturnNotificationInfo(returnInfo: returnInfo, otpCode: otp) + } + + 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 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, + deviceToken: Storage.shared.deviceToken.value, + bundleIdentifier: Storage.shared.bundleId.value, keyId: creds.keyId, apnsKey: creds.apnsKey, teamId: creds.teamId, - payload: finalPayload, + payload: payload, completion: completion ) } + /// 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 + } + var payload = payload + payload["encrypted_return_notification"] = encryptedReturnInfo + sendRemoteCommand(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) { + 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, + otp: String, + completion: @escaping (Bool, String?) -> Void + ) { + LogManager.shared.log(category: .apns, message: "Sending carbs edit for syncIdentifier=\(LogRedactor.tail(syncIdentifier)): \(String(format: "%.1f", carbsAmount))g, absorption \(String(format: "%.1f", absorptionTimeHours))h") + let payload = Self.carbsEditCommandPayload( + syncIdentifier: syncIdentifier, + carbsAmount: carbsAmount, + absorptionTimeHours: absorptionTimeHours, + foodType: foodType, + consumedDate: consumedDate, + otp: otp + ) + sendConfirmedCommand(payload, otp: otp, completion: completion) + } + + /// Sends carbs via APNS push notification + /// - Parameters: + /// - payload: The carbs payload to send + /// - completion: Completion handler with success status and error message + func sendCarbsViaAPNS(payload: LoopAPNSPayload, completion: @escaping (Bool, String?) -> Void) { + var finalPayload = Self.carbsCommandPayload(payload) + if let encryptedReturnInfo = encryptedReturnNotification(otp: payload.otp) { + finalPayload["encrypted_return_notification"] = encryptedReturnInfo + } + LogManager.shared.log(category: .apns, message: "Sending carbs: \(String(format: "%.1f", payload.carbsAmount ?? 0.0))g, absorption: \(String(format: "%.1f", payload.absorptionTime ?? 3.0))h") + sendRemoteCommand(finalPayload, completion: completion) + } + + /// Sends bolus via APNS push notification + /// - Parameters: + /// - payload: The bolus payload to send + /// - completion: Completion handler with success status and error message + func sendBolusViaAPNS(payload: LoopAPNSPayload, completion: @escaping (Bool, String?) -> Void) { + var finalPayload = Self.bolusCommandPayload(payload) + if let encryptedReturnInfo = encryptedReturnNotification(otp: payload.otp) { + finalPayload["encrypted_return_notification"] = encryptedReturnInfo + } + LogManager.shared.log(category: .apns, message: "Sending bolus: \(String(format: "%.2f", payload.bolusAmount ?? 0.0))U") + sendRemoteCommand(finalPayload, completion: completion) + } + /// Validates APNS credentials similar to PushNotificationManager /// - Returns: Array of validation error messages, or nil if valid private func validateCredentials() -> [String]? { @@ -349,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) @@ -632,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) @@ -677,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, ] @@ -737,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 new file mode 100644 index 000000000..2fd82cabc --- /dev/null +++ b/LoopFollow/Remote/LoopAPNS/LoopCarbEditSheet.swift @@ -0,0 +1,193 @@ +// 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")) { + LabeledValueRow(label: "Carbs", value: String(format: "%.0f g", carb.carbs)) + if let hours = carb.absorptionHours { + LabeledValueRow(label: "Absorption", value: String(format: "%.1f h", hours)) + } + if let foodType = carb.foodType { + LabeledValueRow(label: "Food type", value: 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 + RemoteCommandTracker.shared.sendLoopCarbEdit( + carb: carb, + carbsAmount: carbsAmount.doubleValue(for: .gram()), + absorptionHours: absorptionTimeValue, + foodType: foodType.isEmpty ? nil : foodType, + consumedDate: consumedDate + ) { success, error in + DispatchQueue.main.async { + isSending = false + if success { + presentationMode.wrappedValue.dismiss() + } else { + alertType = .sendFailed(error ?? "Failed to send the carb update.") + } + } + } + } +} diff --git a/LoopFollow/Remote/RemoteCommandTracker.swift b/LoopFollow/Remote/RemoteCommandTracker.swift new file mode 100644 index 000000000..1870812ec --- /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/MealMacroInputs.swift b/LoopFollow/Remote/TRC/MealMacroInputs.swift new file mode 100644 index 000000000..71bf9b29b --- /dev/null +++ b/LoopFollow/Remote/TRC/MealMacroInputs.swift @@ -0,0 +1,65 @@ +// 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 + /// 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 + @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: ceiling(maxCarbs.value, currentValues?.carbs), + isFocused: $carbsFocused, + onValidationError: onValidationError + ) + + if showFatProtein { + HKQuantityInputView( + label: "Fat", + quantity: $fat, + unit: .gram(), + maxLength: 4, + minValue: HKQuantity(unit: .gram(), doubleValue: 0), + maxValue: ceiling(maxFat.value, currentValues?.fat), + isFocused: $fatFocused, + onValidationError: onValidationError + ) + + HKQuantityInputView( + label: "Protein", + quantity: $protein, + unit: .gram(), + maxLength: 4, + minValue: HKQuantity(unit: .gram(), doubleValue: 0), + 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/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/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..236161b06 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) @@ -327,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)") } } diff --git a/LoopFollow/Remote/TRC/TRCCommandType.swift b/LoopFollow/Remote/TRC/TRCCommandType.swift index 520f8a43a..b6a6bfe0d 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" + + /// One collapse id per command. + 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/LoopFollow/Remote/TRC/TRCMealEditView.swift b/LoopFollow/Remote/TRC/TRCMealEditView.swift new file mode 100644 index 000000000..06cb8fa67 --- /dev/null +++ b/LoopFollow/Remote/TRC/TRCMealEditView.swift @@ -0,0 +1,198 @@ +// 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) }, + 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()) + } + } + .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 + RemoteCommandTracker.shared.sendTrioMealEdit( + 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 { + 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 { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + dateTimeUtils.applyDisplayTimeZone(to: formatter) + return formatter.string(from: Date(timeIntervalSince1970: date)) + } +} diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index dd63f6dd6..2645ff1e5 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -22,6 +22,10 @@ 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: []) + /// 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/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/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 new file mode 100644 index 000000000..b7a28f480 --- /dev/null +++ b/LoopFollow/Treatments/LoopCarbTreatment.swift @@ -0,0 +1,62 @@ +// 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: Codable, Equatable { + static let editWindow: TimeInterval = 23 * 3600 + static let requiredRemoteCommands: Set = ["carbs-delete", "carbs-edit"] + + 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 + } + + /// 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 f1b1c7595..7aea0415a 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,7 +396,31 @@ private struct DayRow: Identifiable { struct TreatmentDetailView: View { let treatment: Treatment + var rootMeal: Treatment? = nil @StateObject private var viewModel = TreatmentDetailViewModel() + @Environment(\.presentationMode) private var presentationMode + @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 + @ObservedObject private var loopRemoteCommands = Storage.shared.loopRemoteCommands + @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? { + commandMeal?.mealID.uuidString ?? treatment.loopCarb?.syncIdentifier + } + + private var commandState: RemoteCommandTracker.State? { + commandKey.flatMap { commandTracker.states[$0] } + } var body: some View { List { @@ -415,6 +442,24 @@ struct TreatmentDetailView: View { } } + if let meal = treatment.trioMeal { + 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) + } + } + + if let carb = treatment.loopCarb { + 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) + } + } + // Glucose at time if viewModel.isLoading { Section { @@ -558,6 +603,143 @@ struct TreatmentDetailView: View { .onAppear { viewModel.loadDetails(for: treatment) } + .sheet(isPresented: $showEditSheet) { + if let meal = commandMeal { + TRCMealEditView(meal: meal) + } else if let carb = treatment.loopCarb { + LoopCarbEditSheet(carb: carb) + } + } + .confirmationDialog(deleteDialogTitle, isPresented: $showDeleteConfirmation, titleVisibility: .visible) { + Button(treatment.trioMeal != nil ? "Delete meal" : "Delete carbs", role: .destructive, action: sendDelete) + } message: { + Text(deleteDialogMessage) + } + .onChange(of: commandState) { _, state in + guard let commandKey, case .done(success: true, message: _) = state else { return } + commandTracker.consume(key: commandKey) + 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() { + if let meal = commandMeal { + commandTracker.sendTrioMealDelete(mealID: meal.mealID.uuidString) { _, _ in } + } else if let carb = treatment.loopCarb { + 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() + } + } + + @ViewBuilder + 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)) + } + if let foodType = carb.foodType { + LabeledValueRow(label: "Food type", value: foodType) + } + } + } + + @ViewBuilder + private func loopRemoteActionsSection(_ carb: LoopCarbTreatment) -> some View { + let busy = commandTracker.isBusy(key: carb.syncIdentifier) + let withinWindow = carb.isWithinEditWindow() + + Section(header: Text("Remote actions"), footer: loopRemoteActionsFooter(withinWindow: withinWindow)) { + commandStatusRow(pendingText: "Sent, awaiting confirmation from Loop…") + + 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 ? nil : Text("Carb entries can be changed remotely for 23 hours.") + } + + @ViewBuilder + 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) { + Text("Note") + Spacer() + Text(note) + .foregroundColor(.secondary) + .multilineTextAlignment(.trailing) + } + } + if meal.isFPUChild, let rootMeal { + NavigationLink("Show original meal", destination: TreatmentDetailView(treatment: rootMeal)) + } + } + } + + 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 + 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…") + + if !target.isFPUChild { + Button("Edit meal") { showEditSheet = true } + .disabled(!withinWindow || busy) + } + Button("Delete meal", role: .destructive) { showDeleteConfirmation = true } + .disabled(!withinWindow || busy) + } + } + + 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 { @@ -832,9 +1014,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 +1130,10 @@ struct Treatment: Identifiable { let icon: String 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) { + 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 @@ -957,6 +1142,8 @@ struct Treatment: Identifiable { self.icon = icon self.color = color self.bgValue = bgValue + self.trioMeal = trioMeal + self.loopCarb = loopCarb } var hourKey: String { @@ -967,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 @@ -976,6 +1209,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 @@ -1016,6 +1250,11 @@ class TreatmentsViewModel: ObservableObject { } } + /// The root meal for an FPU child, when it is loaded. + func rootMeal(forFPUChild treatment: Treatment) -> Treatment? { + treatment.rootMeal(in: rootMealsByFPUID) + } + func refreshTreatments() { allTreatments.removeAll() processedNightscoutIds.removeAll() @@ -1172,17 +1411,18 @@ 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) : nil + let loopCarb = trioMeal == nil ? LoopCarbTreatment(nightscoutEntry: entry, date: timestamp) : nil + let carbs = entry["carbs"] as? Double ?? 0 + if carbs > 0 || trioMeal != nil { let actualBG = findNearestBG(at: timestamp, in: mainVC.bgData) - let treatment = Treatment( - id: "\(nsId)-carb", - type: .carb, + let treatment = Treatment.carb( + nightscoutID: nsId, date: timestamp, - title: "\(Int(carbs))g", - subtitle: "Carbs", - icon: "circle.fill", - color: .orange, - bgValue: actualBG + carbs: carbs, + bgValue: actualBG, + trioMeal: trioMeal, + loopCarb: loopCarb ) treatments.append(treatment) } @@ -1361,6 +1601,7 @@ class TreatmentsViewModel: ObservableObject { } grouped[key]?.append(treatment) } + 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 new file mode 100644 index 000000000..b87559a07 --- /dev/null +++ b/LoopFollow/Treatments/TrioMealTreatment.swift @@ -0,0 +1,78 @@ +// 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. A document without `fpuID` is a root. +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 mealID: UUID + let fpuID: UUID? + let date: TimeInterval + let carbs: Double + let fat: Int + let protein: Int + let note: String? + let isFPUChild: Bool + + 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, + 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 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 + isFPUChild = fpuID == mealID + } + + var carbsForEdit: Int { Int(carbs.rounded()) } + + func isWithinEditWindow(now: Date = Date()) -> Bool { + let age = now.timeIntervalSince1970 - date + 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 { + remoteControlActive(remoteType: remoteType, device: device) && 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/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 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 == "🍕") + } +} diff --git a/Tests/LoopCarbTreatmentTests.swift b/Tests/LoopCarbTreatmentTests.swift new file mode 100644 index 000000000..9768dbcc2 --- /dev/null +++ b/Tests/LoopCarbTreatmentTests.swift @@ -0,0 +1,66 @@ +// 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, a Loop device and both commands advertised") + func gating() { + 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) + } +} 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/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") + } +} diff --git a/Tests/TrioMealTreatmentTests.swift b/Tests/TrioMealTreatmentTests.swift new file mode 100644 index 000000000..71fe70771 --- /dev/null +++ b/Tests/TrioMealTreatmentTests.swift @@ -0,0 +1,85 @@ +// 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) + #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) + #expect(meal?.isFPUChild == true) + #expect(meal?.mealID.uuidString == familyID) + } + + @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) == 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) + } + #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) + #expect(TrioMealTreatment.remoteControlActive(remoteType: .trc, device: "Trio") == true) + #expect(TrioMealTreatment.remoteControlActive(remoteType: .trc, device: "Loop") == false) + } +}