From 937d135e8d16252d2768b08445f863f242f3ac73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 25 Oct 2026 17:41:57 +0100 Subject: [PATCH 1/6] Add remote_profile customization Lets LoopFollow switch Loop to a profile saved with the profiles customization. Loop uploads the saved profiles to the Nightscout profile store, lists profile-switch in remoteCommands, and accepts an OTP-protected profile switch command whose fingerprint must match the saved profile it activates. Includes dev (also main) and next-dev patches and variants for remote_carb_edit and remote_window in either order. --- remote_carb_edit/README.md | 9 + .../dev_remote_carb_edit_remote_profile.patch | 1010 +++++++++ ...rb_edit_remote_profile_remote_window.patch | 1073 +++++++++ ...tdev_remote_carb_edit_remote_profile.patch | 953 ++++++++ ...rb_edit_remote_profile_remote_window.patch | 956 ++++++++ remote_profile/README.md | 43 + remote_profile/dev_remote_profile.patch | 651 ++++++ .../dev_remote_profile_remote_carb_edit.patch | 650 ++++++ ...ofile_remote_carb_edit_remote_window.patch | 713 ++++++ .../dev_remote_profile_remote_window.patch | 714 ++++++ remote_profile/nextdev_remote_profile.patch | 660 ++++++ ...tdev_remote_profile_remote_carb_edit.patch | 661 ++++++ ...ofile_remote_carb_edit_remote_window.patch | 661 ++++++ ...nextdev_remote_profile_remote_window.patch | 660 ++++++ ...ndow_remote_carb_edit_remote_profile.patch | 1989 +++++++++++++++++ .../dev_remote_window_remote_profile.patch | 1782 +++++++++++++++ ...ndow_remote_carb_edit_remote_profile.patch | 625 ++++++ ...nextdev_remote_window_remote_profile.patch | 542 +++++ 18 files changed, 14352 insertions(+) create mode 100644 remote_carb_edit/dev_remote_carb_edit_remote_profile.patch create mode 100644 remote_carb_edit/dev_remote_carb_edit_remote_profile_remote_window.patch create mode 100644 remote_carb_edit/nextdev_remote_carb_edit_remote_profile.patch create mode 100644 remote_carb_edit/nextdev_remote_carb_edit_remote_profile_remote_window.patch create mode 100644 remote_profile/README.md create mode 100644 remote_profile/dev_remote_profile.patch create mode 100644 remote_profile/dev_remote_profile_remote_carb_edit.patch create mode 100644 remote_profile/dev_remote_profile_remote_carb_edit_remote_window.patch create mode 100644 remote_profile/dev_remote_profile_remote_window.patch create mode 100644 remote_profile/nextdev_remote_profile.patch create mode 100644 remote_profile/nextdev_remote_profile_remote_carb_edit.patch create mode 100644 remote_profile/nextdev_remote_profile_remote_carb_edit_remote_window.patch create mode 100644 remote_profile/nextdev_remote_profile_remote_window.patch create mode 100644 remote_window/dev_remote_window_remote_carb_edit_remote_profile.patch create mode 100644 remote_window/dev_remote_window_remote_profile.patch create mode 100644 remote_window/nextdev_remote_window_remote_carb_edit_remote_profile.patch create mode 100644 remote_window/nextdev_remote_window_remote_profile.patch diff --git a/remote_carb_edit/README.md b/remote_carb_edit/README.md index 9b40ba4..2bbe8a4 100644 --- a/remote_carb_edit/README.md +++ b/remote_carb_edit/README.md @@ -38,3 +38,12 @@ For `next-dev`: Both orders produce the same tree. The selector script tries each folder's patches in sorted order and uses the first one that applies, so choosing both customizations picks the right file whichever is applied first. Applying by hand, use the plain patch for the first customization and the combined one for the second. With remote_window, `sent-at` is required on every remote command (the notification is rejected without it). On `dev`, delete and edit commands also appear in the remote command history with the entry's `syncIdentifier`; the `next-dev` remote_window has no command history. + +## Combining with remote_profile + +`remote_profile` also adds a remote command, so the two have variants for each other, also together with remote_window: + +- `_remote_carb_edit_remote_profile.patch` and `_remote_carb_edit_remote_profile_remote_window.patch`: this customization on top of remote_profile (and remote_window). +- `remote_profile/_remote_profile_remote_carb_edit.patch` and `..._remote_carb_edit_remote_window.patch`: the reverse. + +With both applied, `remoteCommands` lists `carbs-delete`, `carbs-edit` and `profile-switch`. See `remote_profile/README.md`. diff --git a/remote_carb_edit/dev_remote_carb_edit_remote_profile.patch b/remote_carb_edit/dev_remote_carb_edit_remote_profile.patch new file mode 100644 index 0000000..f5a129d --- /dev/null +++ b/remote_carb_edit/dev_remote_carb_edit_remote_profile.patch @@ -0,0 +1,1010 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index d0d2b312..02359815 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -2540,32 +2540,83 @@ extension LoopDataManager: ServicesManagerDelegate { + //Carb Entry + + func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { +- + let absorptionTime = absorptionTime ?? carbStore.defaultAbsorptionTimes.medium ++ let startDate = startDate ?? Date() ++ try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) ++ ++ let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) ++ let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate, foodType: foodType, absorptionTime: absorptionTime) ++ ++ let _ = try await devliverCarbEntry(candidateCarbEntry) ++ } ++ ++ /// Remote edits and deletes may only target entries this app created within the last 24 hours. ++ static let remoteCarbEditWindow: TimeInterval = .hours(24) ++ ++ func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry { ++ let entry = try await remoteEditableCarbEntry(syncIdentifier: syncIdentifier) ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ deleteCarbEntry(entry) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ return entry ++ } ++ ++ func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry { ++ let existing = try await remoteEditableCarbEntry(syncIdentifier: syncIdentifier) ++ try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) ++ ++ let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) ++ let replacement = NewCarbEntry(quantity: quantity, startDate: startDate, foodType: foodType ?? existing.foodType, absorptionTime: absorptionTime) ++ ++ return try await withCheckedThrowingContinuation { continuation in ++ addCarbEntry(replacement, replacing: existing) { result in ++ switch result { ++ case .success(let storedCarbEntry): ++ continuation.resume(returning: storedCarbEntry) ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ } ++ ++ private func validateRemoteCarbParameters(amountInGrams: Double, absorptionTime: TimeInterval, startDate: Date) throws { + if absorptionTime < LoopConstants.minCarbAbsorptionTime || absorptionTime > LoopConstants.maxCarbAbsorptionTime { + throw CarbActionError.invalidAbsorptionTime(absorptionTime) + } +- ++ + guard amountInGrams > 0.0 else { + throw CarbActionError.invalidCarbs + } +- ++ + guard amountInGrams <= LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram()) else { + throw CarbActionError.exceedsMaxCarbs + } +- +- if let startDate = startDate { +- let maxStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) +- let minStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryPastTime) +- guard startDate <= maxStartDate && startDate >= minStartDate else { +- throw CarbActionError.invalidStartDate(startDate) +- } ++ ++ let maxStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) ++ let minStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryPastTime) ++ guard startDate <= maxStartDate && startDate >= minStartDate else { ++ throw CarbActionError.invalidStartDate(startDate) + } +- +- let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) +- let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate ?? Date(), foodType: foodType, absorptionTime: absorptionTime) +- +- let _ = try await devliverCarbEntry(candidateCarbEntry) ++ } ++ ++ private func remoteEditableCarbEntry(syncIdentifier: String) async throws -> StoredCarbEntry { ++ let now = Date() ++ let entries = try await carbStore.getCarbEntries(start: now.addingTimeInterval(-Self.remoteCarbEditWindow), end: now.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime)) ++ guard let entry = entries.first(where: { $0.syncIdentifier == syncIdentifier }) else { ++ throw CarbActionError.entryNotFound ++ } ++ guard entry.createdByCurrentApp else { ++ throw CarbActionError.entryNotEditable ++ } ++ return entry + } + + enum CarbActionError: LocalizedError { +@@ -2574,6 +2625,8 @@ extension LoopDataManager: ServicesManagerDelegate { + case invalidStartDate(Date) + case exceedsMaxCarbs + case invalidCarbs ++ case entryNotFound ++ case entryNotEditable + + var errorDescription: String? { + switch self { +@@ -2581,6 +2634,10 @@ extension LoopDataManager: ServicesManagerDelegate { + return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Carb error description: carbs exceed maximum amount.") + case .invalidCarbs: + return NSLocalizedString("Invalid carb amount", comment: "Carb error description: invalid carb amount.") ++ case .entryNotFound: ++ return NSLocalizedString("Carb entry not found or older than 24 hours", comment: "Carb error description: remote edit target not found.") ++ case .entryNotEditable: ++ return NSLocalizedString("Carb entry was not created by Loop", comment: "Carb error description: remote edit target not created by this app.") + case .invalidAbsorptionTime(let absorptionTime): + let absorptionHoursFormatted = Self.numberFormatter.string(from: absorptionTime.hours) ?? "" + return String(format: NSLocalizedString("Invalid absorption time: %1$@ hours", comment: "Carb error description: invalid absorption time. (1: Input duration in hours)."), absorptionHoursFormatted) +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 17cff75d..6cefc092 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -201,6 +201,36 @@ extension NotificationManager { + UNUserNotificationCenter.current().add(request) + } + ++ @MainActor ++ static func sendRemoteCarbChangeNotification(body: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [body, "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: LoopNotificationCategory.remoteCarbs.rawValue, ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ @MainActor ++ static func sendRemoteCarbChangeFailureNotification(body: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [body, error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: LoopNotificationCategory.remoteCarbsFailure.rawValue, ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ + static func sendMissedMealNotification(mealStart: Date, amountInGrams: Double, delay: TimeInterval? = nil) { + let notification = UNMutableNotificationContent() + /// Notifications should expire after the missed meal is no longer relevant +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index c9d3e04f..5bf1ec36 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -240,6 +240,8 @@ public protocol ServicesManagerDelegate: AnyObject { + func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws + func cancelCurrentOverride() async throws + func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws ++ func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry ++ func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry + } + + // MARK: - StatefulPluggableDelegate +@@ -330,6 +332,48 @@ extension ServicesManager: ServiceDelegate { + } + } + ++ func deleteRemoteCarbs(syncIdentifier: String) async throws { ++ do { ++ guard let servicesManagerDelegate else { ++ throw RemoteCarbCommandError.internalError ++ } ++ let deleted = try await servicesManagerDelegate.deleteCarbs(syncIdentifier: syncIdentifier) ++ let body = String(format: NSLocalizedString("Remote Carbs Delete: %d grams removed", comment: "The notification body for a successful remote carb delete. (1: Carb amount in grams)"), Int(deleted.quantity.doubleValue(for: .gram()))) ++ await NotificationManager.sendRemoteCarbChangeNotification(body: body) ++ await remoteDataServicesManager.triggerUpload(for: .carb) ++ } catch { ++ let body = NSLocalizedString("Remote Carbs Delete", comment: "The notification body prefix for a failed remote carb delete") ++ await NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) ++ throw error ++ } ++ } ++ ++ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws { ++ let body = String(format: NSLocalizedString("Remote Carbs Edit: %d grams", comment: "The carb amount message for a remote carb edit notification. (1: Carb amount in grams)"), Int(amountInGrams)) ++ do { ++ guard let servicesManagerDelegate else { ++ throw RemoteCarbCommandError.internalError ++ } ++ _ = try await servicesManagerDelegate.replaceCarbs(syncIdentifier: syncIdentifier, amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) ++ await NotificationManager.sendRemoteCarbChangeNotification(body: body) ++ await remoteDataServicesManager.triggerUpload(for: .carb) ++ } catch { ++ await NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteCarbCommandError: LocalizedError { ++ case internalError ++ ++ var errorDescription: String? { ++ switch self { ++ case .internalError: ++ return NSLocalizedString("Internal error", comment: "Remote carb command error description: internal error.") ++ } ++ } ++ } ++ + func deliverRemoteBolus(amountInUnits: Double) async throws { + do { + +diff --git a/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +index a7ffef2e..692a4ec2 100644 +--- a/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift ++++ b/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +@@ -34,6 +34,8 @@ protocol CarbStoreProtocol: AnyObject { + func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) + + func addCarbEntry(_ entry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) ++ ++ func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] + + func getCarbStatus(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult<[CarbStatus]>) -> Void) + +diff --git a/Loop/LoopTests/Mock Stores/MockCarbStore.swift b/Loop/LoopTests/Mock Stores/MockCarbStore.swift +index 4a5c016e..dd35a787 100644 +--- a/Loop/LoopTests/Mock Stores/MockCarbStore.swift ++++ b/Loop/LoopTests/Mock Stores/MockCarbStore.swift +@@ -12,6 +12,7 @@ import LoopKit + + class MockCarbStore: CarbStoreProtocol { + var carbHistory: [StoredCarbEntry]? ++ var carbEntries: [StoredCarbEntry] = [] + + init(for scenario: DosingTestScenario = .flatAndStable) { + self.scenario = scenario // The store returns different effect values based on the scenario +@@ -72,6 +73,10 @@ class MockCarbStore: CarbStoreProtocol { + completion(.failure(.notConfigured)) + } + ++ func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] { ++ return carbEntries ++ } ++ + func getCarbStatus(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult<[CarbStatus]>) -> Void) { + completion(.failure(.notConfigured)) + } +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 00fe25ab..56742c96 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -19,4 +19,6 @@ public protocol RemoteActionDelegate: AnyObject { + func cancelRemoteOverride() async throws + func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws + func deliverRemoteBolus(amountInUnits: Double) async throws ++ func deleteRemoteCarbs(syncIdentifier: String) async throws ++ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws + } +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index 14a7691..18143ab 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -32,12 +32,17 @@ + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; + A941B09029BD4A9E00F91340 /* CarbRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */; }; ++ FB42DD0865235D441918DA5E /* CarbEditRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */; }; ++ A2C1A69F3A61FD4E207637A7 /* CarbDeleteRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */; }; + A941B09129BD4A9E00F91340 /* BolusRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */; }; + A941B09229BD4A9F00F91340 /* OverrideCancelRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */; }; + A941B09329BD4A9F00F91340 /* RemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */; }; + A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */; }; + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */; }; + A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */; }; ++ AFFFFDFBBBA4F7DF3997E63E /* CarbEditRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */; }; ++ 73056A2FC515352D51EFFECF /* CarbDeleteRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */; }; ++ 5C1D2E3F4A5B6C7D8E9F0A1B /* RemoteNotificationCapabilitiesTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */; }; + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */; }; + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */; }; + A94DA7642353CF930082E26D /* StoredGlucoseSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */; }; +@@ -51,6 +56,8 @@ + A9AA6E5B29EB072E008FFA78 /* OverrideAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */; }; + A9AA6E6329EB07D7008FFA78 /* Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5D29EB07D6008FFA78 /* Action.swift */; }; + A9AA6E6429EB07D7008FFA78 /* CarbAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */; }; ++ 843DF14B73EC57EB6F0033DA /* CarbEditAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04073CA740189C4259808331 /* CarbEditAction.swift */; }; ++ 7E7294EAE3456F0C6DC10F37 /* CarbDeleteAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */; }; + A9AA6E6529EB07D7008FFA78 /* OverrideCancelAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */; }; + A9AA6E6629EB07D7008FFA78 /* BolusAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */; }; + A9B007B322BD6FF7000131DE /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B007B222BD6FF7000131DE /* LocalizedString.swift */; }; +@@ -170,12 +177,17 @@ + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; + A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbRemoteNotification.swift; sourceTree = ""; }; ++ 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditRemoteNotification.swift; sourceTree = ""; }; ++ A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteRemoteNotification.swift; sourceTree = ""; }; + A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRemoteNotification.swift; sourceTree = ""; }; + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotification.swift; sourceTree = ""; }; + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotification.swift; sourceTree = ""; }; + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideRemoteNotification.swift; sourceTree = ""; }; + A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRemoteNotificationTestCase.swift; sourceTree = ""; }; + A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbRemoteNotificationTestCase.swift; sourceTree = ""; }; ++ E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditRemoteNotificationTestCase.swift; sourceTree = ""; }; ++ 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteRemoteNotificationTestCase.swift; sourceTree = ""; }; ++ 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotificationCapabilitiesTestCase.swift; sourceTree = ""; }; + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotificationTestCase.swift; sourceTree = ""; }; + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideRemoteNotificationTestCase.swift; sourceTree = ""; }; + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredGlucoseSample.swift; sourceTree = ""; }; +@@ -191,6 +203,8 @@ + A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideAction.swift; sourceTree = ""; }; + A9AA6E5D29EB07D6008FFA78 /* Action.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbAction.swift; sourceTree = ""; }; ++ 04073CA740189C4259808331 /* CarbEditAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditAction.swift; sourceTree = ""; }; ++ 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteAction.swift; sourceTree = ""; }; + A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelAction.swift; sourceTree = ""; }; + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusAction.swift; sourceTree = ""; }; + A9B007B222BD6FF7000131DE /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; +@@ -330,6 +344,9 @@ + A91BAC2B22BC691A00ABF1BB /* Info.plist */, + A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */, + A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */, ++ E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */, ++ 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */, ++ 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */, + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, +@@ -371,6 +388,8 @@ + children = ( + A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */, + A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */, ++ 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */, ++ A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */, + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, +@@ -427,6 +446,8 @@ + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, ++ 04073CA740189C4259808331 /* CarbEditAction.swift */, ++ 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */, + A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */, + A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */, + ); +@@ -728,12 +749,16 @@ + A993990322BC7095005E39D3 /* NightscoutService.swift in Sources */, + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */, + A9AA6E6429EB07D7008FFA78 /* CarbAction.swift in Sources */, ++ 843DF14B73EC57EB6F0033DA /* CarbEditAction.swift in Sources */, ++ 7E7294EAE3456F0C6DC10F37 /* CarbDeleteAction.swift in Sources */, + A90E39A922BC7AD10016DFE8 /* Bundle.swift in Sources */, + A90E39A322BC782C0016DFE8 /* SyncCarbObject.swift in Sources */, + A941B09329BD4A9F00F91340 /* RemoteNotification.swift in Sources */, + A9AA6E6629EB07D7008FFA78 /* BolusAction.swift in Sources */, + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */, + A941B09029BD4A9E00F91340 /* CarbRemoteNotification.swift in Sources */, ++ FB42DD0865235D441918DA5E /* CarbEditRemoteNotification.swift in Sources */, ++ A2C1A69F3A61FD4E207637A7 /* CarbDeleteRemoteNotification.swift in Sources */, + A94DA7642353CF930082E26D /* StoredGlucoseSample.swift in Sources */, + A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */, + C1E703642506BE9400DAB534 /* ObjectIdCache.swift in Sources */, +@@ -769,6 +794,9 @@ + buildActionMask = 2147483647; + files = ( + A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */, ++ AFFFFDFBBBA4F7DF3997E63E /* CarbEditRemoteNotificationTestCase.swift in Sources */, ++ 73056A2FC515352D51EFFECF /* CarbDeleteRemoteNotificationTestCase.swift in Sources */, ++ 5C1D2E3F4A5B6C7D8E9F0A1B /* RemoteNotificationCapabilitiesTestCase.swift in Sources */, + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index b66fb97..7c780ea 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -160,7 +160,7 @@ extension StoredDosingDecision { + recommendedBolus: loopStatusRecommendedBolus, + enacted: automaticDoseDecision?.loopStatusEnacted, + failureReason: automaticDoseDecision?.loopStatusFailureReason, +- testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]]), ++ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]]), + overrideStatus: overrideStatus) + } + +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index ef69236..18ac515 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -24,7 +24,7 @@ public final class NightscoutService: Service { + + public static let localizedTitle = LocalizedString("Nightscout", comment: "The title of the Nightscout service") + +- public let objectIdCacheKeepTime = TimeInterval(24 * 60 * 60) ++ public let objectIdCacheKeepTime = TimeInterval(48 * 60 * 60) + + public weak var serviceDelegate: ServiceDelegate? + +@@ -423,6 +423,7 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + var commandType: RemoteNotificationResponseManager.CommandType = .bolus // Default, will be set in switch + var success = false + var message = "" ++ var syncIdentifier: String? = nil + + do { + switch action { +@@ -476,6 +477,26 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + ) + success = true + message = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) ++ ++ case .carbsDelete(let deleteCommand): ++ commandType = .carbsDelete ++ syncIdentifier = deleteCommand.syncIdentifier ++ try await self.serviceDelegate?.deleteRemoteCarbs(syncIdentifier: deleteCommand.syncIdentifier) ++ success = true ++ message = "Carb entry deleted successfully" ++ ++ case .carbsEdit(let editCommand): ++ commandType = .carbsEdit ++ syncIdentifier = editCommand.syncIdentifier ++ try await self.serviceDelegate?.replaceRemoteCarbs( ++ syncIdentifier: editCommand.syncIdentifier, ++ amountInGrams: editCommand.amountInGrams, ++ absorptionTime: editCommand.absorptionTime, ++ foodType: editCommand.foodType, ++ startDate: editCommand.startDate ++ ) ++ success = true ++ message = String(format: "Carb entry updated to %.1f g", editCommand.amountInGrams) + } + } catch { + message = "Command failed: \(error.localizedDescription)" +@@ -485,7 +506,8 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + to: returnInfo, + commandType: commandType, + success: false, +- message: message ++ message: message, ++ syncIdentifier: syncIdentifier + ) + } + throw error +@@ -497,7 +519,8 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + to: returnInfo, + commandType: commandType, + success: success, +- message: message ++ message: message, ++ syncIdentifier: syncIdentifier + ) + } + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index 29313b6..29d3563 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -14,6 +14,8 @@ public enum Action: Codable { + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) + case carbsEntry(CarbAction) ++ case carbsDelete(CarbDeleteAction) ++ case carbsEdit(CarbEditAction) + + public var description: String { + return "\(actionName) \(actionParameterDescription)" +@@ -23,6 +25,10 @@ public enum Action: Codable { + switch self { + case .carbsEntry: + return LocalizedString("Carb Entry", comment: "The remote action name for Carb Entry") ++ case .carbsDelete: ++ return LocalizedString("Delete Carbs", comment: "The remote action name for deleting a carb entry") ++ case .carbsEdit: ++ return LocalizedString("Edit Carbs", comment: "The remote action name for editing a carb entry") + case .bolusEntry: + return LocalizedString("Bolus Entry", comment: "The remote action name for Bolus Entry") + case .cancelTemporaryOverride: +@@ -39,6 +45,11 @@ public enum Action: Codable { + case .carbsEntry(let carbAction): + let amountFormatted = Self.numberFormatter.string(from: carbAction.amountInGrams as NSNumber) ?? "" + return "\(amountFormatted)" + " " + carbGramAbbreviation ++ case .carbsDelete(let deleteAction): ++ return String(deleteAction.syncIdentifier.prefix(8)) ++ case .carbsEdit(let editAction): ++ let amountFormatted = Self.numberFormatter.string(from: editAction.amountInGrams as NSNumber) ?? "" ++ return "\(amountFormatted)" + " " + carbGramAbbreviation + case .bolusEntry(let bolusAction): + let amountFormatted = Self.numberFormatter.string(from: bolusAction.amountInUnits as NSNumber) ?? "" + return "\(amountFormatted)" + " " + bolusUnitAbbreviation +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift +new file mode 100644 +index 0000000..77e9900 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift +@@ -0,0 +1,15 @@ ++// ++// CarbDeleteAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct CarbDeleteAction: Codable { ++ ++ public let syncIdentifier: String ++ ++ public init(syncIdentifier: String) { ++ self.syncIdentifier = syncIdentifier ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift +new file mode 100644 +index 0000000..0aea20b +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift +@@ -0,0 +1,23 @@ ++// ++// CarbEditAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct CarbEditAction: Codable { ++ ++ public let syncIdentifier: String ++ public let amountInGrams: Double ++ public let absorptionTime: TimeInterval ++ public let foodType: String? ++ public let startDate: Date ++ ++ public init(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String? = nil, startDate: Date) { ++ self.syncIdentifier = syncIdentifier ++ self.amountInGrams = amountInGrams ++ self.absorptionTime = absorptionTime ++ self.foodType = foodType ++ self.startDate = startDate ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 4457f80..aabb47f 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -21,12 +21,14 @@ class RemoteNotificationResponseManager { + let commandStatus: String + let commandType: String + let timestamp: TimeInterval ++ let syncIdentifier: String? + + enum CodingKeys: String, CodingKey { + case aps + case commandStatus = "command_status" + case commandType = "command_type" + case timestamp ++ case syncIdentifier = "sync_identifier" + } + } + +@@ -43,6 +45,8 @@ class RemoteNotificationResponseManager { + enum CommandType: String { + case bolus = "bolus" + case carbs = "carbs" ++ case carbsDelete = "carbs_delete" ++ case carbsEdit = "carbs_edit" + case override = "override" + case cancelOverride = "cancel_override" + case profileSwitch = "profile_switch" +@@ -52,7 +56,8 @@ class RemoteNotificationResponseManager { + to returnInfo: ReturnNotificationInfo?, + commandType: CommandType, + success: Bool, +- message: String ++ message: String, ++ syncIdentifier: String? = nil + ) async { + guard let returnInfo = returnInfo else { + os_log("No return notification info provided, skipping response", log: log, type: .info) +@@ -75,7 +80,8 @@ class RemoteNotificationResponseManager { + ), + commandStatus: success ? "success" : "failed", + commandType: commandType.rawValue, +- timestamp: Date().timeIntervalSince1970 ++ timestamp: Date().timeIntervalSince1970, ++ syncIdentifier: syncIdentifier + ) + + await sendPushNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift +new file mode 100644 +index 0000000..1593a74 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift +@@ -0,0 +1,44 @@ ++// ++// CarbDeleteRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++public struct CarbDeleteRemoteNotification: RemoteNotification, Codable { ++ ++ public let syncIdentifier: String ++ public let remoteAddress: String ++ public let expiration: Date? ++ public let sentAt: Date? ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ enum CodingKeys: String, CodingKey { ++ case syncIdentifier = "carbs-delete" ++ case remoteAddress = "remote-address" ++ case expiration = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .carbsDelete(CarbDeleteAction(syncIdentifier: syncIdentifier)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ func expirationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification["carbs-delete"] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift +new file mode 100644 +index 0000000..fde3e4c +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift +@@ -0,0 +1,58 @@ ++// ++// CarbEditRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Replacement values use prefixed keys so an unpatched Loop never reads them as a new carb entry. ++public struct CarbEditRemoteNotification: RemoteNotification, Codable { ++ ++ public let syncIdentifier: String ++ public let amount: Double ++ public let absorptionInHours: Double ++ public let foodType: String? ++ public let startDate: Date ++ public let remoteAddress: String ++ public let expiration: Date? ++ public let sentAt: Date? ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ enum CodingKeys: String, CodingKey { ++ case syncIdentifier = "carbs-edit" ++ case amount = "carbs-edit-entry" ++ case absorptionInHours = "carbs-edit-absorption-time" ++ case foodType = "carbs-edit-food-type" ++ case startDate = "carbs-edit-start-time" ++ case remoteAddress = "remote-address" ++ case expiration = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ public func absorptionTime() -> TimeInterval { ++ return TimeInterval(hours: absorptionInHours) ++ } ++ ++ func toRemoteAction() -> Action { ++ let action = CarbEditAction(syncIdentifier: syncIdentifier, amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate) ++ return .carbsEdit(action) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ func expirationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification["carbs-edit"] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 3362bf7..87f1e92 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -22,12 +22,22 @@ protocol RemoteNotification: Codable { + + func toRemoteAction() -> Action + func otpValidationRequired() -> Bool ++ func expirationRequired() -> Bool + func getReturnNotificationInfo() -> ReturnNotificationInfo? + + static func includedInNotification(_ notification: [String: Any]) -> Bool + } + ++/// Followers read this to know which remote commands this build accepts. ++enum RemoteNotificationCapabilities { ++ static let supported: [String] = ["carbs-delete", "carbs-edit"] ++} ++ + extension RemoteNotification { ++ ++ func expirationRequired() -> Bool { ++ return false ++ } + + var id: String { + //There is no unique identifier so we use the sent date when available +@@ -102,6 +112,10 @@ extension Dictionary { + func toRemoteNotification() throws -> RemoteNotification { + if BolusRemoteNotification.includedInNotification(self) { + return try BolusRemoteNotification(dictionary: self) ++ } else if CarbDeleteRemoteNotification.includedInNotification(self) { ++ return try CarbDeleteRemoteNotification(dictionary: self) ++ } else if CarbEditRemoteNotification.includedInNotification(self) { ++ return try CarbEditRemoteNotification(dictionary: self) + } else if CarbRemoteNotification.includedInNotification(self) { + return try CarbRemoteNotification(dictionary: self) + } else if OverrideRemoteNotification.includedInNotification(self) { +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +index 634eb4e..21d0e0a 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +@@ -16,6 +16,14 @@ struct RemoteCommandValidator { + } + + func validate(remoteNotification: RemoteNotification) throws { ++ if remoteNotification.expirationRequired() { ++ guard remoteNotification.expiration != nil else { ++ throw NotificationValidationError.missingExpiration ++ } ++ guard remoteNotification.sentAt != nil else { ++ throw NotificationValidationError.missingSentAt ++ } ++ } + try validateExpirationDate(remoteNotification: remoteNotification) + if remoteNotification.otpValidationRequired() { + try validateOTP(remoteNotification: remoteNotification) +@@ -45,11 +53,17 @@ struct RemoteCommandValidator { + enum NotificationValidationError: LocalizedError { + case missingOTP + case expiredNotification ++ case missingExpiration ++ case missingSentAt + + var errorDescription: String? { + switch self { + case .missingOTP: + return LocalizedString("Missing OTP", comment: "Remote command error description: Missing OTP.") ++ case .missingExpiration: ++ return LocalizedString("Missing expiration", comment: "Remote command error description: missing expiration.") ++ case .missingSentAt: ++ return LocalizedString("Missing sent-at time", comment: "Remote command error description: missing sent-at.") + case .expiredNotification: + return LocalizedString("Expired", comment: "Remote command error description: expired.") + } +diff --git a/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..6a3aba6 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift +@@ -0,0 +1,67 @@ ++// ++// CarbDeleteRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++@testable import NightscoutServiceKit ++ ++final class CarbDeleteRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseCarbDeleteNotification_ValidPayload_Succeeds() throws { ++ let syncIdentifier = "3F2504E0-4F89-11D3-9A0C-0305E82C3301" ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-delete": syncIdentifier as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ let remoteNotification = try notification.toRemoteNotification() ++ ++ let deleteNotification = try XCTUnwrap(remoteNotification as? CarbDeleteRemoteNotification) ++ XCTAssertEqual(deleteNotification.syncIdentifier, syncIdentifier) ++ XCTAssertEqual(deleteNotification.otp, "12345") ++ XCTAssertTrue(deleteNotification.otpValidationRequired()) ++ XCTAssertTrue(deleteNotification.expirationRequired()) ++ guard case .carbsDelete(let action) = deleteNotification.toRemoteAction() else { ++ return XCTFail("Expected carbsDelete action") ++ } ++ XCTAssertEqual(action.syncIdentifier, syncIdentifier) ++ } ++ ++ func testValidate_MissingExpiration_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "carbs-delete": "abc" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ let remoteNotification = try notification.toRemoteNotification() ++ let validator = RemoteCommandValidator(otpManager: OTPTestCoordinator().createOTPManager()) ++ ++ XCTAssertThrowsError(try validator.validate(remoteNotification: remoteNotification)) { error in ++ guard case RemoteCommandValidator.NotificationValidationError.missingExpiration = error else { ++ return XCTFail("Unexpected error \(error)") ++ } ++ } ++ } ++ ++ func testValidate_MissingSentAt_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "expiration": "2099-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-delete": "abc" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ let remoteNotification = try notification.toRemoteNotification() ++ let validator = RemoteCommandValidator(otpManager: OTPTestCoordinator().createOTPManager()) ++ ++ XCTAssertThrowsError(try validator.validate(remoteNotification: remoteNotification)) { error in ++ guard case RemoteCommandValidator.NotificationValidationError.missingSentAt = error else { ++ return XCTFail("Unexpected error \(error)") ++ } ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..989f7c8 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift +@@ -0,0 +1,108 @@ ++// ++// CarbEditRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++@testable import NightscoutServiceKit ++ ++final class CarbEditRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseCarbEditNotification_ValidPayload_Succeeds() throws { ++ let syncIdentifier = "3F2504E0-4F89-11D3-9A0C-0305E82C3301" ++ let startDateString = "2023-02-25T20:46:35.778Z" ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": syncIdentifier as AnyObject, ++ "carbs-edit-entry": 22.0 as AnyObject, ++ "carbs-edit-absorption-time": 3.0 as AnyObject, ++ "carbs-edit-food-type": "🍕" as AnyObject, ++ "carbs-edit-start-time": startDateString as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ let remoteNotification = try notification.toRemoteNotification() ++ ++ let editNotification = try XCTUnwrap(remoteNotification as? CarbEditRemoteNotification) ++ XCTAssertEqual(editNotification.syncIdentifier, syncIdentifier) ++ XCTAssertEqual(editNotification.amount, 22.0) ++ XCTAssertEqual(editNotification.absorptionTime(), TimeInterval(hours: 3.0)) ++ XCTAssertEqual(editNotification.foodType, "🍕") ++ XCTAssertEqual(editNotification.startDate, dateFormatter().date(from: startDateString)) ++ XCTAssertTrue(editNotification.otpValidationRequired()) ++ XCTAssertTrue(editNotification.expirationRequired()) ++ guard case .carbsEdit(let action) = editNotification.toRemoteAction() else { ++ return XCTFail("Expected carbsEdit action") ++ } ++ XCTAssertEqual(action.syncIdentifier, syncIdentifier) ++ XCTAssertEqual(action.amountInGrams, 22.0) ++ XCTAssertEqual(action.absorptionTime, TimeInterval(hours: 3.0)) ++ XCTAssertEqual(action.startDate, dateFormatter().date(from: startDateString)) ++ } ++ ++ func testParseCarbEditNotification_FoodTypeAbsent_Succeeds() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-absorption-time": 2.0 as AnyObject, ++ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ let editNotification = try XCTUnwrap(try notification.toRemoteNotification() as? CarbEditRemoteNotification) ++ XCTAssertNil(editNotification.foodType) ++ } ++ ++ func testParseCarbEditNotification_MissingStartTime_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-absorption-time": 2.0 as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ XCTAssertThrowsError(try notification.toRemoteNotification()) ++ } ++ ++ func testParseCarbEditNotification_MissingAbsorptionTime_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ XCTAssertThrowsError(try notification.toRemoteNotification()) ++ } ++ ++ func testParseCarbEditNotification_DoesNotDecodeAsCarbEntry() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-absorption-time": 2.0 as AnyObject, ++ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ XCTAssertFalse(CarbRemoteNotification.includedInNotification(notification)) ++ XCTAssertTrue(CarbEditRemoteNotification.includedInNotification(notification)) ++ } ++ ++ func dateFormatter() -> ISO8601DateFormatter { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift b/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift +new file mode 100644 +index 0000000..e284d3f +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift +@@ -0,0 +1,21 @@ ++// ++// RemoteNotificationCapabilitiesTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++@testable import NightscoutServiceKit ++ ++final class RemoteNotificationCapabilitiesTestCase: XCTestCase { ++ ++ func testSupportedCommands_MatchNotificationKeys() { ++ XCTAssertEqual(RemoteNotificationCapabilities.supported, ["carbs-delete", "carbs-edit"]) ++ XCTAssertTrue(CarbDeleteRemoteNotification.includedInNotification(["carbs-delete": "id"])) ++ XCTAssertTrue(CarbEditRemoteNotification.includedInNotification(["carbs-edit": "id"])) ++ for key in RemoteNotificationCapabilities.supported { ++ let notification: [String: Any] = [key: "id"] ++ XCTAssertTrue(CarbDeleteRemoteNotification.includedInNotification(notification) ++ || CarbEditRemoteNotification.includedInNotification(notification), key) ++ } ++ } ++} +diff --git a/remote_carb_edit.loopcustomization b/remote_carb_edit.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/remote_carb_edit/dev_remote_carb_edit_remote_profile_remote_window.patch b/remote_carb_edit/dev_remote_carb_edit_remote_profile_remote_window.patch new file mode 100644 index 0000000..c9aa87a --- /dev/null +++ b/remote_carb_edit/dev_remote_carb_edit_remote_profile_remote_window.patch @@ -0,0 +1,1073 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index 0a5c29eb..03ffe6c7 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -2556,19 +2556,9 @@ extension LoopDataManager: ServicesManagerDelegate { + //Carb Entry + + func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry { +- + let absorptionTime = absorptionTime ?? carbStore.defaultAbsorptionTimes.medium +- if absorptionTime < LoopConstants.minCarbAbsorptionTime || absorptionTime > LoopConstants.maxCarbAbsorptionTime { +- throw CarbActionError.invalidAbsorptionTime(absorptionTime) +- } +- +- guard amountInGrams > 0.0 else { +- throw CarbActionError.invalidCarbs +- } +- +- guard amountInGrams <= LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram()) else { +- throw CarbActionError.exceedsMaxCarbs +- } ++ let startDate = startDate ?? Date() ++ try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) + + let maxAllowedConflictingAmountInGrams = 0.0 // Not allowing any carbs + let minTreatmentIntervalInMinutes = 10.0 +@@ -2583,19 +2573,80 @@ extension LoopDataManager: ServicesManagerDelegate { + throw CarbActionError.conflictingTreatments(conflictingCarbAmount, minTreatmentIntervalInMinutes) + } + +- if let startDate = startDate { +- let maxStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) +- let minStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryPastTime) +- guard startDate <= maxStartDate && startDate >= minStartDate else { +- throw CarbActionError.invalidStartDate(startDate) +- } +- } +- + let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) +- let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate ?? Date(), foodType: foodType, absorptionTime: absorptionTime) ++ let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate, foodType: foodType, absorptionTime: absorptionTime) + + return try await deliverCarbEntry(candidateCarbEntry) + } ++ ++ /// Remote edits and deletes may only target entries this app created within the last 24 hours. ++ static let remoteCarbEditWindow: TimeInterval = .hours(24) ++ ++ func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry { ++ let entry = try await remoteEditableCarbEntry(syncIdentifier: syncIdentifier) ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ deleteCarbEntry(entry) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ return entry ++ } ++ ++ func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry { ++ let existing = try await remoteEditableCarbEntry(syncIdentifier: syncIdentifier) ++ try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) ++ ++ let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) ++ let replacement = NewCarbEntry(quantity: quantity, startDate: startDate, foodType: foodType ?? existing.foodType, absorptionTime: absorptionTime) ++ ++ return try await withCheckedThrowingContinuation { continuation in ++ addCarbEntry(replacement, replacing: existing) { result in ++ switch result { ++ case .success(let storedCarbEntry): ++ continuation.resume(returning: storedCarbEntry) ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ } ++ ++ private func validateRemoteCarbParameters(amountInGrams: Double, absorptionTime: TimeInterval, startDate: Date) throws { ++ if absorptionTime < LoopConstants.minCarbAbsorptionTime || absorptionTime > LoopConstants.maxCarbAbsorptionTime { ++ throw CarbActionError.invalidAbsorptionTime(absorptionTime) ++ } ++ ++ guard amountInGrams > 0.0 else { ++ throw CarbActionError.invalidCarbs ++ } ++ ++ guard amountInGrams <= LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram()) else { ++ throw CarbActionError.exceedsMaxCarbs ++ } ++ ++ let maxStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) ++ let minStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryPastTime) ++ guard startDate <= maxStartDate && startDate >= minStartDate else { ++ throw CarbActionError.invalidStartDate(startDate) ++ } ++ } ++ ++ private func remoteEditableCarbEntry(syncIdentifier: String) async throws -> StoredCarbEntry { ++ let now = Date() ++ let entries = try await carbStore.getCarbEntries(start: now.addingTimeInterval(-Self.remoteCarbEditWindow), end: now.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime)) ++ guard let entry = entries.first(where: { $0.syncIdentifier == syncIdentifier }) else { ++ throw CarbActionError.entryNotFound ++ } ++ guard entry.createdByCurrentApp else { ++ throw CarbActionError.entryNotEditable ++ } ++ return entry ++ } + + enum CarbActionError: LocalizedError { + +@@ -2604,6 +2655,8 @@ extension LoopDataManager: ServicesManagerDelegate { + case exceedsMaxCarbs + case invalidCarbs + case conflictingTreatments(_ conflictingAmountInGrams: Double, _ minutesRequiredBetweenTreatments: TimeInterval) ++ case entryNotFound ++ case entryNotEditable + + var errorDescription: String? { + switch self { +@@ -2611,6 +2664,10 @@ extension LoopDataManager: ServicesManagerDelegate { + return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Carb error description: carbs exceed maximum amount.") + case .invalidCarbs: + return NSLocalizedString("Invalid carb amount", comment: "Carb error description: invalid carb amount.") ++ case .entryNotFound: ++ return NSLocalizedString("Carb entry not found or older than 24 hours", comment: "Carb error description: remote edit target not found.") ++ case .entryNotEditable: ++ return NSLocalizedString("Carb entry was not created by Loop", comment: "Carb error description: remote edit target not created by this app.") + case .invalidAbsorptionTime(let absorptionTime): + let absorptionHoursFormatted = Self.numberFormatter.string(from: absorptionTime.hours) ?? "" + return String(format: NSLocalizedString("Invalid absorption time: %1$@ hours", comment: "Carb error description: invalid absorption time. (1: Input duration in hours)."), absorptionHoursFormatted) +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 17cff75d..6cefc092 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -201,6 +201,36 @@ extension NotificationManager { + UNUserNotificationCenter.current().add(request) + } + ++ @MainActor ++ static func sendRemoteCarbChangeNotification(body: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [body, "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: LoopNotificationCategory.remoteCarbs.rawValue, ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ @MainActor ++ static func sendRemoteCarbChangeFailureNotification(body: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [body, error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: LoopNotificationCategory.remoteCarbsFailure.rawValue, ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ + static func sendMissedMealNotification(mealStart: Date, amountInGrams: Double, delay: TimeInterval? = nil) { + let notification = UNMutableNotificationContent() + /// Notifications should expire after the missed meal is no longer relevant +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index f5f0d3c5..161a42e3 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -240,6 +240,8 @@ public protocol ServicesManagerDelegate: AnyObject { + func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws -> TemporaryScheduleOverride + func cancelCurrentOverride() async throws -> TemporaryScheduleOverride + func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry ++ func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry ++ func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry + } + + // MARK: - StatefulPluggableDelegate +@@ -358,6 +360,39 @@ extension ServicesManager: ServiceDelegate { + } + } + ++ func deleteRemoteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry { ++ do { ++ guard let servicesManagerDelegate else { ++ throw CarbActionError.internalError ++ } ++ let deleted = try await servicesManagerDelegate.deleteCarbs(syncIdentifier: syncIdentifier) ++ let body = String(format: NSLocalizedString("Remote Carbs Delete: %d grams removed", comment: "The notification body for a successful remote carb delete. (1: Carb amount in grams)"), Int(deleted.quantity.doubleValue(for: .gram()))) ++ await NotificationManager.sendRemoteCarbChangeNotification(body: body) ++ await remoteDataServicesManager.triggerUpload(for: .carb) ++ return deleted ++ } catch { ++ let body = NSLocalizedString("Remote Carbs Delete", comment: "The notification body prefix for a failed remote carb delete") ++ await NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) ++ throw error ++ } ++ } ++ ++ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry { ++ let body = String(format: NSLocalizedString("Remote Carbs Edit: %d grams", comment: "The carb amount message for a remote carb edit notification. (1: Carb amount in grams)"), Int(amountInGrams)) ++ do { ++ guard let servicesManagerDelegate else { ++ throw CarbActionError.internalError ++ } ++ let replacement = try await servicesManagerDelegate.replaceCarbs(syncIdentifier: syncIdentifier, amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) ++ await NotificationManager.sendRemoteCarbChangeNotification(body: body) ++ await remoteDataServicesManager.triggerUpload(for: .carb) ++ return replacement ++ } catch { ++ await NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) ++ throw error ++ } ++ } ++ + func deliverRemoteBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry { + do { + guard let servicesManagerDosingDelegate else { +diff --git a/Loop/LoopTests/Mock Stores/MockCarbStore.swift b/Loop/LoopTests/Mock Stores/MockCarbStore.swift +index 4a5c016e..dd35a787 100644 +--- a/Loop/LoopTests/Mock Stores/MockCarbStore.swift ++++ b/Loop/LoopTests/Mock Stores/MockCarbStore.swift +@@ -12,6 +12,7 @@ import LoopKit + + class MockCarbStore: CarbStoreProtocol { + var carbHistory: [StoredCarbEntry]? ++ var carbEntries: [StoredCarbEntry] = [] + + init(for scenario: DosingTestScenario = .flatAndStable) { + self.scenario = scenario // The store returns different effect values based on the scenario +@@ -72,6 +73,10 @@ class MockCarbStore: CarbStoreProtocol { + completion(.failure(.notConfigured)) + } + ++ func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] { ++ return carbEntries ++ } ++ + func getCarbStatus(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult<[CarbStatus]>) -> Void) { + completion(.failure(.notConfigured)) + } +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 6f691598..858a5621 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -19,4 +19,6 @@ public protocol RemoteActionDelegate: AnyObject { + func cancelRemoteOverride() async throws -> TemporaryScheduleOverride + func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry + func deliverRemoteBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry ++ func deleteRemoteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry ++ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry + } +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index 14a7691..18143ab 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -32,12 +32,17 @@ + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; + A941B09029BD4A9E00F91340 /* CarbRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */; }; ++ FB42DD0865235D441918DA5E /* CarbEditRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */; }; ++ A2C1A69F3A61FD4E207637A7 /* CarbDeleteRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */; }; + A941B09129BD4A9E00F91340 /* BolusRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */; }; + A941B09229BD4A9F00F91340 /* OverrideCancelRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */; }; + A941B09329BD4A9F00F91340 /* RemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */; }; + A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */; }; + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */; }; + A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */; }; ++ AFFFFDFBBBA4F7DF3997E63E /* CarbEditRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */; }; ++ 73056A2FC515352D51EFFECF /* CarbDeleteRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */; }; ++ 5C1D2E3F4A5B6C7D8E9F0A1B /* RemoteNotificationCapabilitiesTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */; }; + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */; }; + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */; }; + A94DA7642353CF930082E26D /* StoredGlucoseSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */; }; +@@ -51,6 +56,8 @@ + A9AA6E5B29EB072E008FFA78 /* OverrideAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */; }; + A9AA6E6329EB07D7008FFA78 /* Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5D29EB07D6008FFA78 /* Action.swift */; }; + A9AA6E6429EB07D7008FFA78 /* CarbAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */; }; ++ 843DF14B73EC57EB6F0033DA /* CarbEditAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04073CA740189C4259808331 /* CarbEditAction.swift */; }; ++ 7E7294EAE3456F0C6DC10F37 /* CarbDeleteAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */; }; + A9AA6E6529EB07D7008FFA78 /* OverrideCancelAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */; }; + A9AA6E6629EB07D7008FFA78 /* BolusAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */; }; + A9B007B322BD6FF7000131DE /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B007B222BD6FF7000131DE /* LocalizedString.swift */; }; +@@ -170,12 +177,17 @@ + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; + A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbRemoteNotification.swift; sourceTree = ""; }; ++ 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditRemoteNotification.swift; sourceTree = ""; }; ++ A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteRemoteNotification.swift; sourceTree = ""; }; + A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRemoteNotification.swift; sourceTree = ""; }; + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotification.swift; sourceTree = ""; }; + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotification.swift; sourceTree = ""; }; + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideRemoteNotification.swift; sourceTree = ""; }; + A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRemoteNotificationTestCase.swift; sourceTree = ""; }; + A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbRemoteNotificationTestCase.swift; sourceTree = ""; }; ++ E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditRemoteNotificationTestCase.swift; sourceTree = ""; }; ++ 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteRemoteNotificationTestCase.swift; sourceTree = ""; }; ++ 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotificationCapabilitiesTestCase.swift; sourceTree = ""; }; + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotificationTestCase.swift; sourceTree = ""; }; + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideRemoteNotificationTestCase.swift; sourceTree = ""; }; + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredGlucoseSample.swift; sourceTree = ""; }; +@@ -191,6 +203,8 @@ + A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideAction.swift; sourceTree = ""; }; + A9AA6E5D29EB07D6008FFA78 /* Action.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbAction.swift; sourceTree = ""; }; ++ 04073CA740189C4259808331 /* CarbEditAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditAction.swift; sourceTree = ""; }; ++ 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteAction.swift; sourceTree = ""; }; + A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelAction.swift; sourceTree = ""; }; + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusAction.swift; sourceTree = ""; }; + A9B007B222BD6FF7000131DE /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; +@@ -330,6 +344,9 @@ + A91BAC2B22BC691A00ABF1BB /* Info.plist */, + A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */, + A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */, ++ E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */, ++ 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */, ++ 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */, + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, +@@ -371,6 +388,8 @@ + children = ( + A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */, + A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */, ++ 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */, ++ A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */, + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, +@@ -427,6 +446,8 @@ + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, ++ 04073CA740189C4259808331 /* CarbEditAction.swift */, ++ 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */, + A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */, + A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */, + ); +@@ -728,12 +749,16 @@ + A993990322BC7095005E39D3 /* NightscoutService.swift in Sources */, + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */, + A9AA6E6429EB07D7008FFA78 /* CarbAction.swift in Sources */, ++ 843DF14B73EC57EB6F0033DA /* CarbEditAction.swift in Sources */, ++ 7E7294EAE3456F0C6DC10F37 /* CarbDeleteAction.swift in Sources */, + A90E39A922BC7AD10016DFE8 /* Bundle.swift in Sources */, + A90E39A322BC782C0016DFE8 /* SyncCarbObject.swift in Sources */, + A941B09329BD4A9F00F91340 /* RemoteNotification.swift in Sources */, + A9AA6E6629EB07D7008FFA78 /* BolusAction.swift in Sources */, + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */, + A941B09029BD4A9E00F91340 /* CarbRemoteNotification.swift in Sources */, ++ FB42DD0865235D441918DA5E /* CarbEditRemoteNotification.swift in Sources */, ++ A2C1A69F3A61FD4E207637A7 /* CarbDeleteRemoteNotification.swift in Sources */, + A94DA7642353CF930082E26D /* StoredGlucoseSample.swift in Sources */, + A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */, + C1E703642506BE9400DAB534 /* ObjectIdCache.swift in Sources */, +@@ -769,6 +794,9 @@ + buildActionMask = 2147483647; + files = ( + A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */, ++ AFFFFDFBBBA4F7DF3997E63E /* CarbEditRemoteNotificationTestCase.swift in Sources */, ++ 73056A2FC515352D51EFFECF /* CarbDeleteRemoteNotificationTestCase.swift in Sources */, ++ 5C1D2E3F4A5B6C7D8E9F0A1B /* RemoteNotificationCapabilitiesTestCase.swift in Sources */, + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index b66fb97..7c780ea 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -160,7 +160,7 @@ extension StoredDosingDecision { + recommendedBolus: loopStatusRecommendedBolus, + enacted: automaticDoseDecision?.loopStatusEnacted, + failureReason: automaticDoseDecision?.loopStatusFailureReason, +- testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]]), ++ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]]), + overrideStatus: overrideStatus) + } + +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index 7adb37d..a968a64 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -24,7 +24,7 @@ public final class NightscoutService: Service { + + public static let localizedTitle = LocalizedString("Nightscout", comment: "The title of the Nightscout service") + +- public let objectIdCacheKeepTime = TimeInterval(24 * 60 * 60) ++ public let objectIdCacheKeepTime = TimeInterval(48 * 60 * 60) + + public weak var serviceDelegate: ServiceDelegate? + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index 29313b6..29d3563 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -14,6 +14,8 @@ public enum Action: Codable { + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) + case carbsEntry(CarbAction) ++ case carbsDelete(CarbDeleteAction) ++ case carbsEdit(CarbEditAction) + + public var description: String { + return "\(actionName) \(actionParameterDescription)" +@@ -23,6 +25,10 @@ public enum Action: Codable { + switch self { + case .carbsEntry: + return LocalizedString("Carb Entry", comment: "The remote action name for Carb Entry") ++ case .carbsDelete: ++ return LocalizedString("Delete Carbs", comment: "The remote action name for deleting a carb entry") ++ case .carbsEdit: ++ return LocalizedString("Edit Carbs", comment: "The remote action name for editing a carb entry") + case .bolusEntry: + return LocalizedString("Bolus Entry", comment: "The remote action name for Bolus Entry") + case .cancelTemporaryOverride: +@@ -39,6 +45,11 @@ public enum Action: Codable { + case .carbsEntry(let carbAction): + let amountFormatted = Self.numberFormatter.string(from: carbAction.amountInGrams as NSNumber) ?? "" + return "\(amountFormatted)" + " " + carbGramAbbreviation ++ case .carbsDelete(let deleteAction): ++ return String(deleteAction.syncIdentifier.prefix(8)) ++ case .carbsEdit(let editAction): ++ let amountFormatted = Self.numberFormatter.string(from: editAction.amountInGrams as NSNumber) ?? "" ++ return "\(amountFormatted)" + " " + carbGramAbbreviation + case .bolusEntry(let bolusAction): + let amountFormatted = Self.numberFormatter.string(from: bolusAction.amountInUnits as NSNumber) ?? "" + return "\(amountFormatted)" + " " + bolusUnitAbbreviation +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift +new file mode 100644 +index 0000000..77e9900 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift +@@ -0,0 +1,15 @@ ++// ++// CarbDeleteAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct CarbDeleteAction: Codable { ++ ++ public let syncIdentifier: String ++ ++ public init(syncIdentifier: String) { ++ self.syncIdentifier = syncIdentifier ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift +new file mode 100644 +index 0000000..0aea20b +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift +@@ -0,0 +1,23 @@ ++// ++// CarbEditAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct CarbEditAction: Codable { ++ ++ public let syncIdentifier: String ++ public let amountInGrams: Double ++ public let absorptionTime: TimeInterval ++ public let foodType: String? ++ public let startDate: Date ++ ++ public init(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String? = nil, startDate: Date) { ++ self.syncIdentifier = syncIdentifier ++ self.amountInGrams = amountInGrams ++ self.absorptionTime = absorptionTime ++ self.foodType = foodType ++ self.startDate = startDate ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 4457f80..aabb47f 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -21,12 +21,14 @@ class RemoteNotificationResponseManager { + let commandStatus: String + let commandType: String + let timestamp: TimeInterval ++ let syncIdentifier: String? + + enum CodingKeys: String, CodingKey { + case aps + case commandStatus = "command_status" + case commandType = "command_type" + case timestamp ++ case syncIdentifier = "sync_identifier" + } + } + +@@ -43,6 +45,8 @@ class RemoteNotificationResponseManager { + enum CommandType: String { + case bolus = "bolus" + case carbs = "carbs" ++ case carbsDelete = "carbs_delete" ++ case carbsEdit = "carbs_edit" + case override = "override" + case cancelOverride = "cancel_override" + case profileSwitch = "profile_switch" +@@ -52,7 +56,8 @@ class RemoteNotificationResponseManager { + to returnInfo: ReturnNotificationInfo?, + commandType: CommandType, + success: Bool, +- message: String ++ message: String, ++ syncIdentifier: String? = nil + ) async { + guard let returnInfo = returnInfo else { + os_log("No return notification info provided, skipping response", log: log, type: .info) +@@ -75,7 +80,8 @@ class RemoteNotificationResponseManager { + ), + commandStatus: success ? "success" : "failed", + commandType: commandType.rawValue, +- timestamp: Date().timeIntervalSince1970 ++ timestamp: Date().timeIntervalSince1970, ++ syncIdentifier: syncIdentifier + ) + + await sendPushNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift +new file mode 100644 +index 0000000..eda9134 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift +@@ -0,0 +1,44 @@ ++// ++// CarbDeleteRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++public struct CarbDeleteRemoteNotification: RemoteNotification, Codable { ++ ++ public let syncIdentifier: String ++ public let remoteAddress: String ++ public let expiration: Date? ++ public let sentAt: Date ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ enum CodingKeys: String, CodingKey { ++ case syncIdentifier = "carbs-delete" ++ case remoteAddress = "remote-address" ++ case expiration = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .carbsDelete(CarbDeleteAction(syncIdentifier: syncIdentifier)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ func expirationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification["carbs-delete"] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift +new file mode 100644 +index 0000000..f1ffb51 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift +@@ -0,0 +1,58 @@ ++// ++// CarbEditRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Replacement values use prefixed keys so an unpatched Loop never reads them as a new carb entry. ++public struct CarbEditRemoteNotification: RemoteNotification, Codable { ++ ++ public let syncIdentifier: String ++ public let amount: Double ++ public let absorptionInHours: Double ++ public let foodType: String? ++ public let startDate: Date ++ public let remoteAddress: String ++ public let expiration: Date? ++ public let sentAt: Date ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ enum CodingKeys: String, CodingKey { ++ case syncIdentifier = "carbs-edit" ++ case amount = "carbs-edit-entry" ++ case absorptionInHours = "carbs-edit-absorption-time" ++ case foodType = "carbs-edit-food-type" ++ case startDate = "carbs-edit-start-time" ++ case remoteAddress = "remote-address" ++ case expiration = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ public func absorptionTime() -> TimeInterval { ++ return TimeInterval(hours: absorptionInHours) ++ } ++ ++ func toRemoteAction() -> Action { ++ let action = CarbEditAction(syncIdentifier: syncIdentifier, amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate) ++ return .carbsEdit(action) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ func expirationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification["carbs-edit"] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index a0f58a0..1ea8b96 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -22,12 +22,22 @@ protocol RemoteNotification: Codable { + + func toRemoteAction() -> Action + func otpValidationRequired() -> Bool ++ func expirationRequired() -> Bool + func getReturnNotificationInfo() -> ReturnNotificationInfo? + + static func includedInNotification(_ notification: [String: Any]) -> Bool + } + ++/// Followers read this to know which remote commands this build accepts. ++enum RemoteNotificationCapabilities { ++ static let supported: [String] = ["carbs-delete", "carbs-edit"] ++} ++ + extension RemoteNotification { ++ ++ func expirationRequired() -> Bool { ++ return false ++ } + + var id: String { + //There is no unique identifier so we use the sent date +@@ -98,6 +108,10 @@ extension Dictionary { + func toRemoteNotification() throws -> RemoteNotification { + if BolusRemoteNotification.includedInNotification(self) { + return try BolusRemoteNotification(dictionary: self) ++ } else if CarbDeleteRemoteNotification.includedInNotification(self) { ++ return try CarbDeleteRemoteNotification(dictionary: self) ++ } else if CarbEditRemoteNotification.includedInNotification(self) { ++ return try CarbEditRemoteNotification(dictionary: self) + } else if CarbRemoteNotification.includedInNotification(self) { + return try CarbRemoteNotification(dictionary: self) + } else if OverrideRemoteNotification.includedInNotification(self) { +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +index 0912feb..bff69c8 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +@@ -46,6 +46,7 @@ class RemoteCommandSourceV1: RemoteCommandSource { + + var commandType: RemoteNotificationResponseManager.CommandType = .bolus + var successMessage = "" ++ var syncIdentifier: String? = nil + + do { + try await recentNotifications.trackReceivedRemoteNotification(remoteNotification, rawNotification: notification) +@@ -96,6 +97,32 @@ class RemoteCommandSourceV1: RemoteCommandSource { + status: .success(date: Date(), syncIdentifier: carbEntry.syncIdentifier ?? "", completionMessage: nil), + notificationJSON: notification + ) ++ case .carbsDelete(let deleteCommand): ++ commandType = .carbsDelete ++ syncIdentifier = deleteCommand.syncIdentifier ++ let deletedEntry = try await serviceDelegate.deleteRemoteCarbs(syncIdentifier: deleteCommand.syncIdentifier) ++ successMessage = "Carb entry deleted successfully" ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: deletedEntry.syncIdentifier ?? "", completionMessage: nil), ++ notificationJSON: notification ++ ) ++ case .carbsEdit(let editCommand): ++ commandType = .carbsEdit ++ syncIdentifier = editCommand.syncIdentifier ++ let replacementEntry = try await serviceDelegate.replaceRemoteCarbs( ++ syncIdentifier: editCommand.syncIdentifier, ++ amountInGrams: editCommand.amountInGrams, ++ absorptionTime: editCommand.absorptionTime, ++ foodType: editCommand.foodType, ++ startDate: editCommand.startDate ++ ) ++ successMessage = String(format: "Carb entry updated to %.1f g", editCommand.amountInGrams) ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: replacementEntry.syncIdentifier ?? "", completionMessage: nil), ++ notificationJSON: notification ++ ) + case .profileSwitch(let profileSwitchCommand): + commandType = .profileSwitch + guard let profileDelegate = serviceDelegate as? RemoteProfileActionDelegate else { +@@ -138,7 +165,8 @@ class RemoteCommandSourceV1: RemoteCommandSource { + to: returnInfo, + commandType: commandType, + success: true, +- message: successMessage ++ message: successMessage, ++ syncIdentifier: syncIdentifier + ) + } + } catch { +@@ -155,7 +183,8 @@ class RemoteCommandSourceV1: RemoteCommandSource { + to: returnInfo, + commandType: commandType, + success: false, +- message: "Command failed: \(error.localizedDescription)" ++ message: "Command failed: \(error.localizedDescription)", ++ syncIdentifier: syncIdentifier + ) + } + } +@@ -244,6 +273,14 @@ public class StoredRemoteNotification: NSObject, Codable { + self.init(notificationType: .carbs(carbNotification), notificationJSONData: notificationJSONData) + } + ++ convenience init(carbDeleteNotification: CarbDeleteRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .carbsDelete(carbDeleteNotification), notificationJSONData: notificationJSONData) ++ } ++ ++ convenience init(carbEditNotification: CarbEditRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .carbsEdit(carbEditNotification), notificationJSONData: notificationJSONData) ++ } ++ + convenience init(profileSwitchNotification: ProfileSwitchRemoteNotification, notificationJSONData: Data) { + self.init(notificationType: .profileSwitch(profileSwitchNotification), notificationJSONData: notificationJSONData) + } +@@ -331,6 +368,10 @@ public class StoredRemoteNotification: NSObject, Codable { + return bolusNotification + case let .carbs(carbNotification): + return carbNotification ++ case let .carbsDelete(carbDeleteNotification): ++ return carbDeleteNotification ++ case let .carbsEdit(carbEditNotification): ++ return carbEditNotification + case let .profileSwitch(profileSwitchNotification): + return profileSwitchNotification + case let .override(overrideNotification): +@@ -359,6 +400,8 @@ public class StoredRemoteNotification: NSObject, Codable { + public enum RemoteNotificationType: Codable { + case bolus(BolusRemoteNotification) + case carbs(CarbRemoteNotification) ++ case carbsDelete(CarbDeleteRemoteNotification) ++ case carbsEdit(CarbEditRemoteNotification) + case override(OverrideRemoteNotification) + case overrideCancel(OverrideCancelRemoteNotification) + case profileSwitch(ProfileSwitchRemoteNotification) +@@ -412,6 +455,12 @@ actor RecentNotifications { + } else if let carbNotification = remoteNotification as? CarbRemoteNotification { + let storedNotification = StoredRemoteNotification(carbNotification: carbNotification, notificationJSONData: data) + try storeNotification(storedNotification) ++ } else if let carbDeleteNotification = remoteNotification as? CarbDeleteRemoteNotification { ++ let storedNotification = StoredRemoteNotification(carbDeleteNotification: carbDeleteNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) ++ } else if let carbEditNotification = remoteNotification as? CarbEditRemoteNotification { ++ let storedNotification = StoredRemoteNotification(carbEditNotification: carbEditNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) + } else if let profileSwitchNotification = remoteNotification as? ProfileSwitchRemoteNotification { + let storedNotification = StoredRemoteNotification(profileSwitchNotification: profileSwitchNotification, notificationJSONData: data) + try storeNotification(storedNotification) +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +index fce4623..5be6e1c 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +@@ -16,6 +16,11 @@ struct RemoteCommandValidator { + } + + func validate(remoteNotification: RemoteNotification) throws { ++ if remoteNotification.expirationRequired() { ++ guard remoteNotification.expiration != nil else { ++ throw NotificationValidationError.missingExpiration ++ } ++ } + try validateExpirationDate(remoteNotification: remoteNotification) + if remoteNotification.otpValidationRequired() { + try validateOTP(remoteNotification: remoteNotification) +@@ -45,11 +50,14 @@ struct RemoteCommandValidator { + enum NotificationValidationError: LocalizedError { + case missingOTP + case expiredNotification(sentDate: Date, receivedDate: Date) ++ case missingExpiration + + var errorDescription: String? { + switch self { + case .missingOTP: + return LocalizedString("Missing OTP", comment: "Remote command error description: Missing OTP.") ++ case .missingExpiration: ++ return LocalizedString("Missing expiration", comment: "Remote command error description: missing expiration.") + case .expiredNotification(let sentDate, let receivedDate): + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "h:mm:ss a" +diff --git a/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..f03768c +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift +@@ -0,0 +1,61 @@ ++// ++// CarbDeleteRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++@testable import NightscoutServiceKit ++ ++final class CarbDeleteRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseCarbDeleteNotification_ValidPayload_Succeeds() throws { ++ let syncIdentifier = "3F2504E0-4F89-11D3-9A0C-0305E82C3301" ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-delete": syncIdentifier as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ let remoteNotification = try notification.toRemoteNotification() ++ ++ let deleteNotification = try XCTUnwrap(remoteNotification as? CarbDeleteRemoteNotification) ++ XCTAssertEqual(deleteNotification.syncIdentifier, syncIdentifier) ++ XCTAssertEqual(deleteNotification.otp, "12345") ++ XCTAssertTrue(deleteNotification.otpValidationRequired()) ++ XCTAssertTrue(deleteNotification.expirationRequired()) ++ guard case .carbsDelete(let action) = deleteNotification.toRemoteAction() else { ++ return XCTFail("Expected carbsDelete action") ++ } ++ XCTAssertEqual(action.syncIdentifier, syncIdentifier) ++ } ++ ++ func testValidate_MissingExpiration_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "carbs-delete": "abc" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ let remoteNotification = try notification.toRemoteNotification() ++ let validator = RemoteCommandValidator(otpManager: OTPTestCoordinator().createOTPManager()) ++ ++ XCTAssertThrowsError(try validator.validate(remoteNotification: remoteNotification)) { error in ++ guard case RemoteCommandValidator.NotificationValidationError.missingExpiration = error else { ++ return XCTFail("Unexpected error \(error)") ++ } ++ } ++ } ++ ++ func testParseCarbDeleteNotification_MissingSentAt_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "expiration": "2099-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-delete": "abc" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ XCTAssertThrowsError(try notification.toRemoteNotification()) ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..989f7c8 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift +@@ -0,0 +1,108 @@ ++// ++// CarbEditRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++@testable import NightscoutServiceKit ++ ++final class CarbEditRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseCarbEditNotification_ValidPayload_Succeeds() throws { ++ let syncIdentifier = "3F2504E0-4F89-11D3-9A0C-0305E82C3301" ++ let startDateString = "2023-02-25T20:46:35.778Z" ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": syncIdentifier as AnyObject, ++ "carbs-edit-entry": 22.0 as AnyObject, ++ "carbs-edit-absorption-time": 3.0 as AnyObject, ++ "carbs-edit-food-type": "🍕" as AnyObject, ++ "carbs-edit-start-time": startDateString as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ let remoteNotification = try notification.toRemoteNotification() ++ ++ let editNotification = try XCTUnwrap(remoteNotification as? CarbEditRemoteNotification) ++ XCTAssertEqual(editNotification.syncIdentifier, syncIdentifier) ++ XCTAssertEqual(editNotification.amount, 22.0) ++ XCTAssertEqual(editNotification.absorptionTime(), TimeInterval(hours: 3.0)) ++ XCTAssertEqual(editNotification.foodType, "🍕") ++ XCTAssertEqual(editNotification.startDate, dateFormatter().date(from: startDateString)) ++ XCTAssertTrue(editNotification.otpValidationRequired()) ++ XCTAssertTrue(editNotification.expirationRequired()) ++ guard case .carbsEdit(let action) = editNotification.toRemoteAction() else { ++ return XCTFail("Expected carbsEdit action") ++ } ++ XCTAssertEqual(action.syncIdentifier, syncIdentifier) ++ XCTAssertEqual(action.amountInGrams, 22.0) ++ XCTAssertEqual(action.absorptionTime, TimeInterval(hours: 3.0)) ++ XCTAssertEqual(action.startDate, dateFormatter().date(from: startDateString)) ++ } ++ ++ func testParseCarbEditNotification_FoodTypeAbsent_Succeeds() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-absorption-time": 2.0 as AnyObject, ++ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ let editNotification = try XCTUnwrap(try notification.toRemoteNotification() as? CarbEditRemoteNotification) ++ XCTAssertNil(editNotification.foodType) ++ } ++ ++ func testParseCarbEditNotification_MissingStartTime_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-absorption-time": 2.0 as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ XCTAssertThrowsError(try notification.toRemoteNotification()) ++ } ++ ++ func testParseCarbEditNotification_MissingAbsorptionTime_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ XCTAssertThrowsError(try notification.toRemoteNotification()) ++ } ++ ++ func testParseCarbEditNotification_DoesNotDecodeAsCarbEntry() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-absorption-time": 2.0 as AnyObject, ++ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ XCTAssertFalse(CarbRemoteNotification.includedInNotification(notification)) ++ XCTAssertTrue(CarbEditRemoteNotification.includedInNotification(notification)) ++ } ++ ++ func dateFormatter() -> ISO8601DateFormatter { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift b/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift +new file mode 100644 +index 0000000..e284d3f +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift +@@ -0,0 +1,21 @@ ++// ++// RemoteNotificationCapabilitiesTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++@testable import NightscoutServiceKit ++ ++final class RemoteNotificationCapabilitiesTestCase: XCTestCase { ++ ++ func testSupportedCommands_MatchNotificationKeys() { ++ XCTAssertEqual(RemoteNotificationCapabilities.supported, ["carbs-delete", "carbs-edit"]) ++ XCTAssertTrue(CarbDeleteRemoteNotification.includedInNotification(["carbs-delete": "id"])) ++ XCTAssertTrue(CarbEditRemoteNotification.includedInNotification(["carbs-edit": "id"])) ++ for key in RemoteNotificationCapabilities.supported { ++ let notification: [String: Any] = [key: "id"] ++ XCTAssertTrue(CarbDeleteRemoteNotification.includedInNotification(notification) ++ || CarbEditRemoteNotification.includedInNotification(notification), key) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +index 4ce6ab7..bb9423b 100644 +--- a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift ++++ b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +@@ -54,6 +54,10 @@ extension StoredRemoteNotification { + return "Bolus" + case .carbs: + return "Carbs" ++ case .carbsDelete: ++ return "Delete Carbs" ++ case .carbsEdit: ++ return "Edit Carbs" + case .override: + return "Override" + case .profileSwitch: +@@ -76,6 +80,10 @@ extension StoredRemoteNotification { + return String(format: "%.2f U", notification.amount) + case .carbs(let notification): + return String(format: "%.0f g", notification.amount) ++ case .carbsDelete(let notification): ++ return String(notification.syncIdentifier.prefix(8)) ++ case .carbsEdit(let notification): ++ return String(format: "%.0f g", notification.amount) + case .override(let notification): + return notification.name + case .profileSwitch(let notification): +diff --git a/remote_carb_edit.loopcustomization b/remote_carb_edit.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/remote_carb_edit/nextdev_remote_carb_edit_remote_profile.patch b/remote_carb_edit/nextdev_remote_carb_edit_remote_profile.patch new file mode 100644 index 0000000..42a5a5c --- /dev/null +++ b/remote_carb_edit/nextdev_remote_carb_edit_remote_profile.patch @@ -0,0 +1,953 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index 28f07307..61cdb5c0 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -1416,32 +1416,65 @@ extension LoopDataManager: ServicesManagerDelegate { + //Carb Entry + + func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { +- + let absorptionTime = absorptionTime ?? LoopCoreConstants.defaultCarbAbsorptionTimes.medium ++ let startDate = startDate ?? now ++ try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) ++ ++ let quantity = LoopQuantity(unit: .gram, doubleValue: amountInGrams) ++ let candidateCarbEntry = NewCarbEntry(syncIdentifier: UUID().uuidString, quantity: quantity, startDate: startDate, foodType: foodType, absorptionTime: absorptionTime) ++ ++ let _ = try await carbStore.addCarbEntry(candidateCarbEntry) ++ } ++ ++ /// Remote edits and deletes may only target entries this app created within the last 24 hours. ++ static let remoteCarbEditWindow: TimeInterval = .hours(24) ++ ++ func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry { ++ let entry = try await remoteEditableCarbEntry(syncIdentifier: syncIdentifier) ++ try await deleteCarbEntry(entry) ++ return entry ++ } ++ ++ func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry { ++ let existing = try await remoteEditableCarbEntry(syncIdentifier: syncIdentifier) ++ try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) ++ ++ let quantity = LoopQuantity(unit: .gram, doubleValue: amountInGrams) ++ let update = CarbEntryUpdate(quantity: quantity, startDate: startDate, foodType: foodType ?? existing.foodType, absorptionTime: absorptionTime) ++ ++ return try await replaceCarbEntry(existing, with: update) ++ } ++ ++ private func validateRemoteCarbParameters(amountInGrams: Double, absorptionTime: TimeInterval, startDate: Date) throws { + if absorptionTime < LoopConstants.minCarbAbsorptionTime || absorptionTime > LoopConstants.maxCarbAbsorptionTime { + throw CarbActionError.invalidAbsorptionTime(absorptionTime) + } +- ++ + guard amountInGrams > 0.0 else { + throw CarbActionError.invalidCarbs + } +- ++ + guard amountInGrams <= LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram) else { + throw CarbActionError.exceedsMaxCarbs + } +- +- if let startDate = startDate { +- let maxStartDate = now.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) +- let minStartDate = now.addingTimeInterval(LoopConstants.maxCarbEntryPastTime) +- guard startDate <= maxStartDate && startDate >= minStartDate else { +- throw CarbActionError.invalidStartDate(startDate) +- } ++ ++ let maxStartDate = now.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) ++ let minStartDate = now.addingTimeInterval(LoopConstants.maxCarbEntryPastTime) ++ guard startDate <= maxStartDate && startDate >= minStartDate else { ++ throw CarbActionError.invalidStartDate(startDate) + } +- +- let quantity = LoopQuantity(unit: .gram, doubleValue: amountInGrams) +- let candidateCarbEntry = NewCarbEntry(syncIdentifier: UUID().uuidString, quantity: quantity, startDate: startDate ?? now, foodType: foodType, absorptionTime: absorptionTime) ++ } + +- let _ = try await carbStore.addCarbEntry(candidateCarbEntry) ++ private func remoteEditableCarbEntry(syncIdentifier: String) async throws -> StoredCarbEntry { ++ let now = now ++ let entries = try await carbStore.getCarbEntries(start: now.addingTimeInterval(-Self.remoteCarbEditWindow), end: now.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime)) ++ guard let entry = entries.first(where: { $0.syncIdentifier == syncIdentifier }) else { ++ throw CarbActionError.entryNotFound ++ } ++ guard entry.createdByCurrentApp else { ++ throw CarbActionError.entryNotEditable ++ } ++ return entry + } + + enum CarbActionError: LocalizedError { +@@ -1450,6 +1483,8 @@ extension LoopDataManager: ServicesManagerDelegate { + case invalidStartDate(Date) + case exceedsMaxCarbs + case invalidCarbs ++ case entryNotFound ++ case entryNotEditable + + var errorDescription: String? { + switch self { +@@ -1457,6 +1492,10 @@ extension LoopDataManager: ServicesManagerDelegate { + return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Carb error description: carbs exceed maximum amount.") + case .invalidCarbs: + return NSLocalizedString("Invalid carb amount", comment: "Carb error description: invalid carb amount.") ++ case .entryNotFound: ++ return NSLocalizedString("Carb entry not found or older than 24 hours", comment: "Carb error description: remote edit target not found.") ++ case .entryNotEditable: ++ return NSLocalizedString("Carb entry was not created by Loop", comment: "Carb error description: remote edit target not created by this app.") + case .invalidAbsorptionTime(let absorptionTime): + let absorptionHoursFormatted = Self.numberFormatter.string(from: absorptionTime.hours) ?? "" + return String(format: NSLocalizedString("Invalid absorption time: %1$@ hours", comment: "Carb error description: invalid absorption time. (1: Input duration in hours)."), absorptionHoursFormatted) +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index c4d0f32e..1cf442dd 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -220,6 +220,34 @@ extension NotificationManager { + UNUserNotificationCenter.current().add(request) + } + ++ static func sendRemoteCarbChangeNotification(body: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [body, "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: LoopNotificationCategory.remoteCarbs.rawValue, ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteCarbChangeFailureNotification(body: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [body, error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: LoopNotificationCategory.remoteCarbsFailure.rawValue, ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ + static func sendMissedMealNotification(mealStart: Date, amountInGrams: Double, delay: TimeInterval? = nil) { + let notification = UNMutableNotificationContent() + /// Notifications should expire after the missed meal is no longer relevant +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 15602f08..ffafea70 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -252,6 +252,8 @@ public protocol ServicesManagerDelegate: AnyObject { + func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws + func cancelCurrentOverride() async throws + func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws ++ func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry ++ func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry + } + + // MARK: - StatefulPluggableDelegate +@@ -337,6 +339,48 @@ extension ServicesManager: ServiceDelegate { + } + } + ++ func deleteRemoteCarbs(syncIdentifier: String) async throws { ++ do { ++ guard let servicesManagerDelegate else { ++ throw RemoteCarbCommandError.internalError ++ } ++ let deleted = try await servicesManagerDelegate.deleteCarbs(syncIdentifier: syncIdentifier) ++ let body = String(format: NSLocalizedString("Remote Carbs Delete: %d grams removed", comment: "The notification body for a successful remote carb delete. (1: Carb amount in grams)"), Int(deleted.quantity.doubleValue(for: .gram))) ++ NotificationManager.sendRemoteCarbChangeNotification(body: body) ++ await remoteDataServicesManager.performUpload(for: .carb) ++ } catch { ++ let body = NSLocalizedString("Remote Carbs Delete", comment: "The notification body prefix for a failed remote carb delete") ++ NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) ++ throw error ++ } ++ } ++ ++ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws { ++ let body = String(format: NSLocalizedString("Remote Carbs Edit: %d grams", comment: "The carb amount message for a remote carb edit notification. (1: Carb amount in grams)"), Int(amountInGrams)) ++ do { ++ guard let servicesManagerDelegate else { ++ throw RemoteCarbCommandError.internalError ++ } ++ _ = try await servicesManagerDelegate.replaceCarbs(syncIdentifier: syncIdentifier, amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) ++ NotificationManager.sendRemoteCarbChangeNotification(body: body) ++ await remoteDataServicesManager.performUpload(for: .carb) ++ } catch { ++ NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteCarbCommandError: LocalizedError { ++ case internalError ++ ++ var errorDescription: String? { ++ switch self { ++ case .internalError: ++ return NSLocalizedString("Internal error", comment: "Remote carb command error description: internal error.") ++ } ++ } ++ } ++ + func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?) async throws { + do { + +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index a7c9c653..5f1f37f3 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -19,4 +19,6 @@ public protocol RemoteActionDelegate: AnyObject { + func cancelRemoteOverride() async throws + func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws + func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?) async throws ++ func deleteRemoteCarbs(syncIdentifier: String) async throws ++ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws + } +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index cf6883e..eb8f03d 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -31,12 +31,17 @@ + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; + A941B09029BD4A9E00F91340 /* CarbRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */; }; ++ FB42DD0865235D441918DA5E /* CarbEditRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */; }; ++ A2C1A69F3A61FD4E207637A7 /* CarbDeleteRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */; }; + A941B09129BD4A9E00F91340 /* BolusRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */; }; + A941B09229BD4A9F00F91340 /* OverrideCancelRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */; }; + A941B09329BD4A9F00F91340 /* RemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */; }; + A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */; }; + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */; }; + A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */; }; ++ AFFFFDFBBBA4F7DF3997E63E /* CarbEditRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */; }; ++ 73056A2FC515352D51EFFECF /* CarbDeleteRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */; }; ++ 5C1D2E3F4A5B6C7D8E9F0A1B /* RemoteNotificationCapabilitiesTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */; }; + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */; }; + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */; }; + A94DA7642353CF930082E26D /* StoredGlucoseSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */; }; +@@ -50,6 +55,8 @@ + A9AA6E5B29EB072E008FFA78 /* OverrideAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */; }; + A9AA6E6329EB07D7008FFA78 /* Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5D29EB07D6008FFA78 /* Action.swift */; }; + A9AA6E6429EB07D7008FFA78 /* CarbAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */; }; ++ 843DF14B73EC57EB6F0033DA /* CarbEditAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04073CA740189C4259808331 /* CarbEditAction.swift */; }; ++ 7E7294EAE3456F0C6DC10F37 /* CarbDeleteAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */; }; + A9AA6E6529EB07D7008FFA78 /* OverrideCancelAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */; }; + A9AA6E6629EB07D7008FFA78 /* BolusAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */; }; + A9B007B322BD6FF7000131DE /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B007B222BD6FF7000131DE /* LocalizedString.swift */; }; +@@ -168,12 +175,17 @@ + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; + A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbRemoteNotification.swift; sourceTree = ""; }; ++ 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditRemoteNotification.swift; sourceTree = ""; }; ++ A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteRemoteNotification.swift; sourceTree = ""; }; + A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRemoteNotification.swift; sourceTree = ""; }; + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotification.swift; sourceTree = ""; }; + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotification.swift; sourceTree = ""; }; + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideRemoteNotification.swift; sourceTree = ""; }; + A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRemoteNotificationTestCase.swift; sourceTree = ""; }; + A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbRemoteNotificationTestCase.swift; sourceTree = ""; }; ++ E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditRemoteNotificationTestCase.swift; sourceTree = ""; }; ++ 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteRemoteNotificationTestCase.swift; sourceTree = ""; }; ++ 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotificationCapabilitiesTestCase.swift; sourceTree = ""; }; + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotificationTestCase.swift; sourceTree = ""; }; + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideRemoteNotificationTestCase.swift; sourceTree = ""; }; + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredGlucoseSample.swift; sourceTree = ""; }; +@@ -189,6 +201,8 @@ + A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideAction.swift; sourceTree = ""; }; + A9AA6E5D29EB07D6008FFA78 /* Action.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbAction.swift; sourceTree = ""; }; ++ 04073CA740189C4259808331 /* CarbEditAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditAction.swift; sourceTree = ""; }; ++ 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteAction.swift; sourceTree = ""; }; + A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelAction.swift; sourceTree = ""; }; + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusAction.swift; sourceTree = ""; }; + A9B007B222BD6FF7000131DE /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; +@@ -327,6 +341,9 @@ + A91BAC2B22BC691A00ABF1BB /* Info.plist */, + A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */, + A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */, ++ E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */, ++ 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */, ++ 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */, + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, +@@ -368,6 +385,8 @@ + children = ( + A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */, + A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */, ++ 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */, ++ A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */, + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, +@@ -424,6 +443,8 @@ + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, ++ 04073CA740189C4259808331 /* CarbEditAction.swift */, ++ 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */, + A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */, + A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */, + ); +@@ -725,12 +746,16 @@ + A993990322BC7095005E39D3 /* NightscoutService.swift in Sources */, + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */, + A9AA6E6429EB07D7008FFA78 /* CarbAction.swift in Sources */, ++ 843DF14B73EC57EB6F0033DA /* CarbEditAction.swift in Sources */, ++ 7E7294EAE3456F0C6DC10F37 /* CarbDeleteAction.swift in Sources */, + A90E39A922BC7AD10016DFE8 /* Bundle.swift in Sources */, + A90E39A322BC782C0016DFE8 /* SyncCarbObject.swift in Sources */, + A941B09329BD4A9F00F91340 /* RemoteNotification.swift in Sources */, + A9AA6E6629EB07D7008FFA78 /* BolusAction.swift in Sources */, + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */, + A941B09029BD4A9E00F91340 /* CarbRemoteNotification.swift in Sources */, ++ FB42DD0865235D441918DA5E /* CarbEditRemoteNotification.swift in Sources */, ++ A2C1A69F3A61FD4E207637A7 /* CarbDeleteRemoteNotification.swift in Sources */, + A94DA7642353CF930082E26D /* StoredGlucoseSample.swift in Sources */, + A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */, + C1E703642506BE9400DAB534 /* ObjectIdCache.swift in Sources */, +@@ -765,6 +790,9 @@ + buildActionMask = 2147483647; + files = ( + A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */, ++ AFFFFDFBBBA4F7DF3997E63E /* CarbEditRemoteNotificationTestCase.swift in Sources */, ++ 73056A2FC515352D51EFFECF /* CarbDeleteRemoteNotificationTestCase.swift in Sources */, ++ 5C1D2E3F4A5B6C7D8E9F0A1B /* RemoteNotificationCapabilitiesTestCase.swift in Sources */, + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index 2dec600..1b43f76 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -180,7 +180,7 @@ extension StoredDosingDecision { + enacted: automaticDoseDecision?.loopStatusEnacted, + failureReason: automaticDoseDecision?.loopStatusFailureReason, + failureCode: automaticDoseDecision?.errors.first?.id, +- testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]], ++ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]], + algorithmExperiments: (automaticDoseDecision?.algorithmExperiments ?? algorithmExperiments)?.map { $0.jsonObject }), + overrideStatus: overrideStatus) + } +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index 73db786..3a513fb 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -23,7 +23,7 @@ public final class NightscoutService: Service { + + public static let localizedTitle = LocalizedString("Nightscout", comment: "The title of the Nightscout service") + +- public let objectIdCacheKeepTime = TimeInterval(24 * 60 * 60) ++ public let objectIdCacheKeepTime = TimeInterval(48 * 60 * 60) + + public weak var serviceDelegate: ServiceDelegate? + +@@ -446,6 +446,7 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + var commandType: RemoteNotificationResponseManager.CommandType = .bolus // Default, will be set in switch + var success = false + var message = "" ++ var syncIdentifier: String? = nil + + do { + switch action { +@@ -499,6 +500,26 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + ) + success = true + message = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) ++ ++ case .carbsDelete(let deleteCommand): ++ commandType = .carbsDelete ++ syncIdentifier = deleteCommand.syncIdentifier ++ try await self.serviceDelegate?.deleteRemoteCarbs(syncIdentifier: deleteCommand.syncIdentifier) ++ success = true ++ message = "Carb entry deleted successfully" ++ ++ case .carbsEdit(let editCommand): ++ commandType = .carbsEdit ++ syncIdentifier = editCommand.syncIdentifier ++ try await self.serviceDelegate?.replaceRemoteCarbs( ++ syncIdentifier: editCommand.syncIdentifier, ++ amountInGrams: editCommand.amountInGrams, ++ absorptionTime: editCommand.absorptionTime, ++ foodType: editCommand.foodType, ++ startDate: editCommand.startDate ++ ) ++ success = true ++ message = String(format: "Carb entry updated to %.1f g", editCommand.amountInGrams) + } + } catch { + message = "Command failed: \(error.localizedDescription)" +@@ -508,7 +529,8 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + to: returnInfo, + commandType: commandType, + success: false, +- message: message ++ message: message, ++ syncIdentifier: syncIdentifier + ) + } + throw error +@@ -520,7 +542,8 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + to: returnInfo, + commandType: commandType, + success: success, +- message: message ++ message: message, ++ syncIdentifier: syncIdentifier + ) + } + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index 29313b6..29d3563 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -14,6 +14,8 @@ public enum Action: Codable { + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) + case carbsEntry(CarbAction) ++ case carbsDelete(CarbDeleteAction) ++ case carbsEdit(CarbEditAction) + + public var description: String { + return "\(actionName) \(actionParameterDescription)" +@@ -23,6 +25,10 @@ public enum Action: Codable { + switch self { + case .carbsEntry: + return LocalizedString("Carb Entry", comment: "The remote action name for Carb Entry") ++ case .carbsDelete: ++ return LocalizedString("Delete Carbs", comment: "The remote action name for deleting a carb entry") ++ case .carbsEdit: ++ return LocalizedString("Edit Carbs", comment: "The remote action name for editing a carb entry") + case .bolusEntry: + return LocalizedString("Bolus Entry", comment: "The remote action name for Bolus Entry") + case .cancelTemporaryOverride: +@@ -39,6 +45,11 @@ public enum Action: Codable { + case .carbsEntry(let carbAction): + let amountFormatted = Self.numberFormatter.string(from: carbAction.amountInGrams as NSNumber) ?? "" + return "\(amountFormatted)" + " " + carbGramAbbreviation ++ case .carbsDelete(let deleteAction): ++ return String(deleteAction.syncIdentifier.prefix(8)) ++ case .carbsEdit(let editAction): ++ let amountFormatted = Self.numberFormatter.string(from: editAction.amountInGrams as NSNumber) ?? "" ++ return "\(amountFormatted)" + " " + carbGramAbbreviation + case .bolusEntry(let bolusAction): + let amountFormatted = Self.numberFormatter.string(from: bolusAction.amountInUnits as NSNumber) ?? "" + return "\(amountFormatted)" + " " + bolusUnitAbbreviation +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift +new file mode 100644 +index 0000000..77e9900 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift +@@ -0,0 +1,15 @@ ++// ++// CarbDeleteAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct CarbDeleteAction: Codable { ++ ++ public let syncIdentifier: String ++ ++ public init(syncIdentifier: String) { ++ self.syncIdentifier = syncIdentifier ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift +new file mode 100644 +index 0000000..0aea20b +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift +@@ -0,0 +1,23 @@ ++// ++// CarbEditAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct CarbEditAction: Codable { ++ ++ public let syncIdentifier: String ++ public let amountInGrams: Double ++ public let absorptionTime: TimeInterval ++ public let foodType: String? ++ public let startDate: Date ++ ++ public init(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String? = nil, startDate: Date) { ++ self.syncIdentifier = syncIdentifier ++ self.amountInGrams = amountInGrams ++ self.absorptionTime = absorptionTime ++ self.foodType = foodType ++ self.startDate = startDate ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 4457f80..aabb47f 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -21,12 +21,14 @@ class RemoteNotificationResponseManager { + let commandStatus: String + let commandType: String + let timestamp: TimeInterval ++ let syncIdentifier: String? + + enum CodingKeys: String, CodingKey { + case aps + case commandStatus = "command_status" + case commandType = "command_type" + case timestamp ++ case syncIdentifier = "sync_identifier" + } + } + +@@ -43,6 +45,8 @@ class RemoteNotificationResponseManager { + enum CommandType: String { + case bolus = "bolus" + case carbs = "carbs" ++ case carbsDelete = "carbs_delete" ++ case carbsEdit = "carbs_edit" + case override = "override" + case cancelOverride = "cancel_override" + case profileSwitch = "profile_switch" +@@ -52,7 +56,8 @@ class RemoteNotificationResponseManager { + to returnInfo: ReturnNotificationInfo?, + commandType: CommandType, + success: Bool, +- message: String ++ message: String, ++ syncIdentifier: String? = nil + ) async { + guard let returnInfo = returnInfo else { + os_log("No return notification info provided, skipping response", log: log, type: .info) +@@ -75,7 +80,8 @@ class RemoteNotificationResponseManager { + ), + commandStatus: success ? "success" : "failed", + commandType: commandType.rawValue, +- timestamp: Date().timeIntervalSince1970 ++ timestamp: Date().timeIntervalSince1970, ++ syncIdentifier: syncIdentifier + ) + + await sendPushNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift +new file mode 100644 +index 0000000..1593a74 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift +@@ -0,0 +1,44 @@ ++// ++// CarbDeleteRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++public struct CarbDeleteRemoteNotification: RemoteNotification, Codable { ++ ++ public let syncIdentifier: String ++ public let remoteAddress: String ++ public let expiration: Date? ++ public let sentAt: Date? ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ enum CodingKeys: String, CodingKey { ++ case syncIdentifier = "carbs-delete" ++ case remoteAddress = "remote-address" ++ case expiration = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .carbsDelete(CarbDeleteAction(syncIdentifier: syncIdentifier)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ func expirationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification["carbs-delete"] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift +new file mode 100644 +index 0000000..fde3e4c +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift +@@ -0,0 +1,58 @@ ++// ++// CarbEditRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Replacement values use prefixed keys so an unpatched Loop never reads them as a new carb entry. ++public struct CarbEditRemoteNotification: RemoteNotification, Codable { ++ ++ public let syncIdentifier: String ++ public let amount: Double ++ public let absorptionInHours: Double ++ public let foodType: String? ++ public let startDate: Date ++ public let remoteAddress: String ++ public let expiration: Date? ++ public let sentAt: Date? ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ enum CodingKeys: String, CodingKey { ++ case syncIdentifier = "carbs-edit" ++ case amount = "carbs-edit-entry" ++ case absorptionInHours = "carbs-edit-absorption-time" ++ case foodType = "carbs-edit-food-type" ++ case startDate = "carbs-edit-start-time" ++ case remoteAddress = "remote-address" ++ case expiration = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ public func absorptionTime() -> TimeInterval { ++ return TimeInterval(hours: absorptionInHours) ++ } ++ ++ func toRemoteAction() -> Action { ++ let action = CarbEditAction(syncIdentifier: syncIdentifier, amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate) ++ return .carbsEdit(action) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ func expirationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification["carbs-edit"] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 3362bf7..87f1e92 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -22,12 +22,22 @@ protocol RemoteNotification: Codable { + + func toRemoteAction() -> Action + func otpValidationRequired() -> Bool ++ func expirationRequired() -> Bool + func getReturnNotificationInfo() -> ReturnNotificationInfo? + + static func includedInNotification(_ notification: [String: Any]) -> Bool + } + ++/// Followers read this to know which remote commands this build accepts. ++enum RemoteNotificationCapabilities { ++ static let supported: [String] = ["carbs-delete", "carbs-edit"] ++} ++ + extension RemoteNotification { ++ ++ func expirationRequired() -> Bool { ++ return false ++ } + + var id: String { + //There is no unique identifier so we use the sent date when available +@@ -102,6 +112,10 @@ extension Dictionary { + func toRemoteNotification() throws -> RemoteNotification { + if BolusRemoteNotification.includedInNotification(self) { + return try BolusRemoteNotification(dictionary: self) ++ } else if CarbDeleteRemoteNotification.includedInNotification(self) { ++ return try CarbDeleteRemoteNotification(dictionary: self) ++ } else if CarbEditRemoteNotification.includedInNotification(self) { ++ return try CarbEditRemoteNotification(dictionary: self) + } else if CarbRemoteNotification.includedInNotification(self) { + return try CarbRemoteNotification(dictionary: self) + } else if OverrideRemoteNotification.includedInNotification(self) { +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +index 634eb4e..21d0e0a 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +@@ -16,6 +16,14 @@ struct RemoteCommandValidator { + } + + func validate(remoteNotification: RemoteNotification) throws { ++ if remoteNotification.expirationRequired() { ++ guard remoteNotification.expiration != nil else { ++ throw NotificationValidationError.missingExpiration ++ } ++ guard remoteNotification.sentAt != nil else { ++ throw NotificationValidationError.missingSentAt ++ } ++ } + try validateExpirationDate(remoteNotification: remoteNotification) + if remoteNotification.otpValidationRequired() { + try validateOTP(remoteNotification: remoteNotification) +@@ -45,11 +53,17 @@ struct RemoteCommandValidator { + enum NotificationValidationError: LocalizedError { + case missingOTP + case expiredNotification ++ case missingExpiration ++ case missingSentAt + + var errorDescription: String? { + switch self { + case .missingOTP: + return LocalizedString("Missing OTP", comment: "Remote command error description: Missing OTP.") ++ case .missingExpiration: ++ return LocalizedString("Missing expiration", comment: "Remote command error description: missing expiration.") ++ case .missingSentAt: ++ return LocalizedString("Missing sent-at time", comment: "Remote command error description: missing sent-at.") + case .expiredNotification: + return LocalizedString("Expired", comment: "Remote command error description: expired.") + } +diff --git a/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..6a3aba6 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift +@@ -0,0 +1,67 @@ ++// ++// CarbDeleteRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++@testable import NightscoutServiceKit ++ ++final class CarbDeleteRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseCarbDeleteNotification_ValidPayload_Succeeds() throws { ++ let syncIdentifier = "3F2504E0-4F89-11D3-9A0C-0305E82C3301" ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-delete": syncIdentifier as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ let remoteNotification = try notification.toRemoteNotification() ++ ++ let deleteNotification = try XCTUnwrap(remoteNotification as? CarbDeleteRemoteNotification) ++ XCTAssertEqual(deleteNotification.syncIdentifier, syncIdentifier) ++ XCTAssertEqual(deleteNotification.otp, "12345") ++ XCTAssertTrue(deleteNotification.otpValidationRequired()) ++ XCTAssertTrue(deleteNotification.expirationRequired()) ++ guard case .carbsDelete(let action) = deleteNotification.toRemoteAction() else { ++ return XCTFail("Expected carbsDelete action") ++ } ++ XCTAssertEqual(action.syncIdentifier, syncIdentifier) ++ } ++ ++ func testValidate_MissingExpiration_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "carbs-delete": "abc" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ let remoteNotification = try notification.toRemoteNotification() ++ let validator = RemoteCommandValidator(otpManager: OTPTestCoordinator().createOTPManager()) ++ ++ XCTAssertThrowsError(try validator.validate(remoteNotification: remoteNotification)) { error in ++ guard case RemoteCommandValidator.NotificationValidationError.missingExpiration = error else { ++ return XCTFail("Unexpected error \(error)") ++ } ++ } ++ } ++ ++ func testValidate_MissingSentAt_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "expiration": "2099-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-delete": "abc" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ let remoteNotification = try notification.toRemoteNotification() ++ let validator = RemoteCommandValidator(otpManager: OTPTestCoordinator().createOTPManager()) ++ ++ XCTAssertThrowsError(try validator.validate(remoteNotification: remoteNotification)) { error in ++ guard case RemoteCommandValidator.NotificationValidationError.missingSentAt = error else { ++ return XCTFail("Unexpected error \(error)") ++ } ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..989f7c8 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift +@@ -0,0 +1,108 @@ ++// ++// CarbEditRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++@testable import NightscoutServiceKit ++ ++final class CarbEditRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseCarbEditNotification_ValidPayload_Succeeds() throws { ++ let syncIdentifier = "3F2504E0-4F89-11D3-9A0C-0305E82C3301" ++ let startDateString = "2023-02-25T20:46:35.778Z" ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": syncIdentifier as AnyObject, ++ "carbs-edit-entry": 22.0 as AnyObject, ++ "carbs-edit-absorption-time": 3.0 as AnyObject, ++ "carbs-edit-food-type": "🍕" as AnyObject, ++ "carbs-edit-start-time": startDateString as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ let remoteNotification = try notification.toRemoteNotification() ++ ++ let editNotification = try XCTUnwrap(remoteNotification as? CarbEditRemoteNotification) ++ XCTAssertEqual(editNotification.syncIdentifier, syncIdentifier) ++ XCTAssertEqual(editNotification.amount, 22.0) ++ XCTAssertEqual(editNotification.absorptionTime(), TimeInterval(hours: 3.0)) ++ XCTAssertEqual(editNotification.foodType, "🍕") ++ XCTAssertEqual(editNotification.startDate, dateFormatter().date(from: startDateString)) ++ XCTAssertTrue(editNotification.otpValidationRequired()) ++ XCTAssertTrue(editNotification.expirationRequired()) ++ guard case .carbsEdit(let action) = editNotification.toRemoteAction() else { ++ return XCTFail("Expected carbsEdit action") ++ } ++ XCTAssertEqual(action.syncIdentifier, syncIdentifier) ++ XCTAssertEqual(action.amountInGrams, 22.0) ++ XCTAssertEqual(action.absorptionTime, TimeInterval(hours: 3.0)) ++ XCTAssertEqual(action.startDate, dateFormatter().date(from: startDateString)) ++ } ++ ++ func testParseCarbEditNotification_FoodTypeAbsent_Succeeds() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-absorption-time": 2.0 as AnyObject, ++ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ let editNotification = try XCTUnwrap(try notification.toRemoteNotification() as? CarbEditRemoteNotification) ++ XCTAssertNil(editNotification.foodType) ++ } ++ ++ func testParseCarbEditNotification_MissingStartTime_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-absorption-time": 2.0 as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ XCTAssertThrowsError(try notification.toRemoteNotification()) ++ } ++ ++ func testParseCarbEditNotification_MissingAbsorptionTime_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ XCTAssertThrowsError(try notification.toRemoteNotification()) ++ } ++ ++ func testParseCarbEditNotification_DoesNotDecodeAsCarbEntry() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-absorption-time": 2.0 as AnyObject, ++ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ XCTAssertFalse(CarbRemoteNotification.includedInNotification(notification)) ++ XCTAssertTrue(CarbEditRemoteNotification.includedInNotification(notification)) ++ } ++ ++ func dateFormatter() -> ISO8601DateFormatter { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift b/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift +new file mode 100644 +index 0000000..e284d3f +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift +@@ -0,0 +1,21 @@ ++// ++// RemoteNotificationCapabilitiesTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++@testable import NightscoutServiceKit ++ ++final class RemoteNotificationCapabilitiesTestCase: XCTestCase { ++ ++ func testSupportedCommands_MatchNotificationKeys() { ++ XCTAssertEqual(RemoteNotificationCapabilities.supported, ["carbs-delete", "carbs-edit"]) ++ XCTAssertTrue(CarbDeleteRemoteNotification.includedInNotification(["carbs-delete": "id"])) ++ XCTAssertTrue(CarbEditRemoteNotification.includedInNotification(["carbs-edit": "id"])) ++ for key in RemoteNotificationCapabilities.supported { ++ let notification: [String: Any] = [key: "id"] ++ XCTAssertTrue(CarbDeleteRemoteNotification.includedInNotification(notification) ++ || CarbEditRemoteNotification.includedInNotification(notification), key) ++ } ++ } ++} +diff --git a/remote_carb_edit.loopcustomization b/remote_carb_edit.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/remote_carb_edit/nextdev_remote_carb_edit_remote_profile_remote_window.patch b/remote_carb_edit/nextdev_remote_carb_edit_remote_profile_remote_window.patch new file mode 100644 index 0000000..9720c23 --- /dev/null +++ b/remote_carb_edit/nextdev_remote_carb_edit_remote_profile_remote_window.patch @@ -0,0 +1,956 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index 1966dcde..feb53338 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -1416,28 +1416,10 @@ extension LoopDataManager: ServicesManagerDelegate { + //Carb Entry + + func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws { +- + let absorptionTime = absorptionTime ?? LoopCoreConstants.defaultCarbAbsorptionTimes.medium +- if absorptionTime < LoopConstants.minCarbAbsorptionTime || absorptionTime > LoopConstants.maxCarbAbsorptionTime { +- throw CarbActionError.invalidAbsorptionTime(absorptionTime) +- } +- +- guard amountInGrams > 0.0 else { +- throw CarbActionError.invalidCarbs +- } +- +- guard amountInGrams <= LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram) else { +- throw CarbActionError.exceedsMaxCarbs +- } +- +- if let startDate = startDate { +- let maxStartDate = now.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) +- let minStartDate = now.addingTimeInterval(LoopConstants.maxCarbEntryPastTime) +- guard startDate <= maxStartDate && startDate >= minStartDate else { +- throw CarbActionError.invalidStartDate(startDate) +- } +- } +- ++ let startDate = startDate ?? now ++ try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) ++ + // Guard B (conflicting-treatment protection): reject remote carbs if any carb entry was + // created within minTreatmentIntervalInMinutes of the command's userCreatedDate. Look back + // a little further for safety (e.g. upload delays); zero tolerance for carbs. +@@ -1455,10 +1437,61 @@ extension LoopDataManager: ServicesManagerDelegate { + } + + let quantity = LoopQuantity(unit: .gram, doubleValue: amountInGrams) +- let candidateCarbEntry = NewCarbEntry(syncIdentifier: UUID().uuidString, quantity: quantity, startDate: startDate ?? now, foodType: foodType, absorptionTime: absorptionTime) ++ let candidateCarbEntry = NewCarbEntry(syncIdentifier: UUID().uuidString, quantity: quantity, startDate: startDate, foodType: foodType, absorptionTime: absorptionTime) + + let _ = try await carbStore.addCarbEntry(candidateCarbEntry) + } ++ ++ /// Remote edits and deletes may only target entries this app created within the last 24 hours. ++ static let remoteCarbEditWindow: TimeInterval = .hours(24) ++ ++ func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry { ++ let entry = try await remoteEditableCarbEntry(syncIdentifier: syncIdentifier) ++ try await deleteCarbEntry(entry) ++ return entry ++ } ++ ++ func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry { ++ let existing = try await remoteEditableCarbEntry(syncIdentifier: syncIdentifier) ++ try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) ++ ++ let quantity = LoopQuantity(unit: .gram, doubleValue: amountInGrams) ++ let update = CarbEntryUpdate(quantity: quantity, startDate: startDate, foodType: foodType ?? existing.foodType, absorptionTime: absorptionTime) ++ ++ return try await replaceCarbEntry(existing, with: update) ++ } ++ ++ private func validateRemoteCarbParameters(amountInGrams: Double, absorptionTime: TimeInterval, startDate: Date) throws { ++ if absorptionTime < LoopConstants.minCarbAbsorptionTime || absorptionTime > LoopConstants.maxCarbAbsorptionTime { ++ throw CarbActionError.invalidAbsorptionTime(absorptionTime) ++ } ++ ++ guard amountInGrams > 0.0 else { ++ throw CarbActionError.invalidCarbs ++ } ++ ++ guard amountInGrams <= LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram) else { ++ throw CarbActionError.exceedsMaxCarbs ++ } ++ ++ let maxStartDate = now.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) ++ let minStartDate = now.addingTimeInterval(LoopConstants.maxCarbEntryPastTime) ++ guard startDate <= maxStartDate && startDate >= minStartDate else { ++ throw CarbActionError.invalidStartDate(startDate) ++ } ++ } ++ ++ private func remoteEditableCarbEntry(syncIdentifier: String) async throws -> StoredCarbEntry { ++ let now = now ++ let entries = try await carbStore.getCarbEntries(start: now.addingTimeInterval(-Self.remoteCarbEditWindow), end: now.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime)) ++ guard let entry = entries.first(where: { $0.syncIdentifier == syncIdentifier }) else { ++ throw CarbActionError.entryNotFound ++ } ++ guard entry.createdByCurrentApp else { ++ throw CarbActionError.entryNotEditable ++ } ++ return entry ++ } + + enum CarbActionError: LocalizedError { + +@@ -1466,6 +1499,8 @@ extension LoopDataManager: ServicesManagerDelegate { + case invalidStartDate(Date) + case exceedsMaxCarbs + case invalidCarbs ++ case entryNotFound ++ case entryNotEditable + case conflictingTreatments(_ conflictingAmountInGrams: Double, _ minutesRequiredBetweenTreatments: TimeInterval) + + var errorDescription: String? { +@@ -1478,6 +1513,10 @@ extension LoopDataManager: ServicesManagerDelegate { + return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Carb error description: carbs exceed maximum amount.") + case .invalidCarbs: + return NSLocalizedString("Invalid carb amount", comment: "Carb error description: invalid carb amount.") ++ case .entryNotFound: ++ return NSLocalizedString("Carb entry not found or older than 24 hours", comment: "Carb error description: remote edit target not found.") ++ case .entryNotEditable: ++ return NSLocalizedString("Carb entry was not created by Loop", comment: "Carb error description: remote edit target not created by this app.") + case .invalidAbsorptionTime(let absorptionTime): + let absorptionHoursFormatted = Self.numberFormatter.string(from: absorptionTime.hours) ?? "" + return String(format: NSLocalizedString("Invalid absorption time: %1$@ hours", comment: "Carb error description: invalid absorption time. (1: Input duration in hours)."), absorptionHoursFormatted) +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index c4d0f32e..1cf442dd 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -220,6 +220,34 @@ extension NotificationManager { + UNUserNotificationCenter.current().add(request) + } + ++ static func sendRemoteCarbChangeNotification(body: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [body, "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: LoopNotificationCategory.remoteCarbs.rawValue, ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteCarbChangeFailureNotification(body: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [body, error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: LoopNotificationCategory.remoteCarbsFailure.rawValue, ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ + static func sendMissedMealNotification(mealStart: Date, amountInGrams: Double, delay: TimeInterval? = nil) { + let notification = UNMutableNotificationContent() + /// Notifications should expire after the missed meal is no longer relevant +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 8e7c86ef..65a47001 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -252,6 +252,8 @@ public protocol ServicesManagerDelegate: AnyObject { + func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws + func cancelCurrentOverride() async throws + func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws ++ func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry ++ func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry + } + + // MARK: - StatefulPluggableDelegate +@@ -338,6 +340,48 @@ extension ServicesManager: ServiceDelegate { + } + } + ++ func deleteRemoteCarbs(syncIdentifier: String) async throws { ++ do { ++ guard let servicesManagerDelegate else { ++ throw RemoteCarbCommandError.internalError ++ } ++ let deleted = try await servicesManagerDelegate.deleteCarbs(syncIdentifier: syncIdentifier) ++ let body = String(format: NSLocalizedString("Remote Carbs Delete: %d grams removed", comment: "The notification body for a successful remote carb delete. (1: Carb amount in grams)"), Int(deleted.quantity.doubleValue(for: .gram))) ++ NotificationManager.sendRemoteCarbChangeNotification(body: body) ++ await remoteDataServicesManager.performUpload(for: .carb) ++ } catch { ++ let body = NSLocalizedString("Remote Carbs Delete", comment: "The notification body prefix for a failed remote carb delete") ++ NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) ++ throw error ++ } ++ } ++ ++ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws { ++ let body = String(format: NSLocalizedString("Remote Carbs Edit: %d grams", comment: "The carb amount message for a remote carb edit notification. (1: Carb amount in grams)"), Int(amountInGrams)) ++ do { ++ guard let servicesManagerDelegate else { ++ throw RemoteCarbCommandError.internalError ++ } ++ _ = try await servicesManagerDelegate.replaceCarbs(syncIdentifier: syncIdentifier, amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) ++ NotificationManager.sendRemoteCarbChangeNotification(body: body) ++ await remoteDataServicesManager.performUpload(for: .carb) ++ } catch { ++ NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteCarbCommandError: LocalizedError { ++ case internalError ++ ++ var errorDescription: String? { ++ switch self { ++ case .internalError: ++ return NSLocalizedString("Internal error", comment: "Remote carb command error description: internal error.") ++ } ++ } ++ } ++ + func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?, userCreatedDate: Date) async throws -> Double { + do { + +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index b82f71ed..39c0eee9 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -19,4 +19,6 @@ public protocol RemoteActionDelegate: AnyObject { + func cancelRemoteOverride() async throws + func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws + func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?, userCreatedDate: Date) async throws -> Double ++ func deleteRemoteCarbs(syncIdentifier: String) async throws ++ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws + } +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index cf6883e..eb8f03d 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -31,12 +31,17 @@ + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; + A941B09029BD4A9E00F91340 /* CarbRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */; }; ++ FB42DD0865235D441918DA5E /* CarbEditRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */; }; ++ A2C1A69F3A61FD4E207637A7 /* CarbDeleteRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */; }; + A941B09129BD4A9E00F91340 /* BolusRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */; }; + A941B09229BD4A9F00F91340 /* OverrideCancelRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */; }; + A941B09329BD4A9F00F91340 /* RemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */; }; + A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */; }; + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */; }; + A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */; }; ++ AFFFFDFBBBA4F7DF3997E63E /* CarbEditRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */; }; ++ 73056A2FC515352D51EFFECF /* CarbDeleteRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */; }; ++ 5C1D2E3F4A5B6C7D8E9F0A1B /* RemoteNotificationCapabilitiesTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */; }; + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */; }; + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */; }; + A94DA7642353CF930082E26D /* StoredGlucoseSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */; }; +@@ -50,6 +55,8 @@ + A9AA6E5B29EB072E008FFA78 /* OverrideAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */; }; + A9AA6E6329EB07D7008FFA78 /* Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5D29EB07D6008FFA78 /* Action.swift */; }; + A9AA6E6429EB07D7008FFA78 /* CarbAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */; }; ++ 843DF14B73EC57EB6F0033DA /* CarbEditAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04073CA740189C4259808331 /* CarbEditAction.swift */; }; ++ 7E7294EAE3456F0C6DC10F37 /* CarbDeleteAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */; }; + A9AA6E6529EB07D7008FFA78 /* OverrideCancelAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */; }; + A9AA6E6629EB07D7008FFA78 /* BolusAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */; }; + A9B007B322BD6FF7000131DE /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B007B222BD6FF7000131DE /* LocalizedString.swift */; }; +@@ -168,12 +175,17 @@ + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; + A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbRemoteNotification.swift; sourceTree = ""; }; ++ 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditRemoteNotification.swift; sourceTree = ""; }; ++ A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteRemoteNotification.swift; sourceTree = ""; }; + A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRemoteNotification.swift; sourceTree = ""; }; + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotification.swift; sourceTree = ""; }; + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotification.swift; sourceTree = ""; }; + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideRemoteNotification.swift; sourceTree = ""; }; + A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRemoteNotificationTestCase.swift; sourceTree = ""; }; + A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbRemoteNotificationTestCase.swift; sourceTree = ""; }; ++ E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditRemoteNotificationTestCase.swift; sourceTree = ""; }; ++ 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteRemoteNotificationTestCase.swift; sourceTree = ""; }; ++ 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotificationCapabilitiesTestCase.swift; sourceTree = ""; }; + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotificationTestCase.swift; sourceTree = ""; }; + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideRemoteNotificationTestCase.swift; sourceTree = ""; }; + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredGlucoseSample.swift; sourceTree = ""; }; +@@ -189,6 +201,8 @@ + A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideAction.swift; sourceTree = ""; }; + A9AA6E5D29EB07D6008FFA78 /* Action.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbAction.swift; sourceTree = ""; }; ++ 04073CA740189C4259808331 /* CarbEditAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditAction.swift; sourceTree = ""; }; ++ 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteAction.swift; sourceTree = ""; }; + A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelAction.swift; sourceTree = ""; }; + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusAction.swift; sourceTree = ""; }; + A9B007B222BD6FF7000131DE /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; +@@ -327,6 +341,9 @@ + A91BAC2B22BC691A00ABF1BB /* Info.plist */, + A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */, + A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */, ++ E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */, ++ 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */, ++ 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */, + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, +@@ -368,6 +385,8 @@ + children = ( + A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */, + A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */, ++ 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */, ++ A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */, + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, +@@ -424,6 +443,8 @@ + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, ++ 04073CA740189C4259808331 /* CarbEditAction.swift */, ++ 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */, + A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */, + A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */, + ); +@@ -725,12 +746,16 @@ + A993990322BC7095005E39D3 /* NightscoutService.swift in Sources */, + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */, + A9AA6E6429EB07D7008FFA78 /* CarbAction.swift in Sources */, ++ 843DF14B73EC57EB6F0033DA /* CarbEditAction.swift in Sources */, ++ 7E7294EAE3456F0C6DC10F37 /* CarbDeleteAction.swift in Sources */, + A90E39A922BC7AD10016DFE8 /* Bundle.swift in Sources */, + A90E39A322BC782C0016DFE8 /* SyncCarbObject.swift in Sources */, + A941B09329BD4A9F00F91340 /* RemoteNotification.swift in Sources */, + A9AA6E6629EB07D7008FFA78 /* BolusAction.swift in Sources */, + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */, + A941B09029BD4A9E00F91340 /* CarbRemoteNotification.swift in Sources */, ++ FB42DD0865235D441918DA5E /* CarbEditRemoteNotification.swift in Sources */, ++ A2C1A69F3A61FD4E207637A7 /* CarbDeleteRemoteNotification.swift in Sources */, + A94DA7642353CF930082E26D /* StoredGlucoseSample.swift in Sources */, + A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */, + C1E703642506BE9400DAB534 /* ObjectIdCache.swift in Sources */, +@@ -765,6 +790,9 @@ + buildActionMask = 2147483647; + files = ( + A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */, ++ AFFFFDFBBBA4F7DF3997E63E /* CarbEditRemoteNotificationTestCase.swift in Sources */, ++ 73056A2FC515352D51EFFECF /* CarbDeleteRemoteNotificationTestCase.swift in Sources */, ++ 5C1D2E3F4A5B6C7D8E9F0A1B /* RemoteNotificationCapabilitiesTestCase.swift in Sources */, + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index 2dec600..1b43f76 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -180,7 +180,7 @@ extension StoredDosingDecision { + enacted: automaticDoseDecision?.loopStatusEnacted, + failureReason: automaticDoseDecision?.loopStatusFailureReason, + failureCode: automaticDoseDecision?.errors.first?.id, +- testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]], ++ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]], + algorithmExperiments: (automaticDoseDecision?.algorithmExperiments ?? algorithmExperiments)?.map { $0.jsonObject }), + overrideStatus: overrideStatus) + } +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index ec997ef..64a668c 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -24,7 +24,7 @@ public final class NightscoutService: Service { + + public static let localizedTitle = LocalizedString("Nightscout", comment: "The title of the Nightscout service") + +- public let objectIdCacheKeepTime = TimeInterval(24 * 60 * 60) ++ public let objectIdCacheKeepTime = TimeInterval(48 * 60 * 60) + + public weak var serviceDelegate: ServiceDelegate? + +@@ -457,6 +457,7 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + var commandType: RemoteNotificationResponseManager.CommandType = .bolus // Default, will be set in switch + var success = false + var message = "" ++ var syncIdentifier: String? = nil + + do { + guard let serviceDelegate = self.serviceDelegate else { +@@ -518,6 +519,26 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + ) + success = true + message = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) ++ ++ case .carbsDelete(let deleteCommand): ++ commandType = .carbsDelete ++ syncIdentifier = deleteCommand.syncIdentifier ++ try await serviceDelegate.deleteRemoteCarbs(syncIdentifier: deleteCommand.syncIdentifier) ++ success = true ++ message = "Carb entry deleted successfully" ++ ++ case .carbsEdit(let editCommand): ++ commandType = .carbsEdit ++ syncIdentifier = editCommand.syncIdentifier ++ try await serviceDelegate.replaceRemoteCarbs( ++ syncIdentifier: editCommand.syncIdentifier, ++ amountInGrams: editCommand.amountInGrams, ++ absorptionTime: editCommand.absorptionTime, ++ foodType: editCommand.foodType, ++ startDate: editCommand.startDate ++ ) ++ success = true ++ message = String(format: "Carb entry updated to %.1f g", editCommand.amountInGrams) + } + } catch { + message = "Command failed: \(error.localizedDescription)" +@@ -527,7 +548,8 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + to: returnInfo, + commandType: commandType, + success: false, +- message: message ++ message: message, ++ syncIdentifier: syncIdentifier + ) + } + throw error +@@ -539,7 +561,8 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + to: returnInfo, + commandType: commandType, + success: success, +- message: message ++ message: message, ++ syncIdentifier: syncIdentifier + ) + } + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index 29313b6..29d3563 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -14,6 +14,8 @@ public enum Action: Codable { + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) + case carbsEntry(CarbAction) ++ case carbsDelete(CarbDeleteAction) ++ case carbsEdit(CarbEditAction) + + public var description: String { + return "\(actionName) \(actionParameterDescription)" +@@ -23,6 +25,10 @@ public enum Action: Codable { + switch self { + case .carbsEntry: + return LocalizedString("Carb Entry", comment: "The remote action name for Carb Entry") ++ case .carbsDelete: ++ return LocalizedString("Delete Carbs", comment: "The remote action name for deleting a carb entry") ++ case .carbsEdit: ++ return LocalizedString("Edit Carbs", comment: "The remote action name for editing a carb entry") + case .bolusEntry: + return LocalizedString("Bolus Entry", comment: "The remote action name for Bolus Entry") + case .cancelTemporaryOverride: +@@ -39,6 +45,11 @@ public enum Action: Codable { + case .carbsEntry(let carbAction): + let amountFormatted = Self.numberFormatter.string(from: carbAction.amountInGrams as NSNumber) ?? "" + return "\(amountFormatted)" + " " + carbGramAbbreviation ++ case .carbsDelete(let deleteAction): ++ return String(deleteAction.syncIdentifier.prefix(8)) ++ case .carbsEdit(let editAction): ++ let amountFormatted = Self.numberFormatter.string(from: editAction.amountInGrams as NSNumber) ?? "" ++ return "\(amountFormatted)" + " " + carbGramAbbreviation + case .bolusEntry(let bolusAction): + let amountFormatted = Self.numberFormatter.string(from: bolusAction.amountInUnits as NSNumber) ?? "" + return "\(amountFormatted)" + " " + bolusUnitAbbreviation +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift +new file mode 100644 +index 0000000..77e9900 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift +@@ -0,0 +1,15 @@ ++// ++// CarbDeleteAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct CarbDeleteAction: Codable { ++ ++ public let syncIdentifier: String ++ ++ public init(syncIdentifier: String) { ++ self.syncIdentifier = syncIdentifier ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift +new file mode 100644 +index 0000000..0aea20b +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift +@@ -0,0 +1,23 @@ ++// ++// CarbEditAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct CarbEditAction: Codable { ++ ++ public let syncIdentifier: String ++ public let amountInGrams: Double ++ public let absorptionTime: TimeInterval ++ public let foodType: String? ++ public let startDate: Date ++ ++ public init(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String? = nil, startDate: Date) { ++ self.syncIdentifier = syncIdentifier ++ self.amountInGrams = amountInGrams ++ self.absorptionTime = absorptionTime ++ self.foodType = foodType ++ self.startDate = startDate ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 4457f80..aabb47f 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -21,12 +21,14 @@ class RemoteNotificationResponseManager { + let commandStatus: String + let commandType: String + let timestamp: TimeInterval ++ let syncIdentifier: String? + + enum CodingKeys: String, CodingKey { + case aps + case commandStatus = "command_status" + case commandType = "command_type" + case timestamp ++ case syncIdentifier = "sync_identifier" + } + } + +@@ -43,6 +45,8 @@ class RemoteNotificationResponseManager { + enum CommandType: String { + case bolus = "bolus" + case carbs = "carbs" ++ case carbsDelete = "carbs_delete" ++ case carbsEdit = "carbs_edit" + case override = "override" + case cancelOverride = "cancel_override" + case profileSwitch = "profile_switch" +@@ -52,7 +56,8 @@ class RemoteNotificationResponseManager { + to returnInfo: ReturnNotificationInfo?, + commandType: CommandType, + success: Bool, +- message: String ++ message: String, ++ syncIdentifier: String? = nil + ) async { + guard let returnInfo = returnInfo else { + os_log("No return notification info provided, skipping response", log: log, type: .info) +@@ -75,7 +80,8 @@ class RemoteNotificationResponseManager { + ), + commandStatus: success ? "success" : "failed", + commandType: commandType.rawValue, +- timestamp: Date().timeIntervalSince1970 ++ timestamp: Date().timeIntervalSince1970, ++ syncIdentifier: syncIdentifier + ) + + await sendPushNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift +new file mode 100644 +index 0000000..eda9134 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift +@@ -0,0 +1,44 @@ ++// ++// CarbDeleteRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++public struct CarbDeleteRemoteNotification: RemoteNotification, Codable { ++ ++ public let syncIdentifier: String ++ public let remoteAddress: String ++ public let expiration: Date? ++ public let sentAt: Date ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ enum CodingKeys: String, CodingKey { ++ case syncIdentifier = "carbs-delete" ++ case remoteAddress = "remote-address" ++ case expiration = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .carbsDelete(CarbDeleteAction(syncIdentifier: syncIdentifier)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ func expirationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification["carbs-delete"] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift +new file mode 100644 +index 0000000..f1ffb51 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift +@@ -0,0 +1,58 @@ ++// ++// CarbEditRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Replacement values use prefixed keys so an unpatched Loop never reads them as a new carb entry. ++public struct CarbEditRemoteNotification: RemoteNotification, Codable { ++ ++ public let syncIdentifier: String ++ public let amount: Double ++ public let absorptionInHours: Double ++ public let foodType: String? ++ public let startDate: Date ++ public let remoteAddress: String ++ public let expiration: Date? ++ public let sentAt: Date ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ enum CodingKeys: String, CodingKey { ++ case syncIdentifier = "carbs-edit" ++ case amount = "carbs-edit-entry" ++ case absorptionInHours = "carbs-edit-absorption-time" ++ case foodType = "carbs-edit-food-type" ++ case startDate = "carbs-edit-start-time" ++ case remoteAddress = "remote-address" ++ case expiration = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ public func absorptionTime() -> TimeInterval { ++ return TimeInterval(hours: absorptionInHours) ++ } ++ ++ func toRemoteAction() -> Action { ++ let action = CarbEditAction(syncIdentifier: syncIdentifier, amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate) ++ return .carbsEdit(action) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ func expirationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification["carbs-edit"] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index a0f58a0..1ea8b96 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -22,12 +22,22 @@ protocol RemoteNotification: Codable { + + func toRemoteAction() -> Action + func otpValidationRequired() -> Bool ++ func expirationRequired() -> Bool + func getReturnNotificationInfo() -> ReturnNotificationInfo? + + static func includedInNotification(_ notification: [String: Any]) -> Bool + } + ++/// Followers read this to know which remote commands this build accepts. ++enum RemoteNotificationCapabilities { ++ static let supported: [String] = ["carbs-delete", "carbs-edit"] ++} ++ + extension RemoteNotification { ++ ++ func expirationRequired() -> Bool { ++ return false ++ } + + var id: String { + //There is no unique identifier so we use the sent date +@@ -98,6 +108,10 @@ extension Dictionary { + func toRemoteNotification() throws -> RemoteNotification { + if BolusRemoteNotification.includedInNotification(self) { + return try BolusRemoteNotification(dictionary: self) ++ } else if CarbDeleteRemoteNotification.includedInNotification(self) { ++ return try CarbDeleteRemoteNotification(dictionary: self) ++ } else if CarbEditRemoteNotification.includedInNotification(self) { ++ return try CarbEditRemoteNotification(dictionary: self) + } else if CarbRemoteNotification.includedInNotification(self) { + return try CarbRemoteNotification(dictionary: self) + } else if OverrideRemoteNotification.includedInNotification(self) { +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +index fce4623..5be6e1c 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +@@ -16,6 +16,11 @@ struct RemoteCommandValidator { + } + + func validate(remoteNotification: RemoteNotification) throws { ++ if remoteNotification.expirationRequired() { ++ guard remoteNotification.expiration != nil else { ++ throw NotificationValidationError.missingExpiration ++ } ++ } + try validateExpirationDate(remoteNotification: remoteNotification) + if remoteNotification.otpValidationRequired() { + try validateOTP(remoteNotification: remoteNotification) +@@ -45,11 +50,14 @@ struct RemoteCommandValidator { + enum NotificationValidationError: LocalizedError { + case missingOTP + case expiredNotification(sentDate: Date, receivedDate: Date) ++ case missingExpiration + + var errorDescription: String? { + switch self { + case .missingOTP: + return LocalizedString("Missing OTP", comment: "Remote command error description: Missing OTP.") ++ case .missingExpiration: ++ return LocalizedString("Missing expiration", comment: "Remote command error description: missing expiration.") + case .expiredNotification(let sentDate, let receivedDate): + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "h:mm:ss a" +diff --git a/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..f03768c +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift +@@ -0,0 +1,61 @@ ++// ++// CarbDeleteRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++@testable import NightscoutServiceKit ++ ++final class CarbDeleteRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseCarbDeleteNotification_ValidPayload_Succeeds() throws { ++ let syncIdentifier = "3F2504E0-4F89-11D3-9A0C-0305E82C3301" ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-delete": syncIdentifier as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ let remoteNotification = try notification.toRemoteNotification() ++ ++ let deleteNotification = try XCTUnwrap(remoteNotification as? CarbDeleteRemoteNotification) ++ XCTAssertEqual(deleteNotification.syncIdentifier, syncIdentifier) ++ XCTAssertEqual(deleteNotification.otp, "12345") ++ XCTAssertTrue(deleteNotification.otpValidationRequired()) ++ XCTAssertTrue(deleteNotification.expirationRequired()) ++ guard case .carbsDelete(let action) = deleteNotification.toRemoteAction() else { ++ return XCTFail("Expected carbsDelete action") ++ } ++ XCTAssertEqual(action.syncIdentifier, syncIdentifier) ++ } ++ ++ func testValidate_MissingExpiration_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "carbs-delete": "abc" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ let remoteNotification = try notification.toRemoteNotification() ++ let validator = RemoteCommandValidator(otpManager: OTPTestCoordinator().createOTPManager()) ++ ++ XCTAssertThrowsError(try validator.validate(remoteNotification: remoteNotification)) { error in ++ guard case RemoteCommandValidator.NotificationValidationError.missingExpiration = error else { ++ return XCTFail("Unexpected error \(error)") ++ } ++ } ++ } ++ ++ func testParseCarbDeleteNotification_MissingSentAt_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "expiration": "2099-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-delete": "abc" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ XCTAssertThrowsError(try notification.toRemoteNotification()) ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..989f7c8 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift +@@ -0,0 +1,108 @@ ++// ++// CarbEditRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++@testable import NightscoutServiceKit ++ ++final class CarbEditRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseCarbEditNotification_ValidPayload_Succeeds() throws { ++ let syncIdentifier = "3F2504E0-4F89-11D3-9A0C-0305E82C3301" ++ let startDateString = "2023-02-25T20:46:35.778Z" ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": syncIdentifier as AnyObject, ++ "carbs-edit-entry": 22.0 as AnyObject, ++ "carbs-edit-absorption-time": 3.0 as AnyObject, ++ "carbs-edit-food-type": "🍕" as AnyObject, ++ "carbs-edit-start-time": startDateString as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ let remoteNotification = try notification.toRemoteNotification() ++ ++ let editNotification = try XCTUnwrap(remoteNotification as? CarbEditRemoteNotification) ++ XCTAssertEqual(editNotification.syncIdentifier, syncIdentifier) ++ XCTAssertEqual(editNotification.amount, 22.0) ++ XCTAssertEqual(editNotification.absorptionTime(), TimeInterval(hours: 3.0)) ++ XCTAssertEqual(editNotification.foodType, "🍕") ++ XCTAssertEqual(editNotification.startDate, dateFormatter().date(from: startDateString)) ++ XCTAssertTrue(editNotification.otpValidationRequired()) ++ XCTAssertTrue(editNotification.expirationRequired()) ++ guard case .carbsEdit(let action) = editNotification.toRemoteAction() else { ++ return XCTFail("Expected carbsEdit action") ++ } ++ XCTAssertEqual(action.syncIdentifier, syncIdentifier) ++ XCTAssertEqual(action.amountInGrams, 22.0) ++ XCTAssertEqual(action.absorptionTime, TimeInterval(hours: 3.0)) ++ XCTAssertEqual(action.startDate, dateFormatter().date(from: startDateString)) ++ } ++ ++ func testParseCarbEditNotification_FoodTypeAbsent_Succeeds() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-absorption-time": 2.0 as AnyObject, ++ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ let editNotification = try XCTUnwrap(try notification.toRemoteNotification() as? CarbEditRemoteNotification) ++ XCTAssertNil(editNotification.foodType) ++ } ++ ++ func testParseCarbEditNotification_MissingStartTime_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-absorption-time": 2.0 as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ XCTAssertThrowsError(try notification.toRemoteNotification()) ++ } ++ ++ func testParseCarbEditNotification_MissingAbsorptionTime_Throws() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ XCTAssertThrowsError(try notification.toRemoteNotification()) ++ } ++ ++ func testParseCarbEditNotification_DoesNotDecodeAsCarbEntry() throws { ++ let notification: [String: AnyObject] = [ ++ "remote-address": "::ffff:11.2.44.155" as AnyObject, ++ "carbs-edit": "abc" as AnyObject, ++ "carbs-edit-entry": 10.0 as AnyObject, ++ "carbs-edit-absorption-time": 2.0 as AnyObject, ++ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, ++ "otp": "12345" as AnyObject ++ ] ++ ++ XCTAssertFalse(CarbRemoteNotification.includedInNotification(notification)) ++ XCTAssertTrue(CarbEditRemoteNotification.includedInNotification(notification)) ++ } ++ ++ func dateFormatter() -> ISO8601DateFormatter { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift b/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift +new file mode 100644 +index 0000000..e284d3f +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift +@@ -0,0 +1,21 @@ ++// ++// RemoteNotificationCapabilitiesTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++@testable import NightscoutServiceKit ++ ++final class RemoteNotificationCapabilitiesTestCase: XCTestCase { ++ ++ func testSupportedCommands_MatchNotificationKeys() { ++ XCTAssertEqual(RemoteNotificationCapabilities.supported, ["carbs-delete", "carbs-edit"]) ++ XCTAssertTrue(CarbDeleteRemoteNotification.includedInNotification(["carbs-delete": "id"])) ++ XCTAssertTrue(CarbEditRemoteNotification.includedInNotification(["carbs-edit": "id"])) ++ for key in RemoteNotificationCapabilities.supported { ++ let notification: [String: Any] = [key: "id"] ++ XCTAssertTrue(CarbDeleteRemoteNotification.includedInNotification(notification) ++ || CarbEditRemoteNotification.includedInNotification(notification), key) ++ } ++ } ++} +diff --git a/remote_carb_edit.loopcustomization b/remote_carb_edit.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/remote_profile/README.md b/remote_profile/README.md new file mode 100644 index 0000000..977b0ed --- /dev/null +++ b/remote_profile/README.md @@ -0,0 +1,43 @@ +# Remote profile switch + +Lets a caregiver app (LoopFollow) switch Loop to one of the profiles saved with the `profiles` customization, through the same OTP-protected APNS remote command path as remote carbs and boluses. Requires `profiles`; without it this customization shows as not compatible. + +Switching a profile changes the correction range, carb ratios, basal rates and insulin sensitivities Loop doses with. It runs the same steps as loading the profile on the phone: Loop checks the profile against the guardrails and the pump's supported basal rates, writes the basal schedule to the pump, and only then saves the new settings. If any step fails, nothing changes and the caregiver is told why. This is not medical advice; agree on the saved profiles and when to use them with your care team. + +## What Loop uploads + +- Every saved profile is added to the Nightscout profile document's `store`, keyed by its name, in the same format as the active profile. `Default` and the active profile's name still hold the live settings, and `defaultProfile` names the active profile. A saved profile named `Default`, or with a name starting with `$`, is not uploaded and cannot be switched to remotely. +- Saving, renaming or deleting a profile on the phone uploads the profile document again, so followers see the change. +- Every devicestatus upload lists `profile-switch` in `loop.testingDetails.remoteCommands`. LoopFollow shows the Profiles button only when it is there. + +## The command + +Payload keys, in addition to the usual `otp`, `sent-at`, `remote-address` and `entered-by`: + +- `profile-switch`: the profile's name as it appears in the `store`. +- `profile-switch-fingerprint`: a fingerprint of the profile values the caregiver previewed. +- `expiration` is required; a command without it is rejected. + +The fingerprint is the first 16 hex characters of the SHA-256 of `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...`, built from the `store` entry with each value formatted to three decimals. Loop computes it from the saved profile it is about to activate, in mg/dL and mmol/L, and rejects the command when neither matches, for example when the profile was edited on the phone after the caregiver loaded the preview. The caregiver and the phone both get a notice, and the caregiver can review the profile again once Loop has uploaded it. + +Loop answers through the existing return push (`encrypted_return_notification`) with `command_type` `profile_switch` and `command_status` `success` or `failed`. The alert body names the profile (`Profile '' activated`, or `Profile '' not activated: `, for example a pump communication error), which is how LoopFollow matches the reply to the command. Failures also post the usual Nightscout Note, and Loop shows a local notification for both outcomes. + +## Patches + +Touches Loop, LoopKit and NightscoutService. `dev_remote_profile.patch` is for LoopWorkspace `dev` and `main` (3.14.11), `nextdev_remote_profile.patch` for `next-dev`. Apply after `profiles`, from the LoopWorkspace folder: + +``` +git apply --whitespace=nowarn dev_remote_profile.patch +``` + +It coexists with `basal_lock` and `negative_insulin` in any order. `profiles` cannot be removed while this customization is applied; remove this one first. + +## Combining with remote_carb_edit and remote_window + +Both also change the remote command code, so each has variants for the other customizations already being in the tree: + +- `_remote_profile_remote_carb_edit.patch`, `_remote_profile_remote_window.patch`, `_remote_profile_remote_carb_edit_remote_window.patch`: this customization on top of the named ones. +- `remote_carb_edit/_remote_carb_edit_remote_profile.patch` and `..._remote_profile_remote_window.patch`: remote_carb_edit on top of this one (and remote_window). +- `remote_window/_remote_window_remote_profile.patch` and `..._remote_carb_edit_remote_profile.patch`: remote_window on top of this one (and remote_carb_edit). + +Every order produces the same tree, and each customization can be removed on its own. With remote_carb_edit, `remoteCommands` lists all three commands. With remote_window, `sent-at` is required, and on `dev` profile switches also appear in the remote command history. diff --git a/remote_profile/dev_remote_profile.patch b/remote_profile/dev_remote_profile.patch new file mode 100644 index 0000000..2355d7a --- /dev/null +++ b/remote_profile/dev_remote_profile.patch @@ -0,0 +1,651 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index e7316b68..3b5f1de2 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -433,6 +433,7 @@ final class DeviceDataManager { + ) + + statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) ++ servicesManager.deviceDataManager = self + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 996d1470..17cff75d 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -263,6 +263,38 @@ extension NotificationManager { + } + } + ++ static func sendRemoteProfileNotification(name: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfile", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteProfileFailureNotification(name: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ private static func remoteProfileNotificationBody(name: String) -> String { ++ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ } ++ + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { + return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + } +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 3fb788f9..c9d3e04f 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -28,6 +28,7 @@ class ServicesManager { + + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? ++ weak var deviceDataManager: DeviceDataManager? + + private var services = [Service]() + +@@ -412,3 +413,59 @@ extension Service { + ] + } + } ++ ++// MARK: - RemoteProfileActionDelegate ++ ++extension ServicesManager: RemoteProfileActionDelegate { ++ @MainActor ++ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { ++ do { ++ guard let deviceDataManager else { ++ throw RemoteProfileActionError.unavailable ++ } ++ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) ++ guard deviceDataManager.pumpSupportedIncrements() != nil else { ++ throw RemoteProfileActionError.noPump ++ } ++ guard let profile = try viewModel.savedProfile(named: name) else { ++ throw RemoteProfileActionError.profileNotFound(name) ++ } ++ try profile.verify(with: verify) ++ if case .failure(let error) = viewModel.validateProfile(profile) { ++ throw error ++ } ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ viewModel.triggerProfileSync() ++ NotificationManager.sendRemoteProfileNotification(name: name) ++ } catch { ++ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteProfileActionError: LocalizedError { ++ case unavailable ++ case noPump ++ case profileNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .unavailable: ++ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") ++ case .noPump: ++ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") ++ case .profileNotFound(let name): ++ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) ++ } ++ } ++ } ++} +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 7fdc53ad..00fe25ab 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -8,6 +8,12 @@ + + import Foundation + ++/// Switches to a saved therapy profile on a remote command. ++public protocol RemoteProfileActionDelegate: AnyObject { ++ /// `verify` receives the schedules about to be activated and throws to reject them. ++ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws ++} ++ + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws + func cancelRemoteOverride() async throws +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +index 74724fb7..f48848cd 100644 +--- a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +@@ -174,6 +174,7 @@ extension ProfileViewModel { + try jsonData.write(to: fileURL) + + self.loadProfiles() ++ triggerProfileSync() + } catch { + print("An error occurred while renaming profile: \(error)") + } +@@ -270,6 +271,8 @@ extension ProfileViewModel { + removeProfile(profileReference: profileReference) + if getCurrentProfileName() == profile.name { + clearCurrentProfile() ++ } else { ++ triggerProfileSync() + } + } + +@@ -295,6 +298,14 @@ extension ProfileViewModel { + return getProfile + } + ++ /// The saved profile named `name`, or nil when there is none. ++ public func savedProfile(named name: String) throws -> Profile? { ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ return nil ++ } ++ return try getProfile(from: profileReference) ++ } ++ + public func validateProfile(_ profile: Profile) -> Result { + guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { + return .failure(.fileLoadError) +@@ -406,3 +417,10 @@ extension ProfileViewModel { + return isOutOfSync + } + } ++ ++extension Profile { ++ /// Passes the schedules this profile activates to `check`. ++ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { ++ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) ++ } ++} +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index 69f7a26..14a7691 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -24,6 +24,10 @@ + A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; +@@ -158,6 +162,10 @@ + A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; + A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; +@@ -268,6 +276,7 @@ + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, + A934B6172358F49300949C8B /* StoredSettings.swift */, ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, + A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, + A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, +@@ -324,6 +333,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -364,6 +374,7 @@ + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, + ); + path = Notifications; + sourceTree = ""; +@@ -412,6 +423,7 @@ + A9AA6E5729EB06AA008FFA78 /* Actions */ = { + isa = PBXGroup; + children = ( ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, +@@ -744,6 +756,9 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, + A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, + C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, + ); +@@ -757,6 +772,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, + A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +new file mode 100644 +index 0000000..de73ca9 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,104 @@ ++// ++// SavedProfile.swift ++// NightscoutServiceKit ++// ++ ++import CryptoKit ++import Foundation ++import HealthKit ++import LoopKit ++import NightscoutKit ++ ++/// A profile saved with the Profiles settings screen, read from the file it was saved to. ++struct SavedProfile: Decodable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ ++ private static var directory: URL { ++ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") ++ } ++ ++ static func loadAll() -> [SavedProfile] { ++ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] ++ return files ++ .filter { $0.pathExtension == "json" } ++ .compactMap { try? Data(contentsOf: $0) } ++ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } ++ } ++ ++ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. ++ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { ++ var store: [String: ProfileSet.Profile] = [:] ++ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. ++ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { ++ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) ++ } ++ return store ++ } ++ ++ /// True when this profile uploads with this fingerprint, in either glucose unit. ++ func matches(fingerprint: String) -> Bool { ++ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { ++ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint ++ } ++ } ++ ++ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { ++ guard let correctionRange = correctionRange.schedule(for: unit), ++ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) ++ else { ++ return nil ++ } ++ return ProfileSet.Profile( ++ timezone: basalRateSchedule.timeZone, ++ dia: .hours(6), ++ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, ++ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, ++ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) ++ } ++} ++ ++extension ProfileSet.Profile { ++ /// Followers compute the same value from the uploaded profile and send it with a switch command, ++ /// so Loop only activates the values the follower previewed. ++ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` ++ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. ++ var remoteFingerprint: String { ++ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { ++ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") ++ } ++ let canonical = [ ++ "basal=" + items(basal), ++ "carbratio=" + items(carbratio), ++ "sens=" + items(sensitivity), ++ "target_low=" + items(targetLow), ++ "target_high=" + items(targetHigh), ++ ].joined(separator: ";") ++ let digest = SHA256.hash(data: Data(canonical.utf8)) ++ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index 4e1d49e..b66fb97 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -159,7 +159,8 @@ extension StoredDosingDecision { + automaticDoseRecommendation: loopStatusAutomaticDoseRecommendation, + recommendedBolus: loopStatusRecommendedBolus, + enacted: automaticDoseDecision?.loopStatusEnacted, +- failureReason: automaticDoseDecision?.loopStatusFailureReason), ++ failureReason: automaticDoseDecision?.loopStatusFailureReason, ++ testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]]), + overrideStatus: overrideStatus) + } + +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +index a1769d8..dd7ac28 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +@@ -94,7 +94,8 @@ extension StoredSettings { + return nil + } + +- var store = ["Default": profile] ++ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) ++ store["Default"] = profile + var defaultProfile = "Default" + + if let name = UserDefaults.standard.string(forKey: "currentProfileName") { +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index 4626630..ef69236 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -436,6 +436,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + success = true + message = "Override '\(overrideCommand.name)' enacted successfully" + ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ success = true ++ message = "Profile '\(profileSwitchCommand.name)' activated" ++ + case .cancelTemporaryOverride: + commandType = .cancelOverride + try await self.serviceDelegate?.cancelRemoteOverride() +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index a92b509..29313b6 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -9,6 +9,7 @@ + import Foundation + + public enum Action: Codable { ++ case profileSwitch(ProfileSwitchAction) + case temporaryScheduleOverride(OverrideAction) + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) +@@ -28,6 +29,8 @@ public enum Action: Codable { + return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") + case .temporaryScheduleOverride: + return LocalizedString("Override", comment: "The remote action name for Override") ++ case .profileSwitch: ++ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") + } + } + +@@ -43,6 +46,8 @@ public enum Action: Codable { + return "" + case .temporaryScheduleOverride(let overrideAction): + return overrideAction.name ++ case .profileSwitch(let profileSwitchAction): ++ return profileSwitchAction.name + } + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +new file mode 100644 +index 0000000..ce03ded +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +@@ -0,0 +1,17 @@ ++// ++// ProfileSwitchAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct ProfileSwitchAction: Codable { ++ ++ public let name: String ++ public let fingerprint: String ++ ++ public init(name: String, fingerprint: String) { ++ self.name = name ++ self.fingerprint = fingerprint ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 95274e4..4457f80 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -45,6 +45,7 @@ class RemoteNotificationResponseManager { + case carbs = "carbs" + case override = "override" + case cancelOverride = "cancel_override" ++ case profileSwitch = "profile_switch" + } + + func sendResponseNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +new file mode 100644 +index 0000000..487c620 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -0,0 +1,50 @@ ++// ++// ProfileSwitchRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Switches to a saved profile. `expiration` is required: decoding fails without it. ++public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ public let profileName: String ++ public let fingerprint: String ++ public let remoteAddress: String ++ public let expirationDate: Date ++ public let sentAt: Date? ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ public var expiration: Date? { ++ return expirationDate ++ } ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expirationDate = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification[commandKey] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 0c5f8e3..3362bf7 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -108,6 +108,8 @@ extension Dictionary { + return try OverrideRemoteNotification(dictionary: self) + } else if OverrideCancelRemoteNotification.includedInNotification(self) { + return try OverrideCancelRemoteNotification(dictionary: self) ++ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { ++ return try ProfileSwitchRemoteNotification(dictionary: self) + } else { + throw RemoteNotificationError.unhandledNotification(self) + } +diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..2fcec94 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +@@ -0,0 +1,71 @@ ++// ++// ProfileSwitchRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "sent-at": "2023-02-25T20:46:35.778Z", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ] ++ ++ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) ++ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ ++ XCTAssertEqual(profileSwitch.profileName, "Sport") ++ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertTrue(profileSwitch.otpValidationRequired()) ++ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { ++ return XCTFail("Expected a profile switch action") ++ } ++ XCTAssertEqual(action.name, "Sport") ++ } ++ ++ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { ++ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ let profile = ProfileSet.Profile( ++ timezone: TimeZone(secondsFromGMT: 3600)!, ++ dia: .hours(6), ++ sensitivity: items([(0, 45)]), ++ carbratio: items([(0, 10)]), ++ basal: items([(0, 0.85), (21600, 1.1)]), ++ targetLow: items([(0, 100)]), ++ targetHigh: items([(0, 110)]), ++ units: "mg/dL") ++ ++ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") ++ } ++} ++ ++private extension ISO8601DateFormatter { ++ static let fractional: ISO8601DateFormatter = { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ }() ++} +diff --git a/remote_profile.loopcustomization b/remote_profile.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/remote_profile/dev_remote_profile_remote_carb_edit.patch b/remote_profile/dev_remote_profile_remote_carb_edit.patch new file mode 100644 index 0000000..556dacf --- /dev/null +++ b/remote_profile/dev_remote_profile_remote_carb_edit.patch @@ -0,0 +1,650 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index e7316b68..3b5f1de2 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -433,6 +433,7 @@ final class DeviceDataManager { + ) + + statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) ++ servicesManager.deviceDataManager = self + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 3eb3154b..6cefc092 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -293,6 +293,38 @@ extension NotificationManager { + } + } + ++ static func sendRemoteProfileNotification(name: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfile", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteProfileFailureNotification(name: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ private static func remoteProfileNotificationBody(name: String) -> String { ++ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ } ++ + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { + return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + } +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 3b74fd9d..5bf1ec36 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -28,6 +28,7 @@ class ServicesManager { + + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? ++ weak var deviceDataManager: DeviceDataManager? + + private var services = [Service]() + +@@ -456,3 +457,59 @@ extension Service { + ] + } + } ++ ++// MARK: - RemoteProfileActionDelegate ++ ++extension ServicesManager: RemoteProfileActionDelegate { ++ @MainActor ++ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { ++ do { ++ guard let deviceDataManager else { ++ throw RemoteProfileActionError.unavailable ++ } ++ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) ++ guard deviceDataManager.pumpSupportedIncrements() != nil else { ++ throw RemoteProfileActionError.noPump ++ } ++ guard let profile = try viewModel.savedProfile(named: name) else { ++ throw RemoteProfileActionError.profileNotFound(name) ++ } ++ try profile.verify(with: verify) ++ if case .failure(let error) = viewModel.validateProfile(profile) { ++ throw error ++ } ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ viewModel.triggerProfileSync() ++ NotificationManager.sendRemoteProfileNotification(name: name) ++ } catch { ++ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteProfileActionError: LocalizedError { ++ case unavailable ++ case noPump ++ case profileNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .unavailable: ++ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") ++ case .noPump: ++ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") ++ case .profileNotFound(let name): ++ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) ++ } ++ } ++ } ++} +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 4b2cecf3..56742c96 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -8,6 +8,12 @@ + + import Foundation + ++/// Switches to a saved therapy profile on a remote command. ++public protocol RemoteProfileActionDelegate: AnyObject { ++ /// `verify` receives the schedules about to be activated and throws to reject them. ++ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws ++} ++ + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws + func cancelRemoteOverride() async throws +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +index 74724fb7..f48848cd 100644 +--- a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +@@ -174,6 +174,7 @@ extension ProfileViewModel { + try jsonData.write(to: fileURL) + + self.loadProfiles() ++ triggerProfileSync() + } catch { + print("An error occurred while renaming profile: \(error)") + } +@@ -270,6 +271,8 @@ extension ProfileViewModel { + removeProfile(profileReference: profileReference) + if getCurrentProfileName() == profile.name { + clearCurrentProfile() ++ } else { ++ triggerProfileSync() + } + } + +@@ -295,6 +298,14 @@ extension ProfileViewModel { + return getProfile + } + ++ /// The saved profile named `name`, or nil when there is none. ++ public func savedProfile(named name: String) throws -> Profile? { ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ return nil ++ } ++ return try getProfile(from: profileReference) ++ } ++ + public func validateProfile(_ profile: Profile) -> Result { + guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { + return .failure(.fileLoadError) +@@ -406,3 +417,10 @@ extension ProfileViewModel { + return isOutOfSync + } + } ++ ++extension Profile { ++ /// Passes the schedules this profile activates to `check`. ++ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { ++ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) ++ } ++} +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index 9f75373..18143ab 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -24,6 +24,10 @@ + A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; +@@ -165,6 +169,10 @@ + A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; + A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; +@@ -282,6 +290,7 @@ + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, + A934B6172358F49300949C8B /* StoredSettings.swift */, ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, + A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, + A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, +@@ -341,6 +350,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -383,6 +393,7 @@ + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, + ); + path = Notifications; + sourceTree = ""; +@@ -431,6 +442,7 @@ + A9AA6E5729EB06AA008FFA78 /* Actions */ = { + isa = PBXGroup; + children = ( ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, +@@ -769,6 +781,9 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, + A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, + C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, + ); +@@ -785,6 +800,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, + A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +new file mode 100644 +index 0000000..de73ca9 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,104 @@ ++// ++// SavedProfile.swift ++// NightscoutServiceKit ++// ++ ++import CryptoKit ++import Foundation ++import HealthKit ++import LoopKit ++import NightscoutKit ++ ++/// A profile saved with the Profiles settings screen, read from the file it was saved to. ++struct SavedProfile: Decodable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ ++ private static var directory: URL { ++ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") ++ } ++ ++ static func loadAll() -> [SavedProfile] { ++ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] ++ return files ++ .filter { $0.pathExtension == "json" } ++ .compactMap { try? Data(contentsOf: $0) } ++ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } ++ } ++ ++ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. ++ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { ++ var store: [String: ProfileSet.Profile] = [:] ++ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. ++ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { ++ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) ++ } ++ return store ++ } ++ ++ /// True when this profile uploads with this fingerprint, in either glucose unit. ++ func matches(fingerprint: String) -> Bool { ++ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { ++ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint ++ } ++ } ++ ++ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { ++ guard let correctionRange = correctionRange.schedule(for: unit), ++ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) ++ else { ++ return nil ++ } ++ return ProfileSet.Profile( ++ timezone: basalRateSchedule.timeZone, ++ dia: .hours(6), ++ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, ++ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, ++ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) ++ } ++} ++ ++extension ProfileSet.Profile { ++ /// Followers compute the same value from the uploaded profile and send it with a switch command, ++ /// so Loop only activates the values the follower previewed. ++ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` ++ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. ++ var remoteFingerprint: String { ++ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { ++ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") ++ } ++ let canonical = [ ++ "basal=" + items(basal), ++ "carbratio=" + items(carbratio), ++ "sens=" + items(sensitivity), ++ "target_low=" + items(targetLow), ++ "target_high=" + items(targetHigh), ++ ].joined(separator: ";") ++ let digest = SHA256.hash(data: Data(canonical.utf8)) ++ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index 637988b..7c780ea 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -160,7 +160,7 @@ extension StoredDosingDecision { + recommendedBolus: loopStatusRecommendedBolus, + enacted: automaticDoseDecision?.loopStatusEnacted, + failureReason: automaticDoseDecision?.loopStatusFailureReason, +- testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported]), ++ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]]), + overrideStatus: overrideStatus) + } + +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +index a1769d8..dd7ac28 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +@@ -94,7 +94,8 @@ extension StoredSettings { + return nil + } + +- var store = ["Default": profile] ++ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) ++ store["Default"] = profile + var defaultProfile = "Default" + + if let name = UserDefaults.standard.string(forKey: "currentProfileName") { +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index 169fb86..18ac515 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -437,6 +437,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + success = true + message = "Override '\(overrideCommand.name)' enacted successfully" + ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ success = true ++ message = "Profile '\(profileSwitchCommand.name)' activated" ++ + case .cancelTemporaryOverride: + commandType = .cancelOverride + try await self.serviceDelegate?.cancelRemoteOverride() +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index 91ad56c..29d3563 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -9,6 +9,7 @@ + import Foundation + + public enum Action: Codable { ++ case profileSwitch(ProfileSwitchAction) + case temporaryScheduleOverride(OverrideAction) + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) +@@ -34,6 +35,8 @@ public enum Action: Codable { + return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") + case .temporaryScheduleOverride: + return LocalizedString("Override", comment: "The remote action name for Override") ++ case .profileSwitch: ++ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") + } + } + +@@ -54,6 +57,8 @@ public enum Action: Codable { + return "" + case .temporaryScheduleOverride(let overrideAction): + return overrideAction.name ++ case .profileSwitch(let profileSwitchAction): ++ return profileSwitchAction.name + } + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +new file mode 100644 +index 0000000..ce03ded +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +@@ -0,0 +1,17 @@ ++// ++// ProfileSwitchAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct ProfileSwitchAction: Codable { ++ ++ public let name: String ++ public let fingerprint: String ++ ++ public init(name: String, fingerprint: String) { ++ self.name = name ++ self.fingerprint = fingerprint ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 6092bc9..aabb47f 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -49,6 +49,7 @@ class RemoteNotificationResponseManager { + case carbsEdit = "carbs_edit" + case override = "override" + case cancelOverride = "cancel_override" ++ case profileSwitch = "profile_switch" + } + + func sendResponseNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +new file mode 100644 +index 0000000..487c620 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -0,0 +1,50 @@ ++// ++// ProfileSwitchRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Switches to a saved profile. `expiration` is required: decoding fails without it. ++public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ public let profileName: String ++ public let fingerprint: String ++ public let remoteAddress: String ++ public let expirationDate: Date ++ public let sentAt: Date? ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ public var expiration: Date? { ++ return expirationDate ++ } ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expirationDate = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification[commandKey] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 6f0524d..87f1e92 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -122,6 +122,8 @@ extension Dictionary { + return try OverrideRemoteNotification(dictionary: self) + } else if OverrideCancelRemoteNotification.includedInNotification(self) { + return try OverrideCancelRemoteNotification(dictionary: self) ++ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { ++ return try ProfileSwitchRemoteNotification(dictionary: self) + } else { + throw RemoteNotificationError.unhandledNotification(self) + } +diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..2fcec94 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +@@ -0,0 +1,71 @@ ++// ++// ProfileSwitchRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "sent-at": "2023-02-25T20:46:35.778Z", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ] ++ ++ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) ++ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ ++ XCTAssertEqual(profileSwitch.profileName, "Sport") ++ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertTrue(profileSwitch.otpValidationRequired()) ++ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { ++ return XCTFail("Expected a profile switch action") ++ } ++ XCTAssertEqual(action.name, "Sport") ++ } ++ ++ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { ++ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ let profile = ProfileSet.Profile( ++ timezone: TimeZone(secondsFromGMT: 3600)!, ++ dia: .hours(6), ++ sensitivity: items([(0, 45)]), ++ carbratio: items([(0, 10)]), ++ basal: items([(0, 0.85), (21600, 1.1)]), ++ targetLow: items([(0, 100)]), ++ targetHigh: items([(0, 110)]), ++ units: "mg/dL") ++ ++ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") ++ } ++} ++ ++private extension ISO8601DateFormatter { ++ static let fractional: ISO8601DateFormatter = { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ }() ++} +diff --git a/remote_profile.loopcustomization b/remote_profile.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/remote_profile/dev_remote_profile_remote_carb_edit_remote_window.patch b/remote_profile/dev_remote_profile_remote_carb_edit_remote_window.patch new file mode 100644 index 0000000..29eaf5c --- /dev/null +++ b/remote_profile/dev_remote_profile_remote_carb_edit_remote_window.patch @@ -0,0 +1,713 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index 93d1204d..c253e88e 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -433,6 +433,7 @@ final class DeviceDataManager { + ) + + statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) ++ servicesManager.deviceDataManager = self + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 3eb3154b..6cefc092 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -293,6 +293,38 @@ extension NotificationManager { + } + } + ++ static func sendRemoteProfileNotification(name: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfile", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteProfileFailureNotification(name: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ private static func remoteProfileNotificationBody(name: String) -> String { ++ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ } ++ + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { + return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + } +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 1e272bb2..161a42e3 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -28,6 +28,7 @@ class ServicesManager { + + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? ++ weak var deviceDataManager: DeviceDataManager? + + private var services = [Service]() + +@@ -482,3 +483,59 @@ extension Service { + ] + } + } ++ ++// MARK: - RemoteProfileActionDelegate ++ ++extension ServicesManager: RemoteProfileActionDelegate { ++ @MainActor ++ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { ++ do { ++ guard let deviceDataManager else { ++ throw RemoteProfileActionError.unavailable ++ } ++ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) ++ guard deviceDataManager.pumpSupportedIncrements() != nil else { ++ throw RemoteProfileActionError.noPump ++ } ++ guard let profile = try viewModel.savedProfile(named: name) else { ++ throw RemoteProfileActionError.profileNotFound(name) ++ } ++ try profile.verify(with: verify) ++ if case .failure(let error) = viewModel.validateProfile(profile) { ++ throw error ++ } ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ viewModel.triggerProfileSync() ++ NotificationManager.sendRemoteProfileNotification(name: name) ++ } catch { ++ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteProfileActionError: LocalizedError { ++ case unavailable ++ case noPump ++ case profileNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .unavailable: ++ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") ++ case .noPump: ++ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") ++ case .profileNotFound(let name): ++ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) ++ } ++ } ++ } ++} +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 8fb254b6..858a5621 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -8,6 +8,12 @@ + + import Foundation + ++/// Switches to a saved therapy profile on a remote command. ++public protocol RemoteProfileActionDelegate: AnyObject { ++ /// `verify` receives the schedules about to be activated and throws to reject them. ++ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws ++} ++ + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride + func cancelRemoteOverride() async throws -> TemporaryScheduleOverride +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +index 74724fb7..f48848cd 100644 +--- a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +@@ -174,6 +174,7 @@ extension ProfileViewModel { + try jsonData.write(to: fileURL) + + self.loadProfiles() ++ triggerProfileSync() + } catch { + print("An error occurred while renaming profile: \(error)") + } +@@ -270,6 +271,8 @@ extension ProfileViewModel { + removeProfile(profileReference: profileReference) + if getCurrentProfileName() == profile.name { + clearCurrentProfile() ++ } else { ++ triggerProfileSync() + } + } + +@@ -295,6 +298,14 @@ extension ProfileViewModel { + return getProfile + } + ++ /// The saved profile named `name`, or nil when there is none. ++ public func savedProfile(named name: String) throws -> Profile? { ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ return nil ++ } ++ return try getProfile(from: profileReference) ++ } ++ + public func validateProfile(_ profile: Profile) -> Result { + guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { + return .failure(.fileLoadError) +@@ -406,3 +417,10 @@ extension ProfileViewModel { + return isOutOfSync + } + } ++ ++extension Profile { ++ /// Passes the schedules this profile activates to `check`. ++ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { ++ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) ++ } ++} +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index 9f75373..18143ab 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -24,6 +24,10 @@ + A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; +@@ -165,6 +169,10 @@ + A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; + A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; +@@ -282,6 +290,7 @@ + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, + A934B6172358F49300949C8B /* StoredSettings.swift */, ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, + A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, + A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, +@@ -341,6 +350,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -383,6 +393,7 @@ + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, + ); + path = Notifications; + sourceTree = ""; +@@ -431,6 +442,7 @@ + A9AA6E5729EB06AA008FFA78 /* Actions */ = { + isa = PBXGroup; + children = ( ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, +@@ -769,6 +781,9 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, + A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, + C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, + ); +@@ -785,6 +800,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, + A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +new file mode 100644 +index 0000000..de73ca9 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,104 @@ ++// ++// SavedProfile.swift ++// NightscoutServiceKit ++// ++ ++import CryptoKit ++import Foundation ++import HealthKit ++import LoopKit ++import NightscoutKit ++ ++/// A profile saved with the Profiles settings screen, read from the file it was saved to. ++struct SavedProfile: Decodable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ ++ private static var directory: URL { ++ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") ++ } ++ ++ static func loadAll() -> [SavedProfile] { ++ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] ++ return files ++ .filter { $0.pathExtension == "json" } ++ .compactMap { try? Data(contentsOf: $0) } ++ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } ++ } ++ ++ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. ++ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { ++ var store: [String: ProfileSet.Profile] = [:] ++ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. ++ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { ++ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) ++ } ++ return store ++ } ++ ++ /// True when this profile uploads with this fingerprint, in either glucose unit. ++ func matches(fingerprint: String) -> Bool { ++ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { ++ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint ++ } ++ } ++ ++ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { ++ guard let correctionRange = correctionRange.schedule(for: unit), ++ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) ++ else { ++ return nil ++ } ++ return ProfileSet.Profile( ++ timezone: basalRateSchedule.timeZone, ++ dia: .hours(6), ++ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, ++ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, ++ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) ++ } ++} ++ ++extension ProfileSet.Profile { ++ /// Followers compute the same value from the uploaded profile and send it with a switch command, ++ /// so Loop only activates the values the follower previewed. ++ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` ++ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. ++ var remoteFingerprint: String { ++ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { ++ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") ++ } ++ let canonical = [ ++ "basal=" + items(basal), ++ "carbratio=" + items(carbratio), ++ "sens=" + items(sensitivity), ++ "target_low=" + items(targetLow), ++ "target_high=" + items(targetHigh), ++ ].joined(separator: ";") ++ let digest = SHA256.hash(data: Data(canonical.utf8)) ++ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index 637988b..7c780ea 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -160,7 +160,7 @@ extension StoredDosingDecision { + recommendedBolus: loopStatusRecommendedBolus, + enacted: automaticDoseDecision?.loopStatusEnacted, + failureReason: automaticDoseDecision?.loopStatusFailureReason, +- testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported]), ++ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]]), + overrideStatus: overrideStatus) + } + +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +index a1769d8..dd7ac28 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +@@ -94,7 +94,8 @@ extension StoredSettings { + return nil + } + +- var store = ["Default": profile] ++ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) ++ store["Default"] = profile + var defaultProfile = "Default" + + if let name = UserDefaults.standard.string(forKey: "currentProfileName") { +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index 91ad56c..29d3563 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -9,6 +9,7 @@ + import Foundation + + public enum Action: Codable { ++ case profileSwitch(ProfileSwitchAction) + case temporaryScheduleOverride(OverrideAction) + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) +@@ -34,6 +35,8 @@ public enum Action: Codable { + return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") + case .temporaryScheduleOverride: + return LocalizedString("Override", comment: "The remote action name for Override") ++ case .profileSwitch: ++ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") + } + } + +@@ -54,6 +57,8 @@ public enum Action: Codable { + return "" + case .temporaryScheduleOverride(let overrideAction): + return overrideAction.name ++ case .profileSwitch(let profileSwitchAction): ++ return profileSwitchAction.name + } + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +new file mode 100644 +index 0000000..ce03ded +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +@@ -0,0 +1,17 @@ ++// ++// ProfileSwitchAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct ProfileSwitchAction: Codable { ++ ++ public let name: String ++ public let fingerprint: String ++ ++ public init(name: String, fingerprint: String) { ++ self.name = name ++ self.fingerprint = fingerprint ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 6092bc9..aabb47f 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -49,6 +49,7 @@ class RemoteNotificationResponseManager { + case carbsEdit = "carbs_edit" + case override = "override" + case cancelOverride = "cancel_override" ++ case profileSwitch = "profile_switch" + } + + func sendResponseNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +new file mode 100644 +index 0000000..ebcd6ef +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -0,0 +1,50 @@ ++// ++// ProfileSwitchRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Switches to a saved profile. `expiration` is required: decoding fails without it. ++public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ public let profileName: String ++ public let fingerprint: String ++ public let remoteAddress: String ++ public let expirationDate: Date ++ public let sentAt: Date ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ public var expiration: Date? { ++ return expirationDate ++ } ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expirationDate = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification[commandKey] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index be43210..1ea8b96 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -118,6 +118,8 @@ extension Dictionary { + return try OverrideRemoteNotification(dictionary: self) + } else if OverrideCancelRemoteNotification.includedInNotification(self) { + return try OverrideCancelRemoteNotification(dictionary: self) ++ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { ++ return try ProfileSwitchRemoteNotification(dictionary: self) + } else { + throw RemoteNotificationError.unhandledNotification(self) + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +index 331ed5c..bff69c8 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +@@ -123,6 +123,27 @@ class RemoteCommandSourceV1: RemoteCommandSource { + status: .success(date: Date(), syncIdentifier: replacementEntry.syncIdentifier ?? "", completionMessage: nil), + notificationJSON: notification + ) ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ successMessage = "Profile '\(profileSwitchCommand.name)' activated" ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: "", completionMessage: nil), ++ notificationJSON: notification ++ ) + case .temporaryScheduleOverride(let overrideCommand): + commandType = .override + let override = try await serviceDelegate.enactRemoteOverride( +@@ -260,6 +281,10 @@ public class StoredRemoteNotification: NSObject, Codable { + self.init(notificationType: .carbsEdit(carbEditNotification), notificationJSONData: notificationJSONData) + } + ++ convenience init(profileSwitchNotification: ProfileSwitchRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .profileSwitch(profileSwitchNotification), notificationJSONData: notificationJSONData) ++ } ++ + convenience init(overrideNotification: OverrideRemoteNotification, notificationJSONData: Data) { + self.init(notificationType: .override(overrideNotification), notificationJSONData: notificationJSONData) + } +@@ -347,6 +372,8 @@ public class StoredRemoteNotification: NSObject, Codable { + return carbDeleteNotification + case let .carbsEdit(carbEditNotification): + return carbEditNotification ++ case let .profileSwitch(profileSwitchNotification): ++ return profileSwitchNotification + case let .override(overrideNotification): + return overrideNotification + case let .overrideCancel(overrideCancelNotification): +@@ -377,6 +404,7 @@ public class StoredRemoteNotification: NSObject, Codable { + case carbsEdit(CarbEditRemoteNotification) + case override(OverrideRemoteNotification) + case overrideCancel(OverrideCancelRemoteNotification) ++ case profileSwitch(ProfileSwitchRemoteNotification) + } + } + +@@ -433,6 +461,9 @@ actor RecentNotifications { + } else if let carbEditNotification = remoteNotification as? CarbEditRemoteNotification { + let storedNotification = StoredRemoteNotification(carbEditNotification: carbEditNotification, notificationJSONData: data) + try storeNotification(storedNotification) ++ } else if let profileSwitchNotification = remoteNotification as? ProfileSwitchRemoteNotification { ++ let storedNotification = StoredRemoteNotification(profileSwitchNotification: profileSwitchNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) + } else if let overrideNotification = remoteNotification as? OverrideRemoteNotification { + let storedNotification = StoredRemoteNotification(overrideNotification: overrideNotification, notificationJSONData: data) + try storeNotification(storedNotification) +diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..2fcec94 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +@@ -0,0 +1,71 @@ ++// ++// ProfileSwitchRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "sent-at": "2023-02-25T20:46:35.778Z", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ] ++ ++ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) ++ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ ++ XCTAssertEqual(profileSwitch.profileName, "Sport") ++ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertTrue(profileSwitch.otpValidationRequired()) ++ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { ++ return XCTFail("Expected a profile switch action") ++ } ++ XCTAssertEqual(action.name, "Sport") ++ } ++ ++ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { ++ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ let profile = ProfileSet.Profile( ++ timezone: TimeZone(secondsFromGMT: 3600)!, ++ dia: .hours(6), ++ sensitivity: items([(0, 45)]), ++ carbratio: items([(0, 10)]), ++ basal: items([(0, 0.85), (21600, 1.1)]), ++ targetLow: items([(0, 100)]), ++ targetHigh: items([(0, 110)]), ++ units: "mg/dL") ++ ++ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") ++ } ++} ++ ++private extension ISO8601DateFormatter { ++ static let fractional: ISO8601DateFormatter = { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ }() ++} +diff --git a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +index 0bfd600..bb9423b 100644 +--- a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift ++++ b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +@@ -60,6 +60,8 @@ extension StoredRemoteNotification { + return "Edit Carbs" + case .override: + return "Override" ++ case .profileSwitch: ++ return "Profile Switch" + case .overrideCancel: + return "Cancel Override" + } +@@ -84,6 +86,8 @@ extension StoredRemoteNotification { + return String(format: "%.0f g", notification.amount) + case .override(let notification): + return notification.name ++ case .profileSwitch(let notification): ++ return notification.profileName + case .overrideCancel: + return "" + } +diff --git a/remote_profile.loopcustomization b/remote_profile.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/remote_profile/dev_remote_profile_remote_window.patch b/remote_profile/dev_remote_profile_remote_window.patch new file mode 100644 index 0000000..2dd8db5 --- /dev/null +++ b/remote_profile/dev_remote_profile_remote_window.patch @@ -0,0 +1,714 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index 93d1204d..c253e88e 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -433,6 +433,7 @@ final class DeviceDataManager { + ) + + statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) ++ servicesManager.deviceDataManager = self + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 996d1470..17cff75d 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -263,6 +263,38 @@ extension NotificationManager { + } + } + ++ static func sendRemoteProfileNotification(name: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfile", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteProfileFailureNotification(name: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ private static func remoteProfileNotificationBody(name: String) -> String { ++ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ } ++ + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { + return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + } +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 0b498420..f5f0d3c5 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -28,6 +28,7 @@ class ServicesManager { + + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? ++ weak var deviceDataManager: DeviceDataManager? + + private var services = [Service]() + +@@ -447,3 +448,59 @@ extension Service { + ] + } + } ++ ++// MARK: - RemoteProfileActionDelegate ++ ++extension ServicesManager: RemoteProfileActionDelegate { ++ @MainActor ++ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { ++ do { ++ guard let deviceDataManager else { ++ throw RemoteProfileActionError.unavailable ++ } ++ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) ++ guard deviceDataManager.pumpSupportedIncrements() != nil else { ++ throw RemoteProfileActionError.noPump ++ } ++ guard let profile = try viewModel.savedProfile(named: name) else { ++ throw RemoteProfileActionError.profileNotFound(name) ++ } ++ try profile.verify(with: verify) ++ if case .failure(let error) = viewModel.validateProfile(profile) { ++ throw error ++ } ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ viewModel.triggerProfileSync() ++ NotificationManager.sendRemoteProfileNotification(name: name) ++ } catch { ++ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteProfileActionError: LocalizedError { ++ case unavailable ++ case noPump ++ case profileNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .unavailable: ++ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") ++ case .noPump: ++ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") ++ case .profileNotFound(let name): ++ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) ++ } ++ } ++ } ++} +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index e4279746..6f691598 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -8,6 +8,12 @@ + + import Foundation + ++/// Switches to a saved therapy profile on a remote command. ++public protocol RemoteProfileActionDelegate: AnyObject { ++ /// `verify` receives the schedules about to be activated and throws to reject them. ++ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws ++} ++ + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride + func cancelRemoteOverride() async throws -> TemporaryScheduleOverride +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +index 74724fb7..f48848cd 100644 +--- a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +@@ -174,6 +174,7 @@ extension ProfileViewModel { + try jsonData.write(to: fileURL) + + self.loadProfiles() ++ triggerProfileSync() + } catch { + print("An error occurred while renaming profile: \(error)") + } +@@ -270,6 +271,8 @@ extension ProfileViewModel { + removeProfile(profileReference: profileReference) + if getCurrentProfileName() == profile.name { + clearCurrentProfile() ++ } else { ++ triggerProfileSync() + } + } + +@@ -295,6 +298,14 @@ extension ProfileViewModel { + return getProfile + } + ++ /// The saved profile named `name`, or nil when there is none. ++ public func savedProfile(named name: String) throws -> Profile? { ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ return nil ++ } ++ return try getProfile(from: profileReference) ++ } ++ + public func validateProfile(_ profile: Profile) -> Result { + guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { + return .failure(.fileLoadError) +@@ -406,3 +417,10 @@ extension ProfileViewModel { + return isOutOfSync + } + } ++ ++extension Profile { ++ /// Passes the schedules this profile activates to `check`. ++ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { ++ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) ++ } ++} +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index 69f7a26..14a7691 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -24,6 +24,10 @@ + A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; +@@ -158,6 +162,10 @@ + A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; + A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; +@@ -268,6 +276,7 @@ + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, + A934B6172358F49300949C8B /* StoredSettings.swift */, ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, + A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, + A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, +@@ -324,6 +333,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -364,6 +374,7 @@ + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, + ); + path = Notifications; + sourceTree = ""; +@@ -412,6 +423,7 @@ + A9AA6E5729EB06AA008FFA78 /* Actions */ = { + isa = PBXGroup; + children = ( ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, +@@ -744,6 +756,9 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, + A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, + C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, + ); +@@ -757,6 +772,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, + A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +new file mode 100644 +index 0000000..de73ca9 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,104 @@ ++// ++// SavedProfile.swift ++// NightscoutServiceKit ++// ++ ++import CryptoKit ++import Foundation ++import HealthKit ++import LoopKit ++import NightscoutKit ++ ++/// A profile saved with the Profiles settings screen, read from the file it was saved to. ++struct SavedProfile: Decodable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ ++ private static var directory: URL { ++ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") ++ } ++ ++ static func loadAll() -> [SavedProfile] { ++ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] ++ return files ++ .filter { $0.pathExtension == "json" } ++ .compactMap { try? Data(contentsOf: $0) } ++ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } ++ } ++ ++ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. ++ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { ++ var store: [String: ProfileSet.Profile] = [:] ++ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. ++ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { ++ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) ++ } ++ return store ++ } ++ ++ /// True when this profile uploads with this fingerprint, in either glucose unit. ++ func matches(fingerprint: String) -> Bool { ++ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { ++ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint ++ } ++ } ++ ++ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { ++ guard let correctionRange = correctionRange.schedule(for: unit), ++ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) ++ else { ++ return nil ++ } ++ return ProfileSet.Profile( ++ timezone: basalRateSchedule.timeZone, ++ dia: .hours(6), ++ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, ++ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, ++ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) ++ } ++} ++ ++extension ProfileSet.Profile { ++ /// Followers compute the same value from the uploaded profile and send it with a switch command, ++ /// so Loop only activates the values the follower previewed. ++ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` ++ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. ++ var remoteFingerprint: String { ++ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { ++ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") ++ } ++ let canonical = [ ++ "basal=" + items(basal), ++ "carbratio=" + items(carbratio), ++ "sens=" + items(sensitivity), ++ "target_low=" + items(targetLow), ++ "target_high=" + items(targetHigh), ++ ].joined(separator: ";") ++ let digest = SHA256.hash(data: Data(canonical.utf8)) ++ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index 4e1d49e..b66fb97 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -159,7 +159,8 @@ extension StoredDosingDecision { + automaticDoseRecommendation: loopStatusAutomaticDoseRecommendation, + recommendedBolus: loopStatusRecommendedBolus, + enacted: automaticDoseDecision?.loopStatusEnacted, +- failureReason: automaticDoseDecision?.loopStatusFailureReason), ++ failureReason: automaticDoseDecision?.loopStatusFailureReason, ++ testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]]), + overrideStatus: overrideStatus) + } + +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +index a1769d8..dd7ac28 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +@@ -94,7 +94,8 @@ extension StoredSettings { + return nil + } + +- var store = ["Default": profile] ++ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) ++ store["Default"] = profile + var defaultProfile = "Default" + + if let name = UserDefaults.standard.string(forKey: "currentProfileName") { +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index a92b509..29313b6 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -9,6 +9,7 @@ + import Foundation + + public enum Action: Codable { ++ case profileSwitch(ProfileSwitchAction) + case temporaryScheduleOverride(OverrideAction) + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) +@@ -28,6 +29,8 @@ public enum Action: Codable { + return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") + case .temporaryScheduleOverride: + return LocalizedString("Override", comment: "The remote action name for Override") ++ case .profileSwitch: ++ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") + } + } + +@@ -43,6 +46,8 @@ public enum Action: Codable { + return "" + case .temporaryScheduleOverride(let overrideAction): + return overrideAction.name ++ case .profileSwitch(let profileSwitchAction): ++ return profileSwitchAction.name + } + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +new file mode 100644 +index 0000000..ce03ded +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +@@ -0,0 +1,17 @@ ++// ++// ProfileSwitchAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct ProfileSwitchAction: Codable { ++ ++ public let name: String ++ public let fingerprint: String ++ ++ public init(name: String, fingerprint: String) { ++ self.name = name ++ self.fingerprint = fingerprint ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 95274e4..4457f80 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -45,6 +45,7 @@ class RemoteNotificationResponseManager { + case carbs = "carbs" + case override = "override" + case cancelOverride = "cancel_override" ++ case profileSwitch = "profile_switch" + } + + func sendResponseNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +new file mode 100644 +index 0000000..ebcd6ef +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -0,0 +1,50 @@ ++// ++// ProfileSwitchRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Switches to a saved profile. `expiration` is required: decoding fails without it. ++public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ public let profileName: String ++ public let fingerprint: String ++ public let remoteAddress: String ++ public let expirationDate: Date ++ public let sentAt: Date ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ public var expiration: Date? { ++ return expirationDate ++ } ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expirationDate = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification[commandKey] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 24e63d7..a0f58a0 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -104,6 +104,8 @@ extension Dictionary { + return try OverrideRemoteNotification(dictionary: self) + } else if OverrideCancelRemoteNotification.includedInNotification(self) { + return try OverrideCancelRemoteNotification(dictionary: self) ++ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { ++ return try ProfileSwitchRemoteNotification(dictionary: self) + } else { + throw RemoteNotificationError.unhandledNotification(self) + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +index 84ff118..0912feb 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +@@ -96,6 +96,27 @@ class RemoteCommandSourceV1: RemoteCommandSource { + status: .success(date: Date(), syncIdentifier: carbEntry.syncIdentifier ?? "", completionMessage: nil), + notificationJSON: notification + ) ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ successMessage = "Profile '\(profileSwitchCommand.name)' activated" ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: "", completionMessage: nil), ++ notificationJSON: notification ++ ) + case .temporaryScheduleOverride(let overrideCommand): + commandType = .override + let override = try await serviceDelegate.enactRemoteOverride( +@@ -223,6 +244,10 @@ public class StoredRemoteNotification: NSObject, Codable { + self.init(notificationType: .carbs(carbNotification), notificationJSONData: notificationJSONData) + } + ++ convenience init(profileSwitchNotification: ProfileSwitchRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .profileSwitch(profileSwitchNotification), notificationJSONData: notificationJSONData) ++ } ++ + convenience init(overrideNotification: OverrideRemoteNotification, notificationJSONData: Data) { + self.init(notificationType: .override(overrideNotification), notificationJSONData: notificationJSONData) + } +@@ -306,6 +331,8 @@ public class StoredRemoteNotification: NSObject, Codable { + return bolusNotification + case let .carbs(carbNotification): + return carbNotification ++ case let .profileSwitch(profileSwitchNotification): ++ return profileSwitchNotification + case let .override(overrideNotification): + return overrideNotification + case let .overrideCancel(overrideCancelNotification): +@@ -334,6 +361,7 @@ public class StoredRemoteNotification: NSObject, Codable { + case carbs(CarbRemoteNotification) + case override(OverrideRemoteNotification) + case overrideCancel(OverrideCancelRemoteNotification) ++ case profileSwitch(ProfileSwitchRemoteNotification) + } + } + +@@ -384,6 +412,9 @@ actor RecentNotifications { + } else if let carbNotification = remoteNotification as? CarbRemoteNotification { + let storedNotification = StoredRemoteNotification(carbNotification: carbNotification, notificationJSONData: data) + try storeNotification(storedNotification) ++ } else if let profileSwitchNotification = remoteNotification as? ProfileSwitchRemoteNotification { ++ let storedNotification = StoredRemoteNotification(profileSwitchNotification: profileSwitchNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) + } else if let overrideNotification = remoteNotification as? OverrideRemoteNotification { + let storedNotification = StoredRemoteNotification(overrideNotification: overrideNotification, notificationJSONData: data) + try storeNotification(storedNotification) +diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..2fcec94 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +@@ -0,0 +1,71 @@ ++// ++// ProfileSwitchRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "sent-at": "2023-02-25T20:46:35.778Z", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ] ++ ++ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) ++ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ ++ XCTAssertEqual(profileSwitch.profileName, "Sport") ++ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertTrue(profileSwitch.otpValidationRequired()) ++ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { ++ return XCTFail("Expected a profile switch action") ++ } ++ XCTAssertEqual(action.name, "Sport") ++ } ++ ++ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { ++ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ let profile = ProfileSet.Profile( ++ timezone: TimeZone(secondsFromGMT: 3600)!, ++ dia: .hours(6), ++ sensitivity: items([(0, 45)]), ++ carbratio: items([(0, 10)]), ++ basal: items([(0, 0.85), (21600, 1.1)]), ++ targetLow: items([(0, 100)]), ++ targetHigh: items([(0, 110)]), ++ units: "mg/dL") ++ ++ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") ++ } ++} ++ ++private extension ISO8601DateFormatter { ++ static let fractional: ISO8601DateFormatter = { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ }() ++} +diff --git a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +index 2a3fff3..4ce6ab7 100644 +--- a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift ++++ b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +@@ -56,6 +56,8 @@ extension StoredRemoteNotification { + return "Carbs" + case .override: + return "Override" ++ case .profileSwitch: ++ return "Profile Switch" + case .overrideCancel: + return "Cancel Override" + } +@@ -76,6 +78,8 @@ extension StoredRemoteNotification { + return String(format: "%.0f g", notification.amount) + case .override(let notification): + return notification.name ++ case .profileSwitch(let notification): ++ return notification.profileName + case .overrideCancel: + return "" + } +diff --git a/remote_profile.loopcustomization b/remote_profile.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/remote_profile/nextdev_remote_profile.patch b/remote_profile/nextdev_remote_profile.patch new file mode 100644 index 0000000..0f65390 --- /dev/null +++ b/remote_profile/nextdev_remote_profile.patch @@ -0,0 +1,660 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index 6b70853e..5591c9a5 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -1411,6 +1411,7 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { + NotificationCenter.default.post(name: .LoopDataUpdated, + object: nil, + userInfo: [LoopDataManager.LoopUpdateContextKey: LoopUpdateContext.preferences.rawValue]) ++ settingsManager.storeSettings() + } + + func saveCompletion(therapySettings: TherapySettings) { +diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift +index 6b263045..fe3c3e7f 100644 +--- a/Loop/Loop/Managers/LoopAppManager.swift ++++ b/Loop/Loop/Managers/LoopAppManager.swift +@@ -401,6 +401,7 @@ class LoopAppManager: NSObject { + + dosingDecisionStore.delegate = deviceDataManager + remoteDataServicesManager.delegate = deviceDataManager ++ servicesManager.therapySettingsViewModelDelegate = deviceDataManager + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceDataManager.deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 42eff19f..c4d0f32e 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -298,6 +298,38 @@ extension NotificationManager { + UNUserNotificationCenter.current().add(request) + } + ++ static func sendRemoteProfileNotification(name: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfile", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteProfileFailureNotification(name: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ private static func remoteProfileNotificationBody(name: String) -> String { ++ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ } ++ + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { + return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + } +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index b557f184..15602f08 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -30,6 +30,7 @@ class ServicesManager { + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? + weak var supportManager: SupportManager? ++ weak var therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? + + private var services = [Service]() + +@@ -423,3 +424,58 @@ extension Service { + ] + } + } ++ ++// MARK: - RemoteProfileActionDelegate ++ ++extension ServicesManager: RemoteProfileActionDelegate { ++ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { ++ do { ++ guard let therapySettingsViewModelDelegate else { ++ throw RemoteProfileActionError.unavailable ++ } ++ let viewModel = ProfileViewModel(therapySettings: settingsManager.therapySettings, delegate: therapySettingsViewModelDelegate) ++ guard therapySettingsViewModelDelegate.pumpSupportedIncrements() != nil else { ++ throw RemoteProfileActionError.noPump ++ } ++ guard let profile = try viewModel.savedProfile(named: name) else { ++ throw RemoteProfileActionError.profileNotFound(name) ++ } ++ try profile.verify(with: verify) ++ if case .failure(let error) = viewModel.validateProfile(profile) { ++ throw error ++ } ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ viewModel.triggerProfileSync() ++ NotificationManager.sendRemoteProfileNotification(name: name) ++ } catch { ++ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteProfileActionError: LocalizedError { ++ case unavailable ++ case noPump ++ case profileNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .unavailable: ++ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: profile view model delegate missing.") ++ case .noPump: ++ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") ++ case .profileNotFound(let name): ++ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) ++ } ++ } ++ } ++} +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 8f824906..a7c9c653 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -8,6 +8,12 @@ + + import Foundation + ++/// Switches to a saved therapy profile on a remote command. ++public protocol RemoteProfileActionDelegate: AnyObject { ++ /// `verify` receives the schedules about to be activated and throws to reject them. ++ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws ++} ++ + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws + func cancelRemoteOverride() async throws +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +index cd8d48b8..0a287cac 100644 +--- a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +@@ -175,6 +175,7 @@ extension ProfileViewModel { + try jsonData.write(to: fileURL) + + self.loadProfiles() ++ triggerProfileSync() + } catch { + print("An error occurred while renaming profile: \(error)") + } +@@ -271,6 +272,8 @@ extension ProfileViewModel { + removeProfile(profileReference: profileReference) + if getCurrentProfileName() == profile.name { + clearCurrentProfile() ++ } else { ++ triggerProfileSync() + } + } + +@@ -296,6 +299,14 @@ extension ProfileViewModel { + return getProfile + } + ++ /// The saved profile named `name`, or nil when there is none. ++ public func savedProfile(named name: String) throws -> Profile? { ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ return nil ++ } ++ return try getProfile(from: profileReference) ++ } ++ + public func validateProfile(_ profile: Profile) -> Result { + guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { + return .failure(.fileLoadError) +@@ -407,3 +418,10 @@ extension ProfileViewModel { + return isOutOfSync + } + } ++ ++extension Profile { ++ /// Passes the schedules this profile activates to `check`. ++ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { ++ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) ++ } ++} +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index 00096e2..cf6883e 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -23,6 +23,10 @@ + A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; +@@ -156,6 +160,10 @@ + A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; + A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; +@@ -265,6 +273,7 @@ + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, + A934B6172358F49300949C8B /* StoredSettings.swift */, ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, + A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, + A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, +@@ -321,6 +330,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -361,6 +371,7 @@ + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, + ); + path = Notifications; + sourceTree = ""; +@@ -409,6 +420,7 @@ + A9AA6E5729EB06AA008FFA78 /* Actions */ = { + isa = PBXGroup; + children = ( ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, +@@ -740,6 +752,9 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, + A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, + C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, + ); +@@ -753,6 +768,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, + A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +new file mode 100644 +index 0000000..588ded1 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,104 @@ ++// ++// SavedProfile.swift ++// NightscoutServiceKit ++// ++ ++import CryptoKit ++import Foundation ++import LoopAlgorithm ++import LoopKit ++import NightscoutKit ++ ++/// A profile saved with the Profiles settings screen, read from the file it was saved to. ++struct SavedProfile: Decodable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ ++ private static var directory: URL { ++ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") ++ } ++ ++ static func loadAll() -> [SavedProfile] { ++ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] ++ return files ++ .filter { $0.pathExtension == "json" } ++ .compactMap { try? Data(contentsOf: $0) } ++ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } ++ } ++ ++ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. ++ static func nightscoutStore(unit: LoopUnit) -> [String: ProfileSet.Profile] { ++ var store: [String: ProfileSet.Profile] = [:] ++ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. ++ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { ++ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) ++ } ++ return store ++ } ++ ++ /// True when this profile uploads with this fingerprint, in either glucose unit. ++ func matches(fingerprint: String) -> Bool { ++ [LoopUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { ++ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint ++ } ++ } ++ ++ func nightscoutProfile(unit: LoopUnit) -> ProfileSet.Profile? { ++ guard let correctionRange = correctionRange.schedule(for: unit), ++ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) ++ else { ++ return nil ++ } ++ return ProfileSet.Profile( ++ timezone: basalRateSchedule.timeZone, ++ dia: .hours(6), ++ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, ++ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, ++ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) ++ } ++} ++ ++extension ProfileSet.Profile { ++ /// Followers compute the same value from the uploaded profile and send it with a switch command, ++ /// so Loop only activates the values the follower previewed. ++ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` ++ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. ++ var remoteFingerprint: String { ++ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { ++ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") ++ } ++ let canonical = [ ++ "basal=" + items(basal), ++ "carbratio=" + items(carbratio), ++ "sens=" + items(sensitivity), ++ "target_low=" + items(targetLow), ++ "target_high=" + items(targetHigh), ++ ].joined(separator: ";") ++ let digest = SHA256.hash(data: Data(canonical.utf8)) ++ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index 2f12b47..2dec600 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -180,6 +180,7 @@ extension StoredDosingDecision { + enacted: automaticDoseDecision?.loopStatusEnacted, + failureReason: automaticDoseDecision?.loopStatusFailureReason, + failureCode: automaticDoseDecision?.errors.first?.id, ++ testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]], + algorithmExperiments: (automaticDoseDecision?.algorithmExperiments ?? algorithmExperiments)?.map { $0.jsonObject }), + overrideStatus: overrideStatus) + } +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +index 790330a..ae6fb9a 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +@@ -93,7 +93,8 @@ extension StoredSettings { + return nil + } + +- var store = ["Default": profile] ++ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) ++ store["Default"] = profile + var defaultProfile = "Default" + + if let name = UserDefaults.standard.string(forKey: "currentProfileName") { +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index cd6d2c6..73db786 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -459,6 +459,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + success = true + message = "Override '\(overrideCommand.name)' enacted successfully" + ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ success = true ++ message = "Profile '\(profileSwitchCommand.name)' activated" ++ + case .cancelTemporaryOverride: + commandType = .cancelOverride + try await self.serviceDelegate?.cancelRemoteOverride() +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index a92b509..29313b6 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -9,6 +9,7 @@ + import Foundation + + public enum Action: Codable { ++ case profileSwitch(ProfileSwitchAction) + case temporaryScheduleOverride(OverrideAction) + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) +@@ -28,6 +29,8 @@ public enum Action: Codable { + return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") + case .temporaryScheduleOverride: + return LocalizedString("Override", comment: "The remote action name for Override") ++ case .profileSwitch: ++ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") + } + } + +@@ -43,6 +46,8 @@ public enum Action: Codable { + return "" + case .temporaryScheduleOverride(let overrideAction): + return overrideAction.name ++ case .profileSwitch(let profileSwitchAction): ++ return profileSwitchAction.name + } + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +new file mode 100644 +index 0000000..ce03ded +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +@@ -0,0 +1,17 @@ ++// ++// ProfileSwitchAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct ProfileSwitchAction: Codable { ++ ++ public let name: String ++ public let fingerprint: String ++ ++ public init(name: String, fingerprint: String) { ++ self.name = name ++ self.fingerprint = fingerprint ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 95274e4..4457f80 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -45,6 +45,7 @@ class RemoteNotificationResponseManager { + case carbs = "carbs" + case override = "override" + case cancelOverride = "cancel_override" ++ case profileSwitch = "profile_switch" + } + + func sendResponseNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +new file mode 100644 +index 0000000..487c620 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -0,0 +1,50 @@ ++// ++// ProfileSwitchRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Switches to a saved profile. `expiration` is required: decoding fails without it. ++public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ public let profileName: String ++ public let fingerprint: String ++ public let remoteAddress: String ++ public let expirationDate: Date ++ public let sentAt: Date? ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ public var expiration: Date? { ++ return expirationDate ++ } ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expirationDate = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification[commandKey] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 0c5f8e3..3362bf7 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -108,6 +108,8 @@ extension Dictionary { + return try OverrideRemoteNotification(dictionary: self) + } else if OverrideCancelRemoteNotification.includedInNotification(self) { + return try OverrideCancelRemoteNotification(dictionary: self) ++ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { ++ return try ProfileSwitchRemoteNotification(dictionary: self) + } else { + throw RemoteNotificationError.unhandledNotification(self) + } +diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..2fcec94 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +@@ -0,0 +1,71 @@ ++// ++// ProfileSwitchRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "sent-at": "2023-02-25T20:46:35.778Z", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ] ++ ++ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) ++ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ ++ XCTAssertEqual(profileSwitch.profileName, "Sport") ++ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertTrue(profileSwitch.otpValidationRequired()) ++ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { ++ return XCTFail("Expected a profile switch action") ++ } ++ XCTAssertEqual(action.name, "Sport") ++ } ++ ++ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { ++ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ let profile = ProfileSet.Profile( ++ timezone: TimeZone(secondsFromGMT: 3600)!, ++ dia: .hours(6), ++ sensitivity: items([(0, 45)]), ++ carbratio: items([(0, 10)]), ++ basal: items([(0, 0.85), (21600, 1.1)]), ++ targetLow: items([(0, 100)]), ++ targetHigh: items([(0, 110)]), ++ units: "mg/dL") ++ ++ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") ++ } ++} ++ ++private extension ISO8601DateFormatter { ++ static let fractional: ISO8601DateFormatter = { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ }() ++} +diff --git a/remote_profile.loopcustomization b/remote_profile.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/remote_profile/nextdev_remote_profile_remote_carb_edit.patch b/remote_profile/nextdev_remote_profile_remote_carb_edit.patch new file mode 100644 index 0000000..bb9d0f5 --- /dev/null +++ b/remote_profile/nextdev_remote_profile_remote_carb_edit.patch @@ -0,0 +1,661 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index 6b70853e..5591c9a5 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -1411,6 +1411,7 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { + NotificationCenter.default.post(name: .LoopDataUpdated, + object: nil, + userInfo: [LoopDataManager.LoopUpdateContextKey: LoopUpdateContext.preferences.rawValue]) ++ settingsManager.storeSettings() + } + + func saveCompletion(therapySettings: TherapySettings) { +diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift +index 6b263045..fe3c3e7f 100644 +--- a/Loop/Loop/Managers/LoopAppManager.swift ++++ b/Loop/Loop/Managers/LoopAppManager.swift +@@ -401,6 +401,7 @@ class LoopAppManager: NSObject { + + dosingDecisionStore.delegate = deviceDataManager + remoteDataServicesManager.delegate = deviceDataManager ++ servicesManager.therapySettingsViewModelDelegate = deviceDataManager + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceDataManager.deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 18975ce1..1cf442dd 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -326,6 +326,38 @@ extension NotificationManager { + UNUserNotificationCenter.current().add(request) + } + ++ static func sendRemoteProfileNotification(name: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfile", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteProfileFailureNotification(name: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ private static func remoteProfileNotificationBody(name: String) -> String { ++ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ } ++ + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { + return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + } +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 56cb3642..ffafea70 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -30,6 +30,7 @@ class ServicesManager { + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? + weak var supportManager: SupportManager? ++ weak var therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? + + private var services = [Service]() + +@@ -467,3 +468,58 @@ extension Service { + ] + } + } ++ ++// MARK: - RemoteProfileActionDelegate ++ ++extension ServicesManager: RemoteProfileActionDelegate { ++ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { ++ do { ++ guard let therapySettingsViewModelDelegate else { ++ throw RemoteProfileActionError.unavailable ++ } ++ let viewModel = ProfileViewModel(therapySettings: settingsManager.therapySettings, delegate: therapySettingsViewModelDelegate) ++ guard therapySettingsViewModelDelegate.pumpSupportedIncrements() != nil else { ++ throw RemoteProfileActionError.noPump ++ } ++ guard let profile = try viewModel.savedProfile(named: name) else { ++ throw RemoteProfileActionError.profileNotFound(name) ++ } ++ try profile.verify(with: verify) ++ if case .failure(let error) = viewModel.validateProfile(profile) { ++ throw error ++ } ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ viewModel.triggerProfileSync() ++ NotificationManager.sendRemoteProfileNotification(name: name) ++ } catch { ++ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteProfileActionError: LocalizedError { ++ case unavailable ++ case noPump ++ case profileNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .unavailable: ++ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: profile view model delegate missing.") ++ case .noPump: ++ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") ++ case .profileNotFound(let name): ++ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) ++ } ++ } ++ } ++} +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 6d3f9db2..5f1f37f3 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -8,6 +8,12 @@ + + import Foundation + ++/// Switches to a saved therapy profile on a remote command. ++public protocol RemoteProfileActionDelegate: AnyObject { ++ /// `verify` receives the schedules about to be activated and throws to reject them. ++ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws ++} ++ + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws + func cancelRemoteOverride() async throws +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +index cd8d48b8..0a287cac 100644 +--- a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +@@ -175,6 +175,7 @@ extension ProfileViewModel { + try jsonData.write(to: fileURL) + + self.loadProfiles() ++ triggerProfileSync() + } catch { + print("An error occurred while renaming profile: \(error)") + } +@@ -271,6 +272,8 @@ extension ProfileViewModel { + removeProfile(profileReference: profileReference) + if getCurrentProfileName() == profile.name { + clearCurrentProfile() ++ } else { ++ triggerProfileSync() + } + } + +@@ -296,6 +299,14 @@ extension ProfileViewModel { + return getProfile + } + ++ /// The saved profile named `name`, or nil when there is none. ++ public func savedProfile(named name: String) throws -> Profile? { ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ return nil ++ } ++ return try getProfile(from: profileReference) ++ } ++ + public func validateProfile(_ profile: Profile) -> Result { + guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { + return .failure(.fileLoadError) +@@ -407,3 +418,10 @@ extension ProfileViewModel { + return isOutOfSync + } + } ++ ++extension Profile { ++ /// Passes the schedules this profile activates to `check`. ++ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { ++ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) ++ } ++} +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index ea85f5a..eb8f03d 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -23,6 +23,10 @@ + A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; +@@ -163,6 +167,10 @@ + A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; + A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; +@@ -279,6 +287,7 @@ + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, + A934B6172358F49300949C8B /* StoredSettings.swift */, ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, + A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, + A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, +@@ -338,6 +347,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -380,6 +390,7 @@ + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, + ); + path = Notifications; + sourceTree = ""; +@@ -428,6 +439,7 @@ + A9AA6E5729EB06AA008FFA78 /* Actions */ = { + isa = PBXGroup; + children = ( ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, +@@ -765,6 +777,9 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, + A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, + C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, + ); +@@ -781,6 +796,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, + A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +new file mode 100644 +index 0000000..588ded1 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,104 @@ ++// ++// SavedProfile.swift ++// NightscoutServiceKit ++// ++ ++import CryptoKit ++import Foundation ++import LoopAlgorithm ++import LoopKit ++import NightscoutKit ++ ++/// A profile saved with the Profiles settings screen, read from the file it was saved to. ++struct SavedProfile: Decodable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ ++ private static var directory: URL { ++ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") ++ } ++ ++ static func loadAll() -> [SavedProfile] { ++ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] ++ return files ++ .filter { $0.pathExtension == "json" } ++ .compactMap { try? Data(contentsOf: $0) } ++ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } ++ } ++ ++ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. ++ static func nightscoutStore(unit: LoopUnit) -> [String: ProfileSet.Profile] { ++ var store: [String: ProfileSet.Profile] = [:] ++ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. ++ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { ++ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) ++ } ++ return store ++ } ++ ++ /// True when this profile uploads with this fingerprint, in either glucose unit. ++ func matches(fingerprint: String) -> Bool { ++ [LoopUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { ++ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint ++ } ++ } ++ ++ func nightscoutProfile(unit: LoopUnit) -> ProfileSet.Profile? { ++ guard let correctionRange = correctionRange.schedule(for: unit), ++ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) ++ else { ++ return nil ++ } ++ return ProfileSet.Profile( ++ timezone: basalRateSchedule.timeZone, ++ dia: .hours(6), ++ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, ++ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, ++ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) ++ } ++} ++ ++extension ProfileSet.Profile { ++ /// Followers compute the same value from the uploaded profile and send it with a switch command, ++ /// so Loop only activates the values the follower previewed. ++ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` ++ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. ++ var remoteFingerprint: String { ++ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { ++ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") ++ } ++ let canonical = [ ++ "basal=" + items(basal), ++ "carbratio=" + items(carbratio), ++ "sens=" + items(sensitivity), ++ "target_low=" + items(targetLow), ++ "target_high=" + items(targetHigh), ++ ].joined(separator: ";") ++ let digest = SHA256.hash(data: Data(canonical.utf8)) ++ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index e7bb1ad..1b43f76 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -180,7 +180,7 @@ extension StoredDosingDecision { + enacted: automaticDoseDecision?.loopStatusEnacted, + failureReason: automaticDoseDecision?.loopStatusFailureReason, + failureCode: automaticDoseDecision?.errors.first?.id, +- testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported], ++ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]], + algorithmExperiments: (automaticDoseDecision?.algorithmExperiments ?? algorithmExperiments)?.map { $0.jsonObject }), + overrideStatus: overrideStatus) + } +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +index 790330a..ae6fb9a 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +@@ -93,7 +93,8 @@ extension StoredSettings { + return nil + } + +- var store = ["Default": profile] ++ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) ++ store["Default"] = profile + var defaultProfile = "Default" + + if let name = UserDefaults.standard.string(forKey: "currentProfileName") { +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index fe3c774..3a513fb 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -460,6 +460,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + success = true + message = "Override '\(overrideCommand.name)' enacted successfully" + ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ success = true ++ message = "Profile '\(profileSwitchCommand.name)' activated" ++ + case .cancelTemporaryOverride: + commandType = .cancelOverride + try await self.serviceDelegate?.cancelRemoteOverride() +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index 91ad56c..29d3563 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -9,6 +9,7 @@ + import Foundation + + public enum Action: Codable { ++ case profileSwitch(ProfileSwitchAction) + case temporaryScheduleOverride(OverrideAction) + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) +@@ -34,6 +35,8 @@ public enum Action: Codable { + return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") + case .temporaryScheduleOverride: + return LocalizedString("Override", comment: "The remote action name for Override") ++ case .profileSwitch: ++ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") + } + } + +@@ -54,6 +57,8 @@ public enum Action: Codable { + return "" + case .temporaryScheduleOverride(let overrideAction): + return overrideAction.name ++ case .profileSwitch(let profileSwitchAction): ++ return profileSwitchAction.name + } + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +new file mode 100644 +index 0000000..ce03ded +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +@@ -0,0 +1,17 @@ ++// ++// ProfileSwitchAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct ProfileSwitchAction: Codable { ++ ++ public let name: String ++ public let fingerprint: String ++ ++ public init(name: String, fingerprint: String) { ++ self.name = name ++ self.fingerprint = fingerprint ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 6092bc9..aabb47f 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -49,6 +49,7 @@ class RemoteNotificationResponseManager { + case carbsEdit = "carbs_edit" + case override = "override" + case cancelOverride = "cancel_override" ++ case profileSwitch = "profile_switch" + } + + func sendResponseNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +new file mode 100644 +index 0000000..487c620 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -0,0 +1,50 @@ ++// ++// ProfileSwitchRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Switches to a saved profile. `expiration` is required: decoding fails without it. ++public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ public let profileName: String ++ public let fingerprint: String ++ public let remoteAddress: String ++ public let expirationDate: Date ++ public let sentAt: Date? ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ public var expiration: Date? { ++ return expirationDate ++ } ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expirationDate = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification[commandKey] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 6f0524d..87f1e92 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -122,6 +122,8 @@ extension Dictionary { + return try OverrideRemoteNotification(dictionary: self) + } else if OverrideCancelRemoteNotification.includedInNotification(self) { + return try OverrideCancelRemoteNotification(dictionary: self) ++ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { ++ return try ProfileSwitchRemoteNotification(dictionary: self) + } else { + throw RemoteNotificationError.unhandledNotification(self) + } +diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..2fcec94 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +@@ -0,0 +1,71 @@ ++// ++// ProfileSwitchRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "sent-at": "2023-02-25T20:46:35.778Z", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ] ++ ++ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) ++ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ ++ XCTAssertEqual(profileSwitch.profileName, "Sport") ++ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertTrue(profileSwitch.otpValidationRequired()) ++ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { ++ return XCTFail("Expected a profile switch action") ++ } ++ XCTAssertEqual(action.name, "Sport") ++ } ++ ++ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { ++ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ let profile = ProfileSet.Profile( ++ timezone: TimeZone(secondsFromGMT: 3600)!, ++ dia: .hours(6), ++ sensitivity: items([(0, 45)]), ++ carbratio: items([(0, 10)]), ++ basal: items([(0, 0.85), (21600, 1.1)]), ++ targetLow: items([(0, 100)]), ++ targetHigh: items([(0, 110)]), ++ units: "mg/dL") ++ ++ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") ++ } ++} ++ ++private extension ISO8601DateFormatter { ++ static let fractional: ISO8601DateFormatter = { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ }() ++} +diff --git a/remote_profile.loopcustomization b/remote_profile.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/remote_profile/nextdev_remote_profile_remote_carb_edit_remote_window.patch b/remote_profile/nextdev_remote_profile_remote_carb_edit_remote_window.patch new file mode 100644 index 0000000..5b12373 --- /dev/null +++ b/remote_profile/nextdev_remote_profile_remote_carb_edit_remote_window.patch @@ -0,0 +1,661 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index 6b70853e..5591c9a5 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -1411,6 +1411,7 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { + NotificationCenter.default.post(name: .LoopDataUpdated, + object: nil, + userInfo: [LoopDataManager.LoopUpdateContextKey: LoopUpdateContext.preferences.rawValue]) ++ settingsManager.storeSettings() + } + + func saveCompletion(therapySettings: TherapySettings) { +diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift +index dec6dff3..0b8248c2 100644 +--- a/Loop/Loop/Managers/LoopAppManager.swift ++++ b/Loop/Loop/Managers/LoopAppManager.swift +@@ -401,6 +401,7 @@ class LoopAppManager: NSObject { + + dosingDecisionStore.delegate = deviceDataManager + remoteDataServicesManager.delegate = deviceDataManager ++ servicesManager.therapySettingsViewModelDelegate = deviceDataManager + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceDataManager.deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 18975ce1..1cf442dd 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -326,6 +326,38 @@ extension NotificationManager { + UNUserNotificationCenter.current().add(request) + } + ++ static func sendRemoteProfileNotification(name: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfile", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteProfileFailureNotification(name: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ private static func remoteProfileNotificationBody(name: String) -> String { ++ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ } ++ + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { + return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + } +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 3aaf5212..65a47001 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -30,6 +30,7 @@ class ServicesManager { + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? + weak var supportManager: SupportManager? ++ weak var therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? + + private var services = [Service]() + +@@ -476,3 +477,58 @@ extension Service { + ] + } + } ++ ++// MARK: - RemoteProfileActionDelegate ++ ++extension ServicesManager: RemoteProfileActionDelegate { ++ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { ++ do { ++ guard let therapySettingsViewModelDelegate else { ++ throw RemoteProfileActionError.unavailable ++ } ++ let viewModel = ProfileViewModel(therapySettings: settingsManager.therapySettings, delegate: therapySettingsViewModelDelegate) ++ guard therapySettingsViewModelDelegate.pumpSupportedIncrements() != nil else { ++ throw RemoteProfileActionError.noPump ++ } ++ guard let profile = try viewModel.savedProfile(named: name) else { ++ throw RemoteProfileActionError.profileNotFound(name) ++ } ++ try profile.verify(with: verify) ++ if case .failure(let error) = viewModel.validateProfile(profile) { ++ throw error ++ } ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ viewModel.triggerProfileSync() ++ NotificationManager.sendRemoteProfileNotification(name: name) ++ } catch { ++ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteProfileActionError: LocalizedError { ++ case unavailable ++ case noPump ++ case profileNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .unavailable: ++ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: profile view model delegate missing.") ++ case .noPump: ++ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") ++ case .profileNotFound(let name): ++ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) ++ } ++ } ++ } ++} +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 7da27b70..39c0eee9 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -8,6 +8,12 @@ + + import Foundation + ++/// Switches to a saved therapy profile on a remote command. ++public protocol RemoteProfileActionDelegate: AnyObject { ++ /// `verify` receives the schedules about to be activated and throws to reject them. ++ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws ++} ++ + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws + func cancelRemoteOverride() async throws +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +index cd8d48b8..0a287cac 100644 +--- a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +@@ -175,6 +175,7 @@ extension ProfileViewModel { + try jsonData.write(to: fileURL) + + self.loadProfiles() ++ triggerProfileSync() + } catch { + print("An error occurred while renaming profile: \(error)") + } +@@ -271,6 +272,8 @@ extension ProfileViewModel { + removeProfile(profileReference: profileReference) + if getCurrentProfileName() == profile.name { + clearCurrentProfile() ++ } else { ++ triggerProfileSync() + } + } + +@@ -296,6 +299,14 @@ extension ProfileViewModel { + return getProfile + } + ++ /// The saved profile named `name`, or nil when there is none. ++ public func savedProfile(named name: String) throws -> Profile? { ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ return nil ++ } ++ return try getProfile(from: profileReference) ++ } ++ + public func validateProfile(_ profile: Profile) -> Result { + guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { + return .failure(.fileLoadError) +@@ -407,3 +418,10 @@ extension ProfileViewModel { + return isOutOfSync + } + } ++ ++extension Profile { ++ /// Passes the schedules this profile activates to `check`. ++ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { ++ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) ++ } ++} +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index ea85f5a..eb8f03d 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -23,6 +23,10 @@ + A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; +@@ -163,6 +167,10 @@ + A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; + A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; +@@ -279,6 +287,7 @@ + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, + A934B6172358F49300949C8B /* StoredSettings.swift */, ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, + A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, + A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, +@@ -338,6 +347,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -380,6 +390,7 @@ + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, + ); + path = Notifications; + sourceTree = ""; +@@ -428,6 +439,7 @@ + A9AA6E5729EB06AA008FFA78 /* Actions */ = { + isa = PBXGroup; + children = ( ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, +@@ -765,6 +777,9 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, + A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, + C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, + ); +@@ -781,6 +796,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, + A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +new file mode 100644 +index 0000000..588ded1 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,104 @@ ++// ++// SavedProfile.swift ++// NightscoutServiceKit ++// ++ ++import CryptoKit ++import Foundation ++import LoopAlgorithm ++import LoopKit ++import NightscoutKit ++ ++/// A profile saved with the Profiles settings screen, read from the file it was saved to. ++struct SavedProfile: Decodable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ ++ private static var directory: URL { ++ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") ++ } ++ ++ static func loadAll() -> [SavedProfile] { ++ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] ++ return files ++ .filter { $0.pathExtension == "json" } ++ .compactMap { try? Data(contentsOf: $0) } ++ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } ++ } ++ ++ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. ++ static func nightscoutStore(unit: LoopUnit) -> [String: ProfileSet.Profile] { ++ var store: [String: ProfileSet.Profile] = [:] ++ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. ++ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { ++ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) ++ } ++ return store ++ } ++ ++ /// True when this profile uploads with this fingerprint, in either glucose unit. ++ func matches(fingerprint: String) -> Bool { ++ [LoopUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { ++ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint ++ } ++ } ++ ++ func nightscoutProfile(unit: LoopUnit) -> ProfileSet.Profile? { ++ guard let correctionRange = correctionRange.schedule(for: unit), ++ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) ++ else { ++ return nil ++ } ++ return ProfileSet.Profile( ++ timezone: basalRateSchedule.timeZone, ++ dia: .hours(6), ++ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, ++ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, ++ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) ++ } ++} ++ ++extension ProfileSet.Profile { ++ /// Followers compute the same value from the uploaded profile and send it with a switch command, ++ /// so Loop only activates the values the follower previewed. ++ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` ++ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. ++ var remoteFingerprint: String { ++ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { ++ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") ++ } ++ let canonical = [ ++ "basal=" + items(basal), ++ "carbratio=" + items(carbratio), ++ "sens=" + items(sensitivity), ++ "target_low=" + items(targetLow), ++ "target_high=" + items(targetHigh), ++ ].joined(separator: ";") ++ let digest = SHA256.hash(data: Data(canonical.utf8)) ++ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index e7bb1ad..1b43f76 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -180,7 +180,7 @@ extension StoredDosingDecision { + enacted: automaticDoseDecision?.loopStatusEnacted, + failureReason: automaticDoseDecision?.loopStatusFailureReason, + failureCode: automaticDoseDecision?.errors.first?.id, +- testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported], ++ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]], + algorithmExperiments: (automaticDoseDecision?.algorithmExperiments ?? algorithmExperiments)?.map { $0.jsonObject }), + overrideStatus: overrideStatus) + } +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +index 790330a..ae6fb9a 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +@@ -93,7 +93,8 @@ extension StoredSettings { + return nil + } + +- var store = ["Default": profile] ++ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) ++ store["Default"] = profile + var defaultProfile = "Default" + + if let name = UserDefaults.standard.string(forKey: "currentProfileName") { +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index c3fc48b..64a668c 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -474,6 +474,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + success = true + message = "Override '\(overrideCommand.name)' enacted successfully" + ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ success = true ++ message = "Profile '\(profileSwitchCommand.name)' activated" ++ + case .cancelTemporaryOverride: + commandType = .cancelOverride + try await serviceDelegate.cancelRemoteOverride() +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index 91ad56c..29d3563 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -9,6 +9,7 @@ + import Foundation + + public enum Action: Codable { ++ case profileSwitch(ProfileSwitchAction) + case temporaryScheduleOverride(OverrideAction) + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) +@@ -34,6 +35,8 @@ public enum Action: Codable { + return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") + case .temporaryScheduleOverride: + return LocalizedString("Override", comment: "The remote action name for Override") ++ case .profileSwitch: ++ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") + } + } + +@@ -54,6 +57,8 @@ public enum Action: Codable { + return "" + case .temporaryScheduleOverride(let overrideAction): + return overrideAction.name ++ case .profileSwitch(let profileSwitchAction): ++ return profileSwitchAction.name + } + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +new file mode 100644 +index 0000000..ce03ded +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +@@ -0,0 +1,17 @@ ++// ++// ProfileSwitchAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct ProfileSwitchAction: Codable { ++ ++ public let name: String ++ public let fingerprint: String ++ ++ public init(name: String, fingerprint: String) { ++ self.name = name ++ self.fingerprint = fingerprint ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 6092bc9..aabb47f 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -49,6 +49,7 @@ class RemoteNotificationResponseManager { + case carbsEdit = "carbs_edit" + case override = "override" + case cancelOverride = "cancel_override" ++ case profileSwitch = "profile_switch" + } + + func sendResponseNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +new file mode 100644 +index 0000000..ebcd6ef +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -0,0 +1,50 @@ ++// ++// ProfileSwitchRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Switches to a saved profile. `expiration` is required: decoding fails without it. ++public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ public let profileName: String ++ public let fingerprint: String ++ public let remoteAddress: String ++ public let expirationDate: Date ++ public let sentAt: Date ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ public var expiration: Date? { ++ return expirationDate ++ } ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expirationDate = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification[commandKey] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index be43210..1ea8b96 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -118,6 +118,8 @@ extension Dictionary { + return try OverrideRemoteNotification(dictionary: self) + } else if OverrideCancelRemoteNotification.includedInNotification(self) { + return try OverrideCancelRemoteNotification(dictionary: self) ++ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { ++ return try ProfileSwitchRemoteNotification(dictionary: self) + } else { + throw RemoteNotificationError.unhandledNotification(self) + } +diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..2fcec94 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +@@ -0,0 +1,71 @@ ++// ++// ProfileSwitchRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "sent-at": "2023-02-25T20:46:35.778Z", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ] ++ ++ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) ++ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ ++ XCTAssertEqual(profileSwitch.profileName, "Sport") ++ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertTrue(profileSwitch.otpValidationRequired()) ++ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { ++ return XCTFail("Expected a profile switch action") ++ } ++ XCTAssertEqual(action.name, "Sport") ++ } ++ ++ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { ++ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ let profile = ProfileSet.Profile( ++ timezone: TimeZone(secondsFromGMT: 3600)!, ++ dia: .hours(6), ++ sensitivity: items([(0, 45)]), ++ carbratio: items([(0, 10)]), ++ basal: items([(0, 0.85), (21600, 1.1)]), ++ targetLow: items([(0, 100)]), ++ targetHigh: items([(0, 110)]), ++ units: "mg/dL") ++ ++ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") ++ } ++} ++ ++private extension ISO8601DateFormatter { ++ static let fractional: ISO8601DateFormatter = { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ }() ++} +diff --git a/remote_profile.loopcustomization b/remote_profile.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/remote_profile/nextdev_remote_profile_remote_window.patch b/remote_profile/nextdev_remote_profile_remote_window.patch new file mode 100644 index 0000000..424c434 --- /dev/null +++ b/remote_profile/nextdev_remote_profile_remote_window.patch @@ -0,0 +1,660 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index 6b70853e..5591c9a5 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -1411,6 +1411,7 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { + NotificationCenter.default.post(name: .LoopDataUpdated, + object: nil, + userInfo: [LoopDataManager.LoopUpdateContextKey: LoopUpdateContext.preferences.rawValue]) ++ settingsManager.storeSettings() + } + + func saveCompletion(therapySettings: TherapySettings) { +diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift +index dec6dff3..0b8248c2 100644 +--- a/Loop/Loop/Managers/LoopAppManager.swift ++++ b/Loop/Loop/Managers/LoopAppManager.swift +@@ -401,6 +401,7 @@ class LoopAppManager: NSObject { + + dosingDecisionStore.delegate = deviceDataManager + remoteDataServicesManager.delegate = deviceDataManager ++ servicesManager.therapySettingsViewModelDelegate = deviceDataManager + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceDataManager.deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 42eff19f..c4d0f32e 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -298,6 +298,38 @@ extension NotificationManager { + UNUserNotificationCenter.current().add(request) + } + ++ static func sendRemoteProfileNotification(name: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfile", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteProfileFailureNotification(name: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ private static func remoteProfileNotificationBody(name: String) -> String { ++ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ } ++ + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { + return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + } +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index e81a230b..8e7c86ef 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -30,6 +30,7 @@ class ServicesManager { + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? + weak var supportManager: SupportManager? ++ weak var therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? + + private var services = [Service]() + +@@ -432,3 +433,58 @@ extension Service { + ] + } + } ++ ++// MARK: - RemoteProfileActionDelegate ++ ++extension ServicesManager: RemoteProfileActionDelegate { ++ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { ++ do { ++ guard let therapySettingsViewModelDelegate else { ++ throw RemoteProfileActionError.unavailable ++ } ++ let viewModel = ProfileViewModel(therapySettings: settingsManager.therapySettings, delegate: therapySettingsViewModelDelegate) ++ guard therapySettingsViewModelDelegate.pumpSupportedIncrements() != nil else { ++ throw RemoteProfileActionError.noPump ++ } ++ guard let profile = try viewModel.savedProfile(named: name) else { ++ throw RemoteProfileActionError.profileNotFound(name) ++ } ++ try profile.verify(with: verify) ++ if case .failure(let error) = viewModel.validateProfile(profile) { ++ throw error ++ } ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ viewModel.triggerProfileSync() ++ NotificationManager.sendRemoteProfileNotification(name: name) ++ } catch { ++ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteProfileActionError: LocalizedError { ++ case unavailable ++ case noPump ++ case profileNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .unavailable: ++ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: profile view model delegate missing.") ++ case .noPump: ++ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") ++ case .profileNotFound(let name): ++ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) ++ } ++ } ++ } ++} +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 90d64a0f..b82f71ed 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -8,6 +8,12 @@ + + import Foundation + ++/// Switches to a saved therapy profile on a remote command. ++public protocol RemoteProfileActionDelegate: AnyObject { ++ /// `verify` receives the schedules about to be activated and throws to reject them. ++ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws ++} ++ + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws + func cancelRemoteOverride() async throws +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +index cd8d48b8..0a287cac 100644 +--- a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +@@ -175,6 +175,7 @@ extension ProfileViewModel { + try jsonData.write(to: fileURL) + + self.loadProfiles() ++ triggerProfileSync() + } catch { + print("An error occurred while renaming profile: \(error)") + } +@@ -271,6 +272,8 @@ extension ProfileViewModel { + removeProfile(profileReference: profileReference) + if getCurrentProfileName() == profile.name { + clearCurrentProfile() ++ } else { ++ triggerProfileSync() + } + } + +@@ -296,6 +299,14 @@ extension ProfileViewModel { + return getProfile + } + ++ /// The saved profile named `name`, or nil when there is none. ++ public func savedProfile(named name: String) throws -> Profile? { ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ return nil ++ } ++ return try getProfile(from: profileReference) ++ } ++ + public func validateProfile(_ profile: Profile) -> Result { + guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { + return .failure(.fileLoadError) +@@ -407,3 +418,10 @@ extension ProfileViewModel { + return isOutOfSync + } + } ++ ++extension Profile { ++ /// Passes the schedules this profile activates to `check`. ++ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { ++ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) ++ } ++} +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index 00096e2..cf6883e 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -23,6 +23,10 @@ + A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; +@@ -156,6 +160,10 @@ + A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; + A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; +@@ -265,6 +273,7 @@ + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, + A934B6172358F49300949C8B /* StoredSettings.swift */, ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, + A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, + A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, +@@ -321,6 +330,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -361,6 +371,7 @@ + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, + ); + path = Notifications; + sourceTree = ""; +@@ -409,6 +420,7 @@ + A9AA6E5729EB06AA008FFA78 /* Actions */ = { + isa = PBXGroup; + children = ( ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, +@@ -740,6 +752,9 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, + A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, + C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, + ); +@@ -753,6 +768,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, + A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +new file mode 100644 +index 0000000..588ded1 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,104 @@ ++// ++// SavedProfile.swift ++// NightscoutServiceKit ++// ++ ++import CryptoKit ++import Foundation ++import LoopAlgorithm ++import LoopKit ++import NightscoutKit ++ ++/// A profile saved with the Profiles settings screen, read from the file it was saved to. ++struct SavedProfile: Decodable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ ++ private static var directory: URL { ++ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") ++ } ++ ++ static func loadAll() -> [SavedProfile] { ++ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] ++ return files ++ .filter { $0.pathExtension == "json" } ++ .compactMap { try? Data(contentsOf: $0) } ++ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } ++ } ++ ++ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. ++ static func nightscoutStore(unit: LoopUnit) -> [String: ProfileSet.Profile] { ++ var store: [String: ProfileSet.Profile] = [:] ++ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. ++ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { ++ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) ++ } ++ return store ++ } ++ ++ /// True when this profile uploads with this fingerprint, in either glucose unit. ++ func matches(fingerprint: String) -> Bool { ++ [LoopUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { ++ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint ++ } ++ } ++ ++ func nightscoutProfile(unit: LoopUnit) -> ProfileSet.Profile? { ++ guard let correctionRange = correctionRange.schedule(for: unit), ++ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) ++ else { ++ return nil ++ } ++ return ProfileSet.Profile( ++ timezone: basalRateSchedule.timeZone, ++ dia: .hours(6), ++ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, ++ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, ++ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) ++ } ++} ++ ++extension ProfileSet.Profile { ++ /// Followers compute the same value from the uploaded profile and send it with a switch command, ++ /// so Loop only activates the values the follower previewed. ++ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` ++ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. ++ var remoteFingerprint: String { ++ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { ++ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") ++ } ++ let canonical = [ ++ "basal=" + items(basal), ++ "carbratio=" + items(carbratio), ++ "sens=" + items(sensitivity), ++ "target_low=" + items(targetLow), ++ "target_high=" + items(targetHigh), ++ ].joined(separator: ";") ++ let digest = SHA256.hash(data: Data(canonical.utf8)) ++ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index 2f12b47..2dec600 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -180,6 +180,7 @@ extension StoredDosingDecision { + enacted: automaticDoseDecision?.loopStatusEnacted, + failureReason: automaticDoseDecision?.loopStatusFailureReason, + failureCode: automaticDoseDecision?.errors.first?.id, ++ testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]], + algorithmExperiments: (automaticDoseDecision?.algorithmExperiments ?? algorithmExperiments)?.map { $0.jsonObject }), + overrideStatus: overrideStatus) + } +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +index 790330a..ae6fb9a 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +@@ -93,7 +93,8 @@ extension StoredSettings { + return nil + } + +- var store = ["Default": profile] ++ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) ++ store["Default"] = profile + var defaultProfile = "Default" + + if let name = UserDefaults.standard.string(forKey: "currentProfileName") { +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index c625152..ec997ef 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -473,6 +473,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + success = true + message = "Override '\(overrideCommand.name)' enacted successfully" + ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ success = true ++ message = "Profile '\(profileSwitchCommand.name)' activated" ++ + case .cancelTemporaryOverride: + commandType = .cancelOverride + try await serviceDelegate.cancelRemoteOverride() +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index a92b509..29313b6 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -9,6 +9,7 @@ + import Foundation + + public enum Action: Codable { ++ case profileSwitch(ProfileSwitchAction) + case temporaryScheduleOverride(OverrideAction) + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) +@@ -28,6 +29,8 @@ public enum Action: Codable { + return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") + case .temporaryScheduleOverride: + return LocalizedString("Override", comment: "The remote action name for Override") ++ case .profileSwitch: ++ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") + } + } + +@@ -43,6 +46,8 @@ public enum Action: Codable { + return "" + case .temporaryScheduleOverride(let overrideAction): + return overrideAction.name ++ case .profileSwitch(let profileSwitchAction): ++ return profileSwitchAction.name + } + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +new file mode 100644 +index 0000000..ce03ded +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +@@ -0,0 +1,17 @@ ++// ++// ProfileSwitchAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct ProfileSwitchAction: Codable { ++ ++ public let name: String ++ public let fingerprint: String ++ ++ public init(name: String, fingerprint: String) { ++ self.name = name ++ self.fingerprint = fingerprint ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 95274e4..4457f80 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -45,6 +45,7 @@ class RemoteNotificationResponseManager { + case carbs = "carbs" + case override = "override" + case cancelOverride = "cancel_override" ++ case profileSwitch = "profile_switch" + } + + func sendResponseNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +new file mode 100644 +index 0000000..ebcd6ef +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -0,0 +1,50 @@ ++// ++// ProfileSwitchRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Switches to a saved profile. `expiration` is required: decoding fails without it. ++public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ public let profileName: String ++ public let fingerprint: String ++ public let remoteAddress: String ++ public let expirationDate: Date ++ public let sentAt: Date ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ public var expiration: Date? { ++ return expirationDate ++ } ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expirationDate = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification[commandKey] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 24e63d7..a0f58a0 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -104,6 +104,8 @@ extension Dictionary { + return try OverrideRemoteNotification(dictionary: self) + } else if OverrideCancelRemoteNotification.includedInNotification(self) { + return try OverrideCancelRemoteNotification(dictionary: self) ++ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { ++ return try ProfileSwitchRemoteNotification(dictionary: self) + } else { + throw RemoteNotificationError.unhandledNotification(self) + } +diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..2fcec94 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +@@ -0,0 +1,71 @@ ++// ++// ProfileSwitchRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "sent-at": "2023-02-25T20:46:35.778Z", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ] ++ ++ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) ++ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ ++ XCTAssertEqual(profileSwitch.profileName, "Sport") ++ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertTrue(profileSwitch.otpValidationRequired()) ++ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { ++ return XCTFail("Expected a profile switch action") ++ } ++ XCTAssertEqual(action.name, "Sport") ++ } ++ ++ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { ++ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ let profile = ProfileSet.Profile( ++ timezone: TimeZone(secondsFromGMT: 3600)!, ++ dia: .hours(6), ++ sensitivity: items([(0, 45)]), ++ carbratio: items([(0, 10)]), ++ basal: items([(0, 0.85), (21600, 1.1)]), ++ targetLow: items([(0, 100)]), ++ targetHigh: items([(0, 110)]), ++ units: "mg/dL") ++ ++ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") ++ } ++} ++ ++private extension ISO8601DateFormatter { ++ static let fractional: ISO8601DateFormatter = { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ }() ++} +diff --git a/remote_profile.loopcustomization b/remote_profile.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/remote_window/dev_remote_window_remote_carb_edit_remote_profile.patch b/remote_window/dev_remote_window_remote_carb_edit_remote_profile.patch new file mode 100644 index 0000000..4688407 --- /dev/null +++ b/remote_window/dev_remote_window_remote_carb_edit_remote_profile.patch @@ -0,0 +1,1989 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index 3b5f1de2..c253e88e 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -838,13 +838,14 @@ extension DeviceDataManager { + + // MARK: - Client API + extension DeviceDataManager { +- func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void = { _ in }) { ++ func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ result: Swift.Result) -> Void = { _ in }) { + guard let pumpManager = pumpManager else { +- completion(LoopError.configurationError(.pumpManager)) ++ completion(.failure(LoopError.configurationError(.pumpManager))) + return + } + +- self.loopManager.addRequestedBolus(DoseEntry(type: .bolus, startDate: Date(), value: units, unit: .units, isMutable: true)) { ++ let doseEntry = DoseEntry(type: .bolus, startDate: Date(), value: units, unit: .units, isMutable: true) ++ self.loopManager.addRequestedBolus(doseEntry) { + pumpManager.enactBolus(units: units, activationType: activationType) { (error) in + if let error = error { + self.log.error("%{public}@", String(describing: error)) +@@ -860,11 +861,23 @@ extension DeviceDataManager { + } + + self.loopManager.bolusRequestFailed(error) { +- completion(error) ++ completion(.failure(error)) + } + } else { + self.loopManager.bolusConfirmed() { +- completion(nil) ++ // The DoseEntry from the store is returned as it has the syncIdentifier set. ++ self.doseStore.getDoses(start: doseEntry.startDate, includeMutable: true) { result in ++ switch result { ++ case .success(let doses): ++ if let storedEntry = doses.filter({ $0.programmedUnits == doseEntry.programmedUnits }).first { ++ completion(.success(storedEntry)) ++ } else { ++ completion(.failure(EnactBolusError.storageFailure)) ++ } ++ case .failure: ++ completion(.failure(EnactBolusError.storageFailure)) ++ } ++ } + } + } + } +@@ -873,17 +886,17 @@ extension DeviceDataManager { + } + } + +- func enactBolus(units: Double, activationType: BolusActivationType) async throws { ++ func enactBolus(units: Double, activationType: BolusActivationType) async throws -> DoseEntry { + return try await withCheckedThrowingContinuation { continuation in +- enactBolus(units: units, activationType: activationType) { error in +- if let error = error { +- continuation.resume(throwing: error) +- return +- } +- continuation.resume() ++ enactBolus(units: units, activationType: activationType) { result in ++ continuation.resume(with: result) + } + } + } ++ ++ enum EnactBolusError: Error { ++ case storageFailure ++ } + + var pumpManagerStatus: PumpManagerStatus? { + return pumpManager?.status +@@ -1476,10 +1489,49 @@ extension Notification.Name { + + extension DeviceDataManager: ServicesManagerDosingDelegate { + +- func deliverBolus(amountInUnits: Double) async throws { +- try await enactBolus(units: amountInUnits, activationType: .manualNoRecommendation) ++ func deliverBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry { ++ let minTreatmentIntervalInMinutes = 10.0 ++ let boluses = try await doseStore.getBoluses(start: Date().addingTimeInterval(-.days(1))) ++ // Look back further for safety in case caregiver didn't know about other treatments before they send this (i.e. Upload delays) ++ let conflictStartDate = userCreatedDate.addingTimeInterval(-.minutes(minTreatmentIntervalInMinutes)) ++ let conflictingEntries = boluses.filter({$0.startDate >= conflictStartDate}) ++ let conflictingBolusAmount = conflictingEntries.map({$0.programmedUnits}).reduce(0, {$0 + $1}) ++ let adjustedDoseAmount = amountInUnits - conflictingBolusAmount ++ guard adjustedDoseAmount <= amountInUnits else { // Added safety check for the bolus math ++ throw BolusActionError.invalidCalculation ++ } ++ guard adjustedDoseAmount > 0.0 else { ++ throw BolusActionError.bolusAmountAdjustedToZero(amountInUnits, conflictStartDate) ++ } ++ return try await enactBolus(units: adjustedDoseAmount, activationType: .manualNoRecommendation) + } + ++ enum BolusActionError: LocalizedError { ++ case bolusAmountAdjustedToZero( _ requestedAmountInUnits: Double, _ conflictStartDate: Date) ++ case invalidCalculation ++ ++ var errorDescription: String? { ++ switch self { ++ case .bolusAmountAdjustedToZero(let amountInUnits, let conflictStartDate): ++ let treatmentAmountFormatted = Self.numberFormatter.string(from: HKQuantity(unit: .internationalUnit(), doubleValue: amountInUnits), unit: .internationalUnit()) ?? "" ++ return String(format: NSLocalizedString("All boluses delivered after %1$@ were subtracted from the requested bolus amount for safety. Those amount(s) exceed the requested amount of %2$@ so the remote bolus was not delivered.", comment: "Bolus error description: no remote bolus delivered."), conflictStartDate.formatted(date: .omitted, time: .complete), treatmentAmountFormatted) ++ case .invalidCalculation: ++ return String(format: NSLocalizedString("An invalid bolus calculation occurred.", comment: "Bolus error description: invalid calculation.")) ++ } ++ } ++ ++ static var numberFormatter: NumberFormatter = { ++ let formatter = NumberFormatter() ++ formatter.numberStyle = .decimal ++ return formatter ++ }() ++ ++ static var dateFormatter: DateFormatter = { ++ let formatter = DateFormatter() ++ formatter.timeStyle = .medium ++ return formatter ++ }() ++ } + } + + // MARK: - Critical Event Log Export +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index 02359815..03ffe6c7 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -2501,7 +2501,7 @@ extension LoopDataManager: ServicesManagerDelegate { + + //Overrides + +- func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws { ++ func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws -> TemporaryScheduleOverride { + + guard let preset = settings.overridePresets.first(where: { $0.name == name }) else { + throw EnactOverrideError.unknownPreset(name) +@@ -2514,11 +2514,27 @@ extension LoopDataManager: ServicesManagerDelegate { + } + + await enactOverride(remoteOverride) ++ return remoteOverride + } + + +- func cancelCurrentOverride() async throws { ++ func cancelCurrentOverride() async throws -> TemporaryScheduleOverride { ++ guard let currentOverride = settings.scheduleOverride else { ++ throw OverrideCancelError.overrideNotActive ++ } + await enactOverride(nil) ++ return currentOverride ++ } ++ ++ enum OverrideCancelError: LocalizedError { ++ case overrideNotActive ++ ++ var errorDescription: String? { ++ switch self { ++ case .overrideNotActive: ++ return String(format: NSLocalizedString("No overrides active for cancellation", comment: "Cancel Override error description: override not active for cancellation.")) ++ } ++ } + } + + func enactOverride(_ override: TemporaryScheduleOverride?) async { +@@ -2539,15 +2555,28 @@ extension LoopDataManager: ServicesManagerDelegate { + + //Carb Entry + +- func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { ++ func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry { + let absorptionTime = absorptionTime ?? carbStore.defaultAbsorptionTimes.medium + let startDate = startDate ?? Date() + try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) +- ++ ++ let maxAllowedConflictingAmountInGrams = 0.0 // Not allowing any carbs ++ let minTreatmentIntervalInMinutes = 10.0 ++ let entries = try await carbStore.getCarbEntries(start: Date().addingTimeInterval(-.days(1)), end: Date().addingTimeInterval(.days(1))) ++ let conflictStartDate = userCreatedDate.addingTimeInterval(-.minutes(minTreatmentIntervalInMinutes)) // Look back a little further for added safety (i.e. Network delays) ++ let conflictingEntries = entries.filter({ entry in ++ let createdDate = entry.userCreatedDate ?? entry.startDate ++ return createdDate >= conflictStartDate ++ }) ++ let conflictingCarbAmount = conflictingEntries.map({$0.quantity.doubleValue(for: .gram())}).reduce(0, {$0 + $1}) ++ guard conflictingCarbAmount <= maxAllowedConflictingAmountInGrams else { ++ throw CarbActionError.conflictingTreatments(conflictingCarbAmount, minTreatmentIntervalInMinutes) ++ } ++ + let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) + let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate, foodType: foodType, absorptionTime: absorptionTime) + +- let _ = try await devliverCarbEntry(candidateCarbEntry) ++ return try await deliverCarbEntry(candidateCarbEntry) + } + + /// Remote edits and deletes may only target entries this app created within the last 24 hours. +@@ -2625,11 +2654,12 @@ extension LoopDataManager: ServicesManagerDelegate { + case invalidStartDate(Date) + case exceedsMaxCarbs + case invalidCarbs ++ case conflictingTreatments(_ conflictingAmountInGrams: Double, _ minutesRequiredBetweenTreatments: TimeInterval) + case entryNotFound + case entryNotEditable + + var errorDescription: String? { +- switch self { ++ switch self { + case .exceedsMaxCarbs: + return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Carb error description: carbs exceed maximum amount.") + case .invalidCarbs: +@@ -2644,6 +2674,10 @@ extension LoopDataManager: ServicesManagerDelegate { + case .invalidStartDate(let startDate): + let startDateFormatted = Self.dateFormatter.string(from: startDate) + return String(format: NSLocalizedString("Start time is out of range: %@", comment: "Carb error description: invalid start time is out of range."), startDateFormatted) ++ case .conflictingTreatments(let conflictingAmountInGrams, let minutesRequiredBetweenTreatments): ++ let treatmentAmountFormatted = Self.numberFormatter.string(from: HKQuantity(unit: .gram(), doubleValue: conflictingAmountInGrams), unit: .gram()) ?? "" ++ let minutesRequiredBetweenTreatmentsFormatted = Self.numberFormatter.string(from: minutesRequiredBetweenTreatments) ?? "" ++ return String(format: NSLocalizedString("Conflicting carb treatments (%1$@) have occurred. At least %2$@ minutes must pass between carb entries.", comment: "Carb error description: conflicting treatments occurred."), treatmentAmountFormatted, minutesRequiredBetweenTreatmentsFormatted) + } + } + +@@ -2661,7 +2695,7 @@ extension LoopDataManager: ServicesManagerDelegate { + } + + //Can't add this concurrency wrapper method to LoopKit due to the minimum iOS version +- func devliverCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry { ++ func deliverCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry { + return try await withCheckedThrowingContinuation { continuation in + carbStore.addCarbEntry(carbEntry) { result in + switch result { +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 5bf1ec36..161a42e3 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -233,13 +233,13 @@ class ServicesManager { + } + + public protocol ServicesManagerDosingDelegate: AnyObject { +- func deliverBolus(amountInUnits: Double) async throws ++ func deliverBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry + } + + public protocol ServicesManagerDelegate: AnyObject { +- func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws +- func cancelCurrentOverride() async throws +- func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws ++ func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws -> TemporaryScheduleOverride ++ func cancelCurrentOverride() async throws -> TemporaryScheduleOverride ++ func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry + func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry + func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry + } +@@ -276,7 +276,10 @@ extension ServicesManager: ServiceDelegate { + return semanticVersion + } + +- func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws { ++ func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride { ++ guard let servicesManagerDelegate else { ++ throw OverrideActionError.internalError ++ } + + var duration: TemporaryScheduleOverride.Duration? = nil + if let durationTime = durationTime { +@@ -296,14 +299,16 @@ extension ServicesManager: ServiceDelegate { + } + } + +- try await servicesManagerDelegate?.enactOverride(name: name, duration: duration, remoteAddress: remoteAddress) ++ let override = try await servicesManagerDelegate.enactOverride(name: name, duration: duration, remoteAddress: remoteAddress) + await remoteDataServicesManager.triggerUpload(for: .overrides) ++ return override + } + + enum OverrideActionError: LocalizedError { + + case durationExceedsMax(TimeInterval) + case negativeDuration ++ case internalError + + var errorDescription: String? { + switch self { +@@ -311,36 +316,60 @@ extension ServicesManager: ServiceDelegate { + return String(format: NSLocalizedString("Duration exceeds: %1$.1f hours", comment: "Override error description: duration exceed max (1: max duration in hours)."), maxDurationTime.hours) + case .negativeDuration: + return String(format: NSLocalizedString("Negative duration not allowed", comment: "Override error description: negative duration error.")) ++ case .internalError: ++ return String(format: NSLocalizedString("Internal error", comment: "Override error description: Internal error.")) + } + } + } + +- func cancelRemoteOverride() async throws { +- try await servicesManagerDelegate?.cancelCurrentOverride() ++ func cancelRemoteOverride() async throws -> TemporaryScheduleOverride { ++ guard let servicesManagerDelegate else { ++ throw CarbActionError.internalError ++ } ++ ++ let cancelledOverride = try await servicesManagerDelegate.cancelCurrentOverride() + await remoteDataServicesManager.triggerUpload(for: .overrides) ++ return cancelledOverride + } + +- func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { ++ func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry { + do { +- try await servicesManagerDelegate?.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) ++ guard let servicesManagerDelegate else { ++ throw CarbActionError.internalError ++ } ++ ++ let carbEntry = try await servicesManagerDelegate.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate, userCreatedDate: userCreatedDate) + await NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: amountInGrams) + await remoteDataServicesManager.triggerUpload(for: .carb) + analyticsServicesManager.didAddCarbs(source: "Remote") ++ return carbEntry + } catch { + await NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: amountInGrams) + throw error + } + } + +- func deleteRemoteCarbs(syncIdentifier: String) async throws { ++ enum CarbActionError: LocalizedError { ++ case internalError ++ ++ var errorDescription: String? { ++ switch self { ++ case .internalError: ++ return NSLocalizedString("Internal error", comment: "Carb error description: internal error.") ++ } ++ } ++ } ++ ++ func deleteRemoteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry { + do { + guard let servicesManagerDelegate else { +- throw RemoteCarbCommandError.internalError ++ throw CarbActionError.internalError + } + let deleted = try await servicesManagerDelegate.deleteCarbs(syncIdentifier: syncIdentifier) + let body = String(format: NSLocalizedString("Remote Carbs Delete: %d grams removed", comment: "The notification body for a successful remote carb delete. (1: Carb amount in grams)"), Int(deleted.quantity.doubleValue(for: .gram()))) + await NotificationManager.sendRemoteCarbChangeNotification(body: body) + await remoteDataServicesManager.triggerUpload(for: .carb) ++ return deleted + } catch { + let body = NSLocalizedString("Remote Carbs Delete", comment: "The notification body prefix for a failed remote carb delete") + await NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) +@@ -348,34 +377,27 @@ extension ServicesManager: ServiceDelegate { + } + } + +- func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws { ++ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry { + let body = String(format: NSLocalizedString("Remote Carbs Edit: %d grams", comment: "The carb amount message for a remote carb edit notification. (1: Carb amount in grams)"), Int(amountInGrams)) + do { + guard let servicesManagerDelegate else { +- throw RemoteCarbCommandError.internalError ++ throw CarbActionError.internalError + } +- _ = try await servicesManagerDelegate.replaceCarbs(syncIdentifier: syncIdentifier, amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) ++ let replacement = try await servicesManagerDelegate.replaceCarbs(syncIdentifier: syncIdentifier, amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) + await NotificationManager.sendRemoteCarbChangeNotification(body: body) + await remoteDataServicesManager.triggerUpload(for: .carb) ++ return replacement + } catch { + await NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) + throw error + } + } + +- enum RemoteCarbCommandError: LocalizedError { +- case internalError +- +- var errorDescription: String? { +- switch self { +- case .internalError: +- return NSLocalizedString("Internal error", comment: "Remote carb command error description: internal error.") +- } +- } +- } +- +- func deliverRemoteBolus(amountInUnits: Double) async throws { ++ func deliverRemoteBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry { + do { ++ guard let servicesManagerDosingDelegate else { ++ throw BolusActionError.internalError ++ } + + guard amountInUnits > 0 else { + throw BolusActionError.invalidBolus +@@ -389,10 +411,11 @@ extension ServicesManager: ServiceDelegate { + throw BolusActionError.exceedsMaxBolus + } + +- try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits) +- await NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) ++ let doseEntry = try await servicesManagerDosingDelegate.deliverBolus(amountInUnits: amountInUnits, userCreatedDate: userCreatedDate) ++ await NotificationManager.sendRemoteBolusNotification(amount: doseEntry.programmedUnits) + await remoteDataServicesManager.triggerUpload(for: .dose) + analyticsServicesManager.didBolus(source: "Remote") ++ return doseEntry + } catch { + await NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) + throw error +@@ -404,6 +427,7 @@ extension ServicesManager: ServiceDelegate { + case invalidBolus + case missingMaxBolus + case exceedsMaxBolus ++ case internalError + + var errorDescription: String? { + switch self { +@@ -413,6 +437,8 @@ extension ServicesManager: ServiceDelegate { + return NSLocalizedString("Missing maximum allowed bolus in settings", comment: "Bolus error description: missing maximum bolus in settings.") + case .exceedsMaxBolus: + return NSLocalizedString("Exceeds maximum allowed bolus in settings", comment: "Bolus error description: bolus exceeds maximum bolus in settings.") ++ case .internalError: ++ return NSLocalizedString("Internal error", comment: "Bolus error description: internal error.") + } + } + } +diff --git a/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +index 692a4ec2..41221127 100644 +--- a/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift ++++ b/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +@@ -34,8 +34,6 @@ protocol CarbStoreProtocol: AnyObject { + func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) + + func addCarbEntry(_ entry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) +- +- func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] + + func getCarbStatus(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult<[CarbStatus]>) -> Void) + +@@ -53,6 +51,8 @@ protocol CarbStoreProtocol: AnyObject { + func getTotalCarbs(since start: Date, completion: @escaping (_ result: CarbStoreResult) -> Void) + + func deleteCarbEntry(_ entry: StoredCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) ++ ++ func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] + } + + extension CarbStore: CarbStoreProtocol { } +diff --git a/Loop/Loop/View Models/BolusEntryViewModel.swift b/Loop/Loop/View Models/BolusEntryViewModel.swift +index 20735e7f..4f2691a4 100644 +--- a/Loop/Loop/View Models/BolusEntryViewModel.swift ++++ b/Loop/Loop/View Models/BolusEntryViewModel.swift +@@ -29,7 +29,7 @@ protocol BolusEntryViewModelDelegate: AnyObject { + + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) + +- func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void) ++ func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ result: Swift.Result) -> Void) + + func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (_ samples: Swift.Result<[StoredGlucoseSample], Error>) -> Void) + +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit/InsulinKit/DoseStore.swift b/LoopKit/LoopKit/InsulinKit/DoseStore.swift +index 712f9048..be7c17dd 100644 +--- a/LoopKit/LoopKit/InsulinKit/DoseStore.swift ++++ b/LoopKit/LoopKit/InsulinKit/DoseStore.swift +@@ -1274,8 +1274,18 @@ extension DoseStore { + public func getDoses(start: Date? = nil, end: Date? = nil) async throws -> [DoseEntry] { + return try await insulinDeliveryStore.getDoses(start: start, end: end) + } +- +- ++ ++ /// Retrieves doses overlapping supplied range ++ /// ++ /// - Parameters: ++ /// - start: The earliest date of dose entries to retrieve. ++ /// - end: The latest date of dose entries to retrieve, if provided. ++ /// - includeMutable: Whether to include mutable dose entries or not. ++ /// - completion: A closure called once the dose entries have been retrieved. ++ /// - result: An array of dose entries, in chronological order by startDate, or error. ++ public func getDoses(start: Date, end: Date? = nil, includeMutable: Bool, completion: @escaping (_ result: Swift.Result<[DoseEntry], Error>) -> Void) { ++ insulinDeliveryStore.getDoseEntries(start: start, end: end, includeMutable: includeMutable, completion: completion) ++ } + + /// Retrieves the maximum insulin on-board value from the two timeline values nearest to the specified date + /// +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 56742c96..858a5621 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -15,10 +15,10 @@ public protocol RemoteProfileActionDelegate: AnyObject { + } + + public protocol RemoteActionDelegate: AnyObject { +- func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws +- func cancelRemoteOverride() async throws +- func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws +- func deliverRemoteBolus(amountInUnits: Double) async throws +- func deleteRemoteCarbs(syncIdentifier: String) async throws +- func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws ++ func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride ++ func cancelRemoteOverride() async throws -> TemporaryScheduleOverride ++ func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry ++ func deliverRemoteBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry ++ func deleteRemoteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry ++ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry + } +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/NightscoutUploader.swift b/NightscoutService/NightscoutServiceKit/Extensions/NightscoutUploader.swift +index 4a85888..fa35651 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/NightscoutUploader.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/NightscoutUploader.swift +@@ -117,25 +117,27 @@ extension NightscoutClient { + + extension NightscoutClient { + +- func createDoses(_ data: [DoseEntry], usingObjectIdCache objectIdCache: ObjectIdCache, completion: @escaping (Result<[String], Error>) -> Void) { +- guard !data.isEmpty else { +- completion(.success([])) +- return +- } +- +- let source = "loop://\(UIDevice.current.name)" +- ++ func createDoses( ++ _ data: [DoseEntry], ++ sourceMessage: (DoseEntry) -> String, ++ usingObjectIdCache objectIdCache: ObjectIdCache, ++ completion: @escaping (Result<[String], Error>) -> Void ++ ) { + let treatments = data.compactMap { (dose) -> NightscoutTreatment? in + var objectId: String? = nil +- ++ + if let syncIdentifier = dose.syncIdentifier { + objectId = objectIdCache.findObjectIdBySyncIdentifier(syncIdentifier) + } +- +- return dose.treatment(enteredBy: source, withObjectId: objectId) ++ ++ return dose.treatment(enteredBy: sourceMessage(dose), withObjectId: objectId) + } +- +- ++ ++ guard !treatments.isEmpty else { ++ completion(.success([])) ++ return ++ } ++ + self.upload(treatments) { (result) in + switch result { + case .failure(let error): +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index 18ac515..a968a64 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -65,10 +65,12 @@ public final class NightscoutService: Service { + + private let log = OSLog(category: "NightscoutService") + ++ private let notificationExpirationInMinutes = 10.0 ++ + public init() { + self.isOnboarded = false + self.lockedObjectIdCache = Locked(ObjectIdCache()) +- self.otpManager = OTPManager(secretStore: KeychainManager()) ++ self.otpManager = OTPManager(secretStore: KeychainManager(), maxMinutesValid: notificationExpirationInMinutes) + self.commandSourceV1 = RemoteCommandSourceV1(otpManager: otpManager) + self.commandSourceV1.delegate = self + } +@@ -83,11 +85,11 @@ public final class NightscoutService: Service { + } else { + self.lockedObjectIdCache = Locked(ObjectIdCache()) + } +- +- self.otpManager = OTPManager(secretStore: KeychainManager()) ++ ++ self.otpManager = OTPManager(secretStore: KeychainManager(), maxMinutesValid: notificationExpirationInMinutes) + self.commandSourceV1 = RemoteCommandSourceV1(otpManager: otpManager) + self.commandSourceV1.delegate = self +- ++ + restoreCredentials() + } + +@@ -238,12 +240,35 @@ extension NightscoutService: RemoteDataService { + public var doseDataLimit: Int? { return 1000 } + + public func uploadDoseData(created: [DoseEntry], deleted: [DoseEntry], completion: @escaping (_ result: Result) -> Void) { ++ Task { ++ let notificationHistory = await notificationHistory() ++ uploadDoseData(created: created, deleted: deleted, notificationHistory: notificationHistory, completion: completion) ++ ++ // Upload pending stored notifications ++ await uploadPendingNotifications() ++ } ++ } ++ ++ func uploadPendingNotifications() async { ++ await commandSourceV1.uploadPendingNotifications() ++ } ++ ++ public func uploadDoseData(created: [DoseEntry], deleted: [DoseEntry], notificationHistory: [StoredRemoteNotification], completion: @escaping (_ result: Result) -> Void) { + guard hasConfiguration, let uploader = uploader else { + completion(.success(true)) + return + } + +- uploader.createDoses(created, usingObjectIdCache: self.objectIdCache) { (result) in ++ let deviceName = UIDevice.current.name ++ let sourceMessage: (DoseEntry) -> String = { (dose) -> String in ++ if notificationHistory.contains(where: { $0.containsDose(dose) }) { ++ return "Loop (via remote command)" ++ } else { ++ return "loop://\(deviceName)" ++ } ++ } ++ ++ uploader.createDoses(created, sourceMessage: sourceMessage, usingObjectIdCache: objectIdCache) { (result) in + switch (result) { + case .failure(let error): + completion(.failure(error)) +@@ -399,10 +424,34 @@ extension NightscoutService: RemoteDataService { + + + public func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async throws { ++ guard let serviceDelegate else { ++ return ++ } ++ ++ // Set the expiration to longer duration. Ideally this would be done in Nightscout instead. ++ var notification = notification ++ let dateFormatter = DateFormatter.iso8601DateDecoder ++ if let sentDateString = notification["sent-at"] as? String, let sentDate = dateFormatter.date(from: sentDateString) { ++ let expirationDate = sentDate.addingTimeInterval(60 * notificationExpirationInMinutes) ++ notification["expiration"] = dateFormatter.string(from: expirationDate) as AnyObject ++ } ++ + let commandSource = try commandSource(notification: notification) +- await commandSource.remoteNotificationWasReceived(notification) ++ await commandSource.remoteNotificationWasReceived(notification, serviceDelegate: serviceDelegate) + } +- ++ ++ public func notificationHistory() async -> [StoredRemoteNotification] { ++ return await commandSourceV1.notificationHistory() ++ } ++ ++ public func notificationPublisher() async -> AsyncStream<[StoredRemoteNotification]> { ++ return await commandSourceV1.notificationPublisher() ++ } ++ ++ public func deleteNotificationHistory() { ++ commandSourceV1.deleteNotificationHistory() ++ } ++ + private func commandSource(notification: [String: AnyObject]) throws -> RemoteCommandSource { + return commandSourceV1 + } +@@ -410,144 +459,29 @@ extension NightscoutService: RemoteDataService { + } + + extension NightscoutService: RemoteCommandSourceV1Delegate { +- +- func commandSourceV1(_: RemoteCommandSourceV1, handleAction action: Action, remoteNotification: RemoteNotification) async throws { +- +- let returnInfo = remoteNotification.getReturnNotificationInfo() +- if returnInfo == nil { +- os_log("No return notification info available, response will not be sent", log: .default, type: .info) +- } else { +- os_log("Return notification info available, will send response after command processing", log: .default, type: .info) +- } +- +- var commandType: RemoteNotificationResponseManager.CommandType = .bolus // Default, will be set in switch +- var success = false +- var message = "" +- var syncIdentifier: String? = nil +- +- do { +- switch action { +- case .temporaryScheduleOverride(let overrideCommand): +- commandType = .override +- try await self.serviceDelegate?.enactRemoteOverride( +- name: overrideCommand.name, +- durationTime: overrideCommand.durationTime, +- remoteAddress: overrideCommand.remoteAddress +- ) +- success = true +- message = "Override '\(overrideCommand.name)' enacted successfully" +- +- case .profileSwitch(let profileSwitchCommand): +- commandType = .profileSwitch +- guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { +- throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) +- } +- do { +- try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in +- let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) +- guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { +- throw RemoteProfileSwitchError.profileChanged +- } +- } +- } catch { +- throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) +- } +- success = true +- message = "Profile '\(profileSwitchCommand.name)' activated" +- +- case .cancelTemporaryOverride: +- commandType = .cancelOverride +- try await self.serviceDelegate?.cancelRemoteOverride() +- success = true +- message = "Override cancelled successfully" +- +- case .bolusEntry(let bolusCommand): +- commandType = .bolus +- try await self.serviceDelegate?.deliverRemoteBolus(amountInUnits: bolusCommand.amountInUnits) +- success = true +- message = String(format: "Bolus of %.2f units delivered successfully", bolusCommand.amountInUnits) +- +- case .carbsEntry(let carbCommand): +- commandType = .carbs +- try await self.serviceDelegate?.deliverRemoteCarbs( +- amountInGrams: carbCommand.amountInGrams, +- absorptionTime: carbCommand.absorptionTime, +- foodType: carbCommand.foodType, +- startDate: carbCommand.startDate +- ) +- success = true +- message = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) +- +- case .carbsDelete(let deleteCommand): +- commandType = .carbsDelete +- syncIdentifier = deleteCommand.syncIdentifier +- try await self.serviceDelegate?.deleteRemoteCarbs(syncIdentifier: deleteCommand.syncIdentifier) +- success = true +- message = "Carb entry deleted successfully" +- +- case .carbsEdit(let editCommand): +- commandType = .carbsEdit +- syncIdentifier = editCommand.syncIdentifier +- try await self.serviceDelegate?.replaceRemoteCarbs( +- syncIdentifier: editCommand.syncIdentifier, +- amountInGrams: editCommand.amountInGrams, +- absorptionTime: editCommand.absorptionTime, +- foodType: editCommand.foodType, +- startDate: editCommand.startDate +- ) +- success = true +- message = String(format: "Carb entry updated to %.1f g", editCommand.amountInGrams) +- } +- } catch { +- message = "Command failed: \(error.localizedDescription)" +- // Send failure response before rethrowing +- if let returnInfo = returnInfo { +- await RemoteNotificationResponseManager.shared.sendResponseNotification( +- to: returnInfo, +- commandType: commandType, +- success: false, +- message: message, +- syncIdentifier: syncIdentifier +- ) +- } +- throw error +- } +- +- // Send success response +- if let returnInfo = returnInfo { +- await RemoteNotificationResponseManager.shared.sendResponseNotification( +- to: returnInfo, +- commandType: commandType, +- success: success, +- message: message, +- syncIdentifier: syncIdentifier +- ) +- } +- } +- +- func commandSourceV1(_: RemoteCommandSourceV1, uploadError error: Error, notification: [String: AnyObject]) async throws { +- ++ func commandSourceV1(_: RemoteCommandSourceV1, uploadError errorMessage: String, receivedDate: Date, notification: [String: AnyObject]) async throws { ++ + guard let uploader = self.uploader else {throw NightscoutServiceError.missingCredentials} + var commandDescription = "Loop Remote Action Error" + if let remoteNotification = try? notification.toRemoteNotification() { + commandDescription = remoteNotification.toRemoteAction().description + } +- ++ + let notificationJSON = try JSONSerialization.data(withJSONObject: notification) + let notificationJSONString = String(data: notificationJSON, encoding: .utf8) ?? "" +- ++ + let noteBody = """ +- \(error.localizedDescription) ++ \(errorMessage) + \(notificationJSONString) + """ + + let treatment = NightscoutTreatment( +- timestamp: Date(), ++ timestamp: receivedDate, + enteredBy: commandDescription, + notes: noteBody, + eventType: .note + ) +- ++ + return try await withCheckedThrowingContinuation { continuation in + uploader.upload([treatment], completionHandler: { result in + switch result { +diff --git a/NightscoutService/NightscoutServiceKit/OTPManager.swift b/NightscoutService/NightscoutServiceKit/OTPManager.swift +index 3d51b72..7a572d3 100644 +--- a/NightscoutService/NightscoutServiceKit/OTPManager.swift ++++ b/NightscoutService/NightscoutServiceKit/OTPManager.swift +@@ -77,8 +77,19 @@ public class OTPManager { + let maxOTPsToAccept: Int + + public static var defaultTokenPeriod: TimeInterval = 30 +- public static var defaultMaxOTPsToAccept = 2 +- ++ public static var defaultMaxOTPsToAccept = 30 ++ ++ public init(secretStore: OTPSecretStore = KeychainManager(), nowDateSource: @escaping () -> Date = {Date()}, maxMinutesValid: Double) { ++ self.secretStore = secretStore ++ self.nowDateSource = nowDateSource ++ self.tokenPeriod = OTPManager.defaultTokenPeriod ++ let secondsValid = 60.0 * maxMinutesValid ++ self.maxOTPsToAccept = Int(secondsValid / OTPManager.defaultTokenPeriod) ++ if secretStore.tokenSecretKey() == nil || secretStore.tokenSecretKeyName() == nil { ++ resetSecretKey() ++ } ++ } ++ + public init(secretStore: OTPSecretStore = KeychainManager(), nowDateSource: @escaping () -> Date = {Date()}, tokenPeriod: TimeInterval = OTPManager.defaultTokenPeriod, maxOTPsToAccept: Int = OTPManager.defaultMaxOTPsToAccept) { + self.secretStore = secretStore + self.nowDateSource = nowDateSource +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift +index 3a56d81..6db615c 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift +@@ -9,10 +9,12 @@ + import Foundation + + public struct BolusAction: Codable { +- ++ + public let amountInUnits: Double +- +- public init(amountInUnits: Double) { ++ public let userCreatedDate: Date ++ ++ public init(amountInUnits: Double, userCreatedDate: Date) { + self.amountInUnits = amountInUnits ++ self.userCreatedDate = userCreatedDate + } + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift +index 3865989..b315b16 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift +@@ -14,11 +14,13 @@ public struct CarbAction: Codable{ + public let absorptionTime: TimeInterval? + public let foodType: String? + public let startDate: Date? +- +- public init(amountInGrams: Double, absorptionTime: TimeInterval? = nil, foodType: String? = nil, startDate: Date? = nil) { ++ public let userCreatedDate: Date ++ ++ public init(amountInGrams: Double, absorptionTime: TimeInterval? = nil, foodType: String? = nil, startDate: Date? = nil, userCreatedDate: Date) { + self.amountInGrams = amountInGrams + self.absorptionTime = absorptionTime + self.foodType = foodType + self.startDate = startDate ++ self.userCreatedDate = userCreatedDate + } + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteCommandSource.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteCommandSource.swift +index e3cea7f..eb57fac 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteCommandSource.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteCommandSource.swift +@@ -9,5 +9,6 @@ + import LoopKit + + protocol RemoteCommandSource { +- func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async ++ func remoteNotificationWasReceived(_ notification: [String: AnyObject], serviceDelegate: ServiceDelegate) async ++ func notificationHistory() async -> [StoredRemoteNotification] + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift +index 1c14cdf..869abc2 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift +@@ -14,11 +14,11 @@ public struct BolusRemoteNotification: RemoteNotification, Codable { + public let amount: Double + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? +- ++ + enum CodingKeys: String, CodingKey { + case remoteAddress = "remote-address" + case amount = "bolus-entry" +@@ -28,9 +28,13 @@ public struct BolusRemoteNotification: RemoteNotification, Codable { + case enteredBy = "entered-by" + case encryptedReturnNotification = "encrypted_return_notification" + } +- ++ + func toRemoteAction() -> Action { +- return .bolusEntry(BolusAction(amountInUnits: amount)) ++ return .bolusEntry(toBolusAction()) ++ } ++ ++ func toBolusAction() -> BolusAction { ++ return BolusAction(amountInUnits: amount, userCreatedDate: sentAt) + } + + func otpValidationRequired() -> Bool { +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift +index 1593a74..eda9134 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift +@@ -11,7 +11,7 @@ public struct CarbDeleteRemoteNotification: RemoteNotification, Codable { + public let syncIdentifier: String + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift +index fde3e4c..f1ffb51 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift +@@ -16,7 +16,7 @@ public struct CarbEditRemoteNotification: RemoteNotification, Codable { + public let startDate: Date + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift +index 6c238ed..c069c34 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift +@@ -17,7 +17,7 @@ public struct CarbRemoteNotification: RemoteNotification, Codable { + public let startDate: Date? + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? +@@ -43,7 +43,7 @@ public struct CarbRemoteNotification: RemoteNotification, Codable { + } + + func toRemoteAction() -> Action { +- let action = CarbAction(amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate) ++ let action = CarbAction(amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate, userCreatedDate: sentAt) + return .carbsEntry(action) + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift +index fdd6d6b..babb71f 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift +@@ -13,7 +13,7 @@ public struct OverrideCancelRemoteNotification: RemoteNotification, Codable { + + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let cancelOverride: String + public let enteredBy: String? + public let otp: String? +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift +index 2060a74..426ff89 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift +@@ -15,7 +15,7 @@ public struct OverrideRemoteNotification: RemoteNotification, Codable { + public let durationInMinutes: Double? + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let enteredBy: String? + public let otp: String? + public let encryptedReturnNotification: String? +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +index 487c620..ebcd6ef 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -16,7 +16,7 @@ public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { + public let fingerprint: String + public let remoteAddress: String + public let expirationDate: Date +- public let sentAt: Date? ++ public let sentAt: Date + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 87f1e92..1ea8b96 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -14,7 +14,7 @@ protocol RemoteNotification: Codable { + + var id: String {get} + var expiration: Date? {get} +- var sentAt: Date? {get} ++ var sentAt: Date {get} + var otp: String? {get} + var remoteAddress: String {get} + var enteredBy: String? {get} +@@ -40,12 +40,8 @@ extension RemoteNotification { + } + + var id: String { +- //There is no unique identifier so we use the sent date when available +- if let sentAt = sentAt { +- return "\(sentAt.timeIntervalSince1970)" +- } else { +- return UUID().uuidString +- } ++ //There is no unique identifier so we use the sent date ++ return "\(sentAt.timeIntervalSince1970)" + } + + func getReturnNotificationInfo() -> ReturnNotificationInfo? { +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +index 0165004..bff69c8 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +@@ -7,69 +7,558 @@ + // + + import Foundation ++import LoopKit + import OSLog + + class RemoteCommandSourceV1: RemoteCommandSource { +- ++ + weak var delegate: RemoteCommandSourceV1Delegate? + private let otpManager: OTPManager + private let log = OSLog(category: "Remote Command Source V1") + private var commandValidator: RemoteCommandValidator + private var recentNotifications = RecentNotifications() +- ++ + init(otpManager: OTPManager) { + self.otpManager = otpManager + self.commandValidator = RemoteCommandValidator(otpManager: otpManager) + } +- ++ + //MARK: RemoteCommandSource +- +- func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async { +- +- if let encryptedReturnNotification = notification["encrypted_return_notification"] { +- log.info("Found encrypted_return_notification in notification: %{public}@", String(describing: encryptedReturnNotification)) +- } else { +- log.info("No encrypted_return_notification found in notification. Available keys: %{public}@", Array(notification.keys).joined(separator: ", ")) ++ ++ func remoteNotificationWasReceived(_ notification: [String: AnyObject], serviceDelegate: ServiceDelegate) async { ++ ++ guard let remoteNotification = try? notification.toRemoteNotification() else { ++ log.error("Remote Notification: Malformed notification payload") ++ return ++ } ++ ++ guard await !recentNotifications.contains(pushIdentifier: remoteNotification.id) else { ++ // Duplicate notifications are expected after app is force killed ++ // https://github.com/LoopKit/Loop/issues/2174 ++ return ++ } ++ ++ // Extract APNS return info for sending responses back to the caregiver ++ let returnInfo = remoteNotification.getReturnNotificationInfo() ++ if returnInfo != nil { ++ log.info("Return notification info available, will send APNS response after command processing") + } +- ++ ++ var commandType: RemoteNotificationResponseManager.CommandType = .bolus ++ var successMessage = "" ++ var syncIdentifier: String? = nil ++ + do { +- guard let delegate = delegate else {return} +- let remoteNotification = try notification.toRemoteNotification() +- +- // Log after parsing to see if the field was preserved +- if let encryptedReturnNotification = remoteNotification.encryptedReturnNotification { +- log.info("Parsed encrypted_return_notification successfully, length: %d", encryptedReturnNotification.count) +- } else { +- log.info("encrypted_return_notification is nil after parsing") ++ try await recentNotifications.trackReceivedRemoteNotification(remoteNotification, rawNotification: notification) ++ try commandValidator.validate(remoteNotification: remoteNotification) ++ ++ switch remoteNotification.toRemoteAction() { ++ case .bolusEntry(let bolusCommand): ++ commandType = .bolus ++ let doseEntry = try await serviceDelegate.deliverRemoteBolus( ++ amountInUnits: bolusCommand.amountInUnits, ++ userCreatedDate: bolusCommand.userCreatedDate ++ ) ++ var adjustmentMessage: String? = nil ++ if bolusCommand.amountInUnits > doseEntry.programmedUnits { ++ let quantityFormatter = QuantityFormatter(for: .internationalUnit()) ++ if let bolusAmountDescription = quantityFormatter.numberFormatter.string(from: bolusCommand.amountInUnits as NSNumber), ++ let doseAmountDescription = quantityFormatter.numberFormatter.string(from: doseEntry.programmedUnits as NSNumber){ ++ adjustmentMessage = "Bolus amount was reduced from \(bolusAmountDescription) U to \(doseAmountDescription) U due to other recent treatments." ++ } ++ } ++ successMessage = String(format: "Bolus of %.2f units delivered successfully", doseEntry.programmedUnits) ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: doseEntry.syncIdentifier ?? "", completionMessage: adjustmentMessage), ++ notificationJSON: notification ++ ) ++ case .cancelTemporaryOverride: ++ commandType = .cancelOverride ++ let cancelledOverride = try await serviceDelegate.cancelRemoteOverride() ++ successMessage = "Override cancelled successfully" ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: cancelledOverride.syncIdentifier.uuidString, completionMessage: nil), ++ notificationJSON: notification ++ ) ++ case .carbsEntry(let carbCommand): ++ commandType = .carbs ++ let carbEntry = try await serviceDelegate.deliverRemoteCarbs( ++ amountInGrams: carbCommand.amountInGrams, ++ absorptionTime: carbCommand.absorptionTime, ++ foodType: carbCommand.foodType, ++ startDate: carbCommand.startDate, ++ userCreatedDate: carbCommand.userCreatedDate ++ ) ++ successMessage = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: carbEntry.syncIdentifier ?? "", completionMessage: nil), ++ notificationJSON: notification ++ ) ++ case .carbsDelete(let deleteCommand): ++ commandType = .carbsDelete ++ syncIdentifier = deleteCommand.syncIdentifier ++ let deletedEntry = try await serviceDelegate.deleteRemoteCarbs(syncIdentifier: deleteCommand.syncIdentifier) ++ successMessage = "Carb entry deleted successfully" ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: deletedEntry.syncIdentifier ?? "", completionMessage: nil), ++ notificationJSON: notification ++ ) ++ case .carbsEdit(let editCommand): ++ commandType = .carbsEdit ++ syncIdentifier = editCommand.syncIdentifier ++ let replacementEntry = try await serviceDelegate.replaceRemoteCarbs( ++ syncIdentifier: editCommand.syncIdentifier, ++ amountInGrams: editCommand.amountInGrams, ++ absorptionTime: editCommand.absorptionTime, ++ foodType: editCommand.foodType, ++ startDate: editCommand.startDate ++ ) ++ successMessage = String(format: "Carb entry updated to %.1f g", editCommand.amountInGrams) ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: replacementEntry.syncIdentifier ?? "", completionMessage: nil), ++ notificationJSON: notification ++ ) ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ successMessage = "Profile '\(profileSwitchCommand.name)' activated" ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: "", completionMessage: nil), ++ notificationJSON: notification ++ ) ++ case .temporaryScheduleOverride(let overrideCommand): ++ commandType = .override ++ let override = try await serviceDelegate.enactRemoteOverride( ++ name: overrideCommand.name, ++ durationTime: overrideCommand.durationTime, ++ remoteAddress: overrideCommand.remoteAddress ++ ) ++ successMessage = "Override '\(overrideCommand.name)' enacted successfully" ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: override.syncIdentifier.uuidString, completionMessage: nil), ++ notificationJSON: notification ++ ) + } +- +- guard await !recentNotifications.isDuplicate(remoteNotification) else { +- // Duplicate notifications are expected after app is force killed +- // https://github.com/LoopKit/Loop/issues/2174 +- return ++ ++ // Send APNS success response back to caregiver ++ if let returnInfo = returnInfo { ++ await RemoteNotificationResponseManager.shared.sendResponseNotification( ++ to: returnInfo, ++ commandType: commandType, ++ success: true, ++ message: successMessage, ++ syncIdentifier: syncIdentifier ++ ) + } +- try commandValidator.validate(remoteNotification: remoteNotification) +- try await delegate.commandSourceV1(self, handleAction: remoteNotification.toRemoteAction(), remoteNotification: remoteNotification) + } catch { + log.error("Remote Notification: %{public}@. Error: %{public}@", String(describing: notification), String(describing: error)) +- try? await self.delegate?.commandSourceV1(self, uploadError: error, notification: notification) ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .failure(date: Date(), errorMessage: error.localizedDescription), ++ notificationJSON: notification ++ ) ++ ++ // Send APNS failure response back to caregiver ++ if let returnInfo = returnInfo { ++ await RemoteNotificationResponseManager.shared.sendResponseNotification( ++ to: returnInfo, ++ commandType: commandType, ++ success: false, ++ message: "Command failed: \(error.localizedDescription)", ++ syncIdentifier: syncIdentifier ++ ) ++ } ++ } ++ } ++ ++ func handleEnactmentCompletion( ++ remoteNotification: RemoteNotification, ++ status: RemoteNotificationStatus, ++ notificationJSON: [String: AnyObject] ++ ) async { ++ do { ++ let storedNotification = try await recentNotifications.updateStatus(status, for: remoteNotification.id) ++ await uploadStoredNotification(storedNotification) ++ } catch { ++ log.error("Remote Notification: %{public}@. Error: %{public}@", String(describing: notificationJSON), String(describing: error)) ++ } ++ } ++ ++ func uploadStoredNotification(_ storedNotification: StoredRemoteNotification) async { ++ do { ++ switch storedNotification.status { ++ case .success(_, _, let completionMessage): ++ if let completionMessage { ++ // Store adjustments as an error note to Nightscout ++ // try await self.delegate?.commandSourceV1(self, uploadError: completionMessage, receivedDate: storedNotification.receivedDate, notification: storedNotification.notificationJSON()) ++ } ++ case .failure(_, let errorMessage): ++ try await self.delegate?.commandSourceV1(self, uploadError: errorMessage, receivedDate: storedNotification.receivedDate, notification: storedNotification.notificationJSON()) ++ case .none: ++ return ++ } ++ try await recentNotifications.updateUploadStatus(true, for: storedNotification.pushIdentifier) ++ } catch { ++ log.error("Remote Notification: %{public}@. Error: %{public}@", String(describing: storedNotification), String(describing: error)) ++ } ++ } ++ ++ func notificationHistory() async -> [StoredRemoteNotification] { ++ return await recentNotifications.notifications ++ } ++ ++ /// Uploads pending notifications. Limited to a few at a time to avoid long background delays. ++ func uploadPendingNotifications() async { ++ guard let mostRecentPendingNotification = await notificationHistory().filter({$0.isPendingUpload}).sorted(by: {$0.receivedDate > $1.receivedDate}).last else { ++ return ++ } ++ await uploadStoredNotification(mostRecentPendingNotification) ++ } ++ ++ func notificationPublisher() async -> AsyncStream<[StoredRemoteNotification]> { ++ return await recentNotifications.notificationPublisher() ++ } ++ ++ func deleteNotificationHistory() { ++ Task { ++ await recentNotifications.deleteNotificationHistory() + } + } + } + + protocol RemoteCommandSourceV1Delegate: AnyObject { +- func commandSourceV1(_: RemoteCommandSourceV1, handleAction action: Action, remoteNotification: RemoteNotification) async throws +- func commandSourceV1(_: RemoteCommandSourceV1, uploadError error: Error, notification: [String: AnyObject]) async throws ++ func commandSourceV1(_: RemoteCommandSourceV1, uploadError errorMessage: String, receivedDate: Date, notification: [String: AnyObject]) async throws + } + +-private actor RecentNotifications { +- private var recentNotifications = [RemoteNotification]() +- +- func isDuplicate(_ remoteNotification: RemoteNotification) -> Bool { +- if recentNotifications.contains(where: {remoteNotification.id == $0.id}) { ++// MARK: Notification history ++ ++public class StoredRemoteNotification: NSObject, Codable { ++ public let remoteNotificationType: RemoteNotificationType ++ public let notificationJSONData: Data ++ public var status: RemoteNotificationStatus? = nil ++ public var receivedDate: Date ++ public var uploaded: Bool = false ++ ++ init(notificationType: RemoteNotificationType, notificationJSONData: Data) { ++ self.remoteNotificationType = notificationType ++ self.notificationJSONData = notificationJSONData ++ self.receivedDate = Date() ++ self.uploaded = false ++ } ++ ++ convenience init(bolusNotification: BolusRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .bolus(bolusNotification), notificationJSONData: notificationJSONData) ++ } ++ ++ convenience init(carbNotification: CarbRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .carbs(carbNotification), notificationJSONData: notificationJSONData) ++ } ++ ++ convenience init(carbDeleteNotification: CarbDeleteRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .carbsDelete(carbDeleteNotification), notificationJSONData: notificationJSONData) ++ } ++ ++ convenience init(carbEditNotification: CarbEditRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .carbsEdit(carbEditNotification), notificationJSONData: notificationJSONData) ++ } ++ ++ convenience init(profileSwitchNotification: ProfileSwitchRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .profileSwitch(profileSwitchNotification), notificationJSONData: notificationJSONData) ++ } ++ ++ convenience init(overrideNotification: OverrideRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .override(overrideNotification), notificationJSONData: notificationJSONData) ++ } ++ ++ convenience init(overrideCancelNotification: OverrideCancelRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .overrideCancel(overrideCancelNotification), notificationJSONData: notificationJSONData) ++ } ++ ++ public var pushIdentifier: String { ++ return remoteNotification().id ++ } ++ ++ var isPendingUpload: Bool { ++ guard !uploaded else { ++ return false ++ } ++ switch status { ++ case .success, .failure: + return true ++ case nil: ++ return false ++ } ++ } ++ ++ func containsDose(_ dose: DoseEntry) -> Bool { ++ guard case let .bolus(bolusNotification) = remoteNotificationType else { ++ return false ++ } ++ ++ if case let .success(_, syncIdentifier: syncIdentifier, _) = status { ++ return dose.syncIdentifier == syncIdentifier + } +- recentNotifications.append(remoteNotification) +- return false ++ ++ // If sync identifier not set yet, that could mean either a failure occurred ++ // or we are in the middle of processing this notification. ++ // Doses start uploading during remote bolus action, ++ // before syncIdentifier is set to StoredRemoteAction. ++ // Heuristics are used to match the dose. ++ guard isDateWithinEnactmentPeriod(dose.startDate) else { ++ return false ++ } ++ ++ return dose.programmedUnits <= bolusNotification.amount ++ } ++ ++ func isDateWithinEnactmentPeriod(_ date: Date) -> Bool { ++ guard date.isAfterOrEqual(otherDate: receivedDate) else { ++ return false ++ } ++ ++ guard let completionDate else { ++ // Either enactment is in progress or there was an app crash during enactment ++ // For the corner case of an app crash, we want only want to match if the ++ // received date is within a few minutes. It should be within seconds really ++ // but minutes help when paused in the Xcode debugger. ++ return date.timeIntervalSince(receivedDate) < 60 * 5 ++ } ++ ++ guard date.isBeforeOrEqual(otherDate: completionDate) else { ++ return false ++ } ++ ++ return true ++ } ++ ++ var completionDate: Date? { ++ guard let status else { ++ return nil ++ } ++ switch status { ++ case .success(let completionDate, _, _): ++ return completionDate ++ case .failure(let completionDate, _): ++ return completionDate ++ } ++ } ++ ++ func remoteNotification() -> RemoteNotification { ++ switch remoteNotificationType { ++ case let .bolus(bolusNotification): ++ return bolusNotification ++ case let .carbs(carbNotification): ++ return carbNotification ++ case let .carbsDelete(carbDeleteNotification): ++ return carbDeleteNotification ++ case let .carbsEdit(carbEditNotification): ++ return carbEditNotification ++ case let .profileSwitch(profileSwitchNotification): ++ return profileSwitchNotification ++ case let .override(overrideNotification): ++ return overrideNotification ++ case let .overrideCancel(overrideCancelNotification): ++ return overrideCancelNotification ++ } ++ } ++ ++ public func remoteAction() -> Action { ++ return remoteNotification().toRemoteAction() ++ } ++ ++ func notificationJSON() throws -> [String: AnyObject] { ++ let jsonObject = try JSONSerialization.jsonObject(with: notificationJSONData, options: []) ++ guard let notificationJSON = jsonObject as? [String: AnyObject] else { ++ throw StoredRemoteNotificationError.notificationJSONTypeIncorrect ++ } ++ return notificationJSON ++ } ++ ++ enum StoredRemoteNotificationError: Error { ++ case notificationJSONTypeIncorrect ++ } ++ ++ public enum RemoteNotificationType: Codable { ++ case bolus(BolusRemoteNotification) ++ case carbs(CarbRemoteNotification) ++ case carbsDelete(CarbDeleteRemoteNotification) ++ case carbsEdit(CarbEditRemoteNotification) ++ case override(OverrideRemoteNotification) ++ case overrideCancel(OverrideCancelRemoteNotification) ++ case profileSwitch(ProfileSwitchRemoteNotification) ++ } ++} ++ ++public enum RemoteNotificationStatus: Codable, Equatable { ++ case success(date: Date, syncIdentifier: String, completionMessage: String?) ++ case failure(date: Date, errorMessage: String) ++} ++ ++actor RecentNotifications { ++ var notifications: [StoredRemoteNotification] = [] ++ private var continuation: AsyncStream<[StoredRemoteNotification]>.Continuation? ++ ++ init() { ++ Task { ++ await loadNotifications() ++ } ++ } ++ ++ // Publish ++ ++ func notificationPublisher() -> AsyncStream<[StoredRemoteNotification]> { ++ return AsyncStream { continuation in ++ self.continuation = continuation ++ continuation.yield(notifications) ++ } ++ } ++ ++ private func publish(notifications: [StoredRemoteNotification]) { ++ self.notifications = notifications ++ continuation?.yield(notifications) ++ } ++ ++ // Misc ++ ++ func contains(pushIdentifier: String) -> Bool { ++ return storedNotification(for: pushIdentifier) != nil ++ } ++ ++ func storedNotification(for id: String) -> StoredRemoteNotification? { ++ return notifications.first(where: {$0.pushIdentifier == id}) ++ } ++ ++ func trackReceivedRemoteNotification(_ remoteNotification: RemoteNotification, rawNotification: [String : AnyObject]) throws { ++ let data = try JSONSerialization.data(withJSONObject: rawNotification, options: []) ++ if let bolusNotification = remoteNotification as? BolusRemoteNotification { ++ let storedNotification = StoredRemoteNotification(bolusNotification: bolusNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) ++ } else if let carbNotification = remoteNotification as? CarbRemoteNotification { ++ let storedNotification = StoredRemoteNotification(carbNotification: carbNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) ++ } else if let carbDeleteNotification = remoteNotification as? CarbDeleteRemoteNotification { ++ let storedNotification = StoredRemoteNotification(carbDeleteNotification: carbDeleteNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) ++ } else if let carbEditNotification = remoteNotification as? CarbEditRemoteNotification { ++ let storedNotification = StoredRemoteNotification(carbEditNotification: carbEditNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) ++ } else if let profileSwitchNotification = remoteNotification as? ProfileSwitchRemoteNotification { ++ let storedNotification = StoredRemoteNotification(profileSwitchNotification: profileSwitchNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) ++ } else if let overrideNotification = remoteNotification as? OverrideRemoteNotification { ++ let storedNotification = StoredRemoteNotification(overrideNotification: overrideNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) ++ } else if let overrideCancelNotification = remoteNotification as? OverrideCancelRemoteNotification { ++ let storedNotification = StoredRemoteNotification(overrideCancelNotification: overrideCancelNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) ++ } else { ++ fatalError() ++ } ++ } ++ ++ func updateStatus(_ status: RemoteNotificationStatus, for pushIdentifier: String) throws -> StoredRemoteNotification { ++ guard let storedNotification = storedNotification(for: pushIdentifier) else { ++ throw RemoteNotificationError.notificationNotFound(pushIdentifier) ++ } ++ storedNotification.status = status ++ try storeNotification(storedNotification) ++ return storedNotification ++ } ++ ++ func updateUploadStatus(_ uploaded: Bool, for pushIdentifier: String) throws { ++ guard let storedNotification = storedNotification(for: pushIdentifier) else { ++ throw RemoteNotificationError.notificationNotFound(pushIdentifier) ++ } ++ storedNotification.uploaded = uploaded ++ try storeNotification(storedNotification) ++ } ++ ++ // Disk Storage ++ ++ static let maxStoredNotifications = 50 ++ ++ static var storageURL: URL { ++ let appSupportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! ++ return appSupportDir.appendingPathComponent("remote_notifications.json") ++ } ++ ++ private func storeNotification(_ notification: StoredRemoteNotification) throws { ++ if let existingIndex = notifications.firstIndex(where: {$0.pushIdentifier == notification.pushIdentifier}) { ++ notifications[existingIndex] = notification ++ } else { ++ notifications.append(notification) ++ } ++ ++ if notifications.count > Self.maxStoredNotifications { ++ notifications = Array(notifications.suffix(Self.maxStoredNotifications)) ++ } ++ ++ try saveNotifications() ++ publish(notifications: notifications) ++ } ++ ++ private func saveNotifications() throws { ++ let encoder = JSONEncoder() ++ encoder.dateEncodingStrategy = .iso8601 ++ let data = try encoder.encode(notifications) ++ try data.write(to: Self.storageURL) ++ } ++ ++ private func loadNotifications() { ++ let decoder = JSONDecoder() ++ decoder.dateDecodingStrategy = .iso8601 ++ guard let data = try? Data(contentsOf: Self.storageURL) else { ++ return ++ } ++ guard let loaded = try? decoder.decode([StoredRemoteNotification].self, from: data) else { ++ return ++ } ++ self.notifications = loaded ++ } ++ ++ func deleteNotificationHistory() { ++ notifications = [] ++ try? saveNotifications() ++ publish(notifications: notifications) ++ } ++} ++ ++enum RemoteNotificationError: LocalizedError { ++ case notificationNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .notificationNotFound(let id): ++ return "Notification not found: \(id)" ++ } ++ } ++} ++ ++extension Date { ++ func isBeforeOrEqual(otherDate: Date) -> Bool { ++ return self <= otherDate ++ } ++ ++ func isAfterOrEqual(otherDate: Date) -> Bool { ++ return self >= otherDate + } + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +index 21d0e0a..5be6e1c 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +@@ -20,9 +20,6 @@ struct RemoteCommandValidator { + guard remoteNotification.expiration != nil else { + throw NotificationValidationError.missingExpiration + } +- guard remoteNotification.sentAt != nil else { +- throw NotificationValidationError.missingSentAt +- } + } + try validateExpirationDate(remoteNotification: remoteNotification) + if remoteNotification.otpValidationRequired() { +@@ -31,13 +28,13 @@ struct RemoteCommandValidator { + } + + private func validateExpirationDate(remoteNotification: RemoteNotification) throws { +- ++ + guard let expirationDate = remoteNotification.expiration else { + return //Skip validation if no date included + } +- ++ + if nowDateSource() > expirationDate { +- throw NotificationValidationError.expiredNotification ++ throw NotificationValidationError.expiredNotification(sentDate: remoteNotification.sentAt, receivedDate: nowDateSource()) + } + } + +@@ -52,20 +49,19 @@ struct RemoteCommandValidator { + + enum NotificationValidationError: LocalizedError { + case missingOTP +- case expiredNotification ++ case expiredNotification(sentDate: Date, receivedDate: Date) + case missingExpiration +- case missingSentAt +- ++ + var errorDescription: String? { + switch self { + case .missingOTP: + return LocalizedString("Missing OTP", comment: "Remote command error description: Missing OTP.") + case .missingExpiration: + return LocalizedString("Missing expiration", comment: "Remote command error description: missing expiration.") +- case .missingSentAt: +- return LocalizedString("Missing sent-at time", comment: "Remote command error description: missing sent-at.") +- case .expiredNotification: +- return LocalizedString("Expired", comment: "Remote command error description: expired.") ++ case .expiredNotification(let sentDate, let receivedDate): ++ let dateFormatter = DateFormatter() ++ dateFormatter.dateFormat = "h:mm:ss a" ++ return String(format: LocalizedString("Expired. Sent: %@. Received: %@", comment: "Remote command error description: expired."), dateFormatter.string(from: sentDate), dateFormatter.string(from: receivedDate)) + } + } + } +diff --git a/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift +index 6a3aba6..f03768c 100644 +--- a/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift ++++ b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift +@@ -48,20 +48,14 @@ final class CarbDeleteRemoteNotificationTestCase: XCTestCase { + } + } + +- func testValidate_MissingSentAt_Throws() throws { ++ func testParseCarbDeleteNotification_MissingSentAt_Throws() throws { + let notification: [String: AnyObject] = [ + "remote-address": "::ffff:11.2.44.155" as AnyObject, + "expiration": "2099-02-25T20:51:35.778Z" as AnyObject, + "carbs-delete": "abc" as AnyObject, + "otp": "12345" as AnyObject + ] +- let remoteNotification = try notification.toRemoteNotification() +- let validator = RemoteCommandValidator(otpManager: OTPTestCoordinator().createOTPManager()) + +- XCTAssertThrowsError(try validator.validate(remoteNotification: remoteNotification)) { error in +- guard case RemoteCommandValidator.NotificationValidationError.missingSentAt = error else { +- return XCTFail("Unexpected error \(error)") +- } +- } ++ XCTAssertThrowsError(try notification.toRemoteNotification()) + } + } +diff --git a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +index 3d54080..bb9423b 100644 +--- a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift ++++ b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +@@ -14,6 +14,9 @@ import LoopKit + protocol ServiceStatusViewModelDelegate { + func verifyConfiguration(completion: @escaping (Error?) -> Void) + var siteURL: URL? { get } ++ func notificationHistory() async -> [StoredRemoteNotification] ++ func notificationPublisher() async -> AsyncStream<[StoredRemoteNotification]> ++ func deleteNotificationHistory() + } + + enum ServiceStatus { +@@ -35,19 +38,100 @@ extension ServiceStatus: CustomStringConvertible { + } + } + ++struct RemoteCommand: Identifiable { ++ let id: String ++ let actionName: String ++ let createdDate: String ++ let details: String ++ let statusMessage: String ++ let isError: Bool ++} ++ ++extension StoredRemoteNotification { ++ var actionName: String { ++ switch remoteNotificationType { ++ case .bolus: ++ return "Bolus" ++ case .carbs: ++ return "Carbs" ++ case .carbsDelete: ++ return "Delete Carbs" ++ case .carbsEdit: ++ return "Edit Carbs" ++ case .override: ++ return "Override" ++ case .profileSwitch: ++ return "Profile Switch" ++ case .overrideCancel: ++ return "Cancel Override" ++ } ++ } ++ ++ var createdDateDescription: String { ++ let formatter = DateFormatter() ++ formatter.dateStyle = .short ++ formatter.timeStyle = .medium ++ return formatter.string(from: receivedDate) ++ } ++ ++ var details: String { ++ switch remoteNotificationType { ++ case .bolus(let notification): ++ return String(format: "%.2f U", notification.amount) ++ case .carbs(let notification): ++ return String(format: "%.0f g", notification.amount) ++ case .carbsDelete(let notification): ++ return String(notification.syncIdentifier.prefix(8)) ++ case .carbsEdit(let notification): ++ return String(format: "%.0f g", notification.amount) ++ case .override(let notification): ++ return notification.name ++ case .profileSwitch(let notification): ++ return notification.profileName ++ case .overrideCancel: ++ return "" ++ } ++ } ++ ++ var statusMessage: String { ++ guard let status else { ++ return "Pending..." ++ } ++ switch status { ++ case .success(_, _, let completionMessage): ++ return completionMessage ?? "Success" ++ case .failure(_, let errorMessage): ++ return errorMessage ++ } ++ } ++ ++ var isError: Bool { ++ guard let status else { ++ return false ++ } ++ switch status { ++ case .success: ++ return false ++ case .failure: ++ return true ++ } ++ } ++} ++ + class ServiceStatusViewModel: ObservableObject { + @Published var status: ServiceStatus = .checking +- ++ @Published var remoteCommands: [RemoteCommand] = [] ++ + let delegate: ServiceStatusViewModelDelegate + var didLogout: (() -> Void)? +- ++ + var urlString: String { + return delegate.siteURL?.absoluteString ?? LocalizedString("Not Available", comment: "Error when nightscout service url is not set") + } + + init(delegate: ServiceStatusViewModelDelegate) { + self.delegate = delegate +- ++ + delegate.verifyConfiguration { (error) in + DispatchQueue.main.async { + if let error = error { +@@ -57,5 +141,24 @@ class ServiceStatusViewModel: ObservableObject { + } + } + } ++ ++ Task { ++ let stream = await delegate.notificationPublisher() ++ for await notifications in stream { ++ let commands = notifications.reversed().map { notification in ++ RemoteCommand( ++ id: notification.pushIdentifier, ++ actionName: notification.actionName, ++ createdDate: notification.createdDateDescription, ++ details: notification.details, ++ statusMessage: notification.statusMessage, ++ isError: notification.isError ++ ) ++ } ++ await MainActor.run { ++ self.remoteCommands = commands ++ } ++ } ++ } + } + } +diff --git a/NightscoutService/NightscoutServiceKitUI/Views/ServiceStatusView.swift b/NightscoutService/NightscoutServiceKitUI/Views/ServiceStatusView.swift +index 4ecbdde..f213ccb 100644 +--- a/NightscoutService/NightscoutServiceKitUI/Views/ServiceStatusView.swift ++++ b/NightscoutService/NightscoutServiceKitUI/Views/ServiceStatusView.swift +@@ -17,56 +17,86 @@ struct ServiceStatusView: View, HorizontalSizeClassOverride { + @ObservedObject var otpViewModel: OTPViewModel + @State private var selectedItem: String? + var body: some View { +- VStack { +- Text("Nightscout") +- .font(.largeTitle) +- .fontWeight(.semibold) +- Image(frameworkImage: "nightscout", decorative: true) +- .resizable() +- .aspectRatio(contentMode: .fill) +- .frame(width: 150, height: 150) +- ++ List { ++ Section { ++ VStack { ++ Text("Nightscout") ++ .font(.largeTitle) ++ .fontWeight(.semibold) ++ Image(frameworkImage: "nightscout", decorative: true) ++ .resizable() ++ .aspectRatio(contentMode: .fill) ++ .frame(width: 150, height: 150) ++ } ++ .frame(maxWidth: .infinity) ++ .listRowBackground(Color.clear) ++ } + +- VStack(spacing: 0) { ++ Section { + HStack { + Text("URL") + Spacer() + Text(viewModel.urlString) + } +- .padding() +- Divider() + HStack { + Text("Status") + Spacer() + Text(String(describing: viewModel.status)) + } +- .padding() +- Divider() + NavigationLink(destination: OTPSelectionView(otpViewModel: otpViewModel), tag: "otp-view", selection: $selectedItem) { + HStack { + Text("One-Time Password") + Spacer() + Text(otpViewModel.otpCode) +- Image(systemName: "chevron.right") +- .font(.caption) + } +- }.foregroundColor(Color.primary) +- .padding() ++ } + } +- .background(Color(UIColor.secondarySystemBackground)) +- .cornerRadius(10) +- +- Button(action: { +- viewModel.didLogout?() +- } ) { +- Text("Logout").padding(.top, 20) ++ ++ Section(header: Text("Remote Commands")) { ++ if viewModel.remoteCommands.isEmpty { ++ Text("No remote commands received") ++ .foregroundColor(.secondary) ++ } else { ++ ForEach(viewModel.remoteCommands) { command in ++ VStack(alignment: .leading, spacing: 4) { ++ HStack { ++ Text(command.actionName) ++ .fontWeight(.medium) ++ if !command.details.isEmpty { ++ Text(command.details) ++ .foregroundColor(.secondary) ++ } ++ Spacer() ++ Text(command.createdDate) ++ .font(.caption) ++ .foregroundColor(.secondary) ++ } ++ Text(command.statusMessage) ++ .font(.caption) ++ .foregroundColor(command.isError ? .red : .secondary) ++ } ++ } ++ } ++ Button(role: .destructive) { ++ viewModel.delegate.deleteNotificationHistory() ++ } label: { ++ Text("Remove History") ++ } ++ } ++ ++ Section { ++ Button(action: { ++ viewModel.didLogout?() ++ } ) { ++ Text("Logout") ++ .foregroundColor(.red) ++ } + } + } +- .padding([.leading, .trailing]) + .navigationBarTitle("") + .navigationBarItems(trailing: dismissButton) + } +- ++ + private var dismissButton: some View { + Button(action: dismiss) { + Text("Done").bold() +diff --git a/remote_window.loopcustomization b/remote_window.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/remote_window/dev_remote_window_remote_profile.patch b/remote_window/dev_remote_window_remote_profile.patch new file mode 100644 index 0000000..3f7f163 --- /dev/null +++ b/remote_window/dev_remote_window_remote_profile.patch @@ -0,0 +1,1782 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index 3b5f1de2..c253e88e 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -838,13 +838,14 @@ extension DeviceDataManager { + + // MARK: - Client API + extension DeviceDataManager { +- func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void = { _ in }) { ++ func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ result: Swift.Result) -> Void = { _ in }) { + guard let pumpManager = pumpManager else { +- completion(LoopError.configurationError(.pumpManager)) ++ completion(.failure(LoopError.configurationError(.pumpManager))) + return + } + +- self.loopManager.addRequestedBolus(DoseEntry(type: .bolus, startDate: Date(), value: units, unit: .units, isMutable: true)) { ++ let doseEntry = DoseEntry(type: .bolus, startDate: Date(), value: units, unit: .units, isMutable: true) ++ self.loopManager.addRequestedBolus(doseEntry) { + pumpManager.enactBolus(units: units, activationType: activationType) { (error) in + if let error = error { + self.log.error("%{public}@", String(describing: error)) +@@ -860,11 +861,23 @@ extension DeviceDataManager { + } + + self.loopManager.bolusRequestFailed(error) { +- completion(error) ++ completion(.failure(error)) + } + } else { + self.loopManager.bolusConfirmed() { +- completion(nil) ++ // The DoseEntry from the store is returned as it has the syncIdentifier set. ++ self.doseStore.getDoses(start: doseEntry.startDate, includeMutable: true) { result in ++ switch result { ++ case .success(let doses): ++ if let storedEntry = doses.filter({ $0.programmedUnits == doseEntry.programmedUnits }).first { ++ completion(.success(storedEntry)) ++ } else { ++ completion(.failure(EnactBolusError.storageFailure)) ++ } ++ case .failure: ++ completion(.failure(EnactBolusError.storageFailure)) ++ } ++ } + } + } + } +@@ -873,17 +886,17 @@ extension DeviceDataManager { + } + } + +- func enactBolus(units: Double, activationType: BolusActivationType) async throws { ++ func enactBolus(units: Double, activationType: BolusActivationType) async throws -> DoseEntry { + return try await withCheckedThrowingContinuation { continuation in +- enactBolus(units: units, activationType: activationType) { error in +- if let error = error { +- continuation.resume(throwing: error) +- return +- } +- continuation.resume() ++ enactBolus(units: units, activationType: activationType) { result in ++ continuation.resume(with: result) + } + } + } ++ ++ enum EnactBolusError: Error { ++ case storageFailure ++ } + + var pumpManagerStatus: PumpManagerStatus? { + return pumpManager?.status +@@ -1476,10 +1489,49 @@ extension Notification.Name { + + extension DeviceDataManager: ServicesManagerDosingDelegate { + +- func deliverBolus(amountInUnits: Double) async throws { +- try await enactBolus(units: amountInUnits, activationType: .manualNoRecommendation) ++ func deliverBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry { ++ let minTreatmentIntervalInMinutes = 10.0 ++ let boluses = try await doseStore.getBoluses(start: Date().addingTimeInterval(-.days(1))) ++ // Look back further for safety in case caregiver didn't know about other treatments before they send this (i.e. Upload delays) ++ let conflictStartDate = userCreatedDate.addingTimeInterval(-.minutes(minTreatmentIntervalInMinutes)) ++ let conflictingEntries = boluses.filter({$0.startDate >= conflictStartDate}) ++ let conflictingBolusAmount = conflictingEntries.map({$0.programmedUnits}).reduce(0, {$0 + $1}) ++ let adjustedDoseAmount = amountInUnits - conflictingBolusAmount ++ guard adjustedDoseAmount <= amountInUnits else { // Added safety check for the bolus math ++ throw BolusActionError.invalidCalculation ++ } ++ guard adjustedDoseAmount > 0.0 else { ++ throw BolusActionError.bolusAmountAdjustedToZero(amountInUnits, conflictStartDate) ++ } ++ return try await enactBolus(units: adjustedDoseAmount, activationType: .manualNoRecommendation) + } + ++ enum BolusActionError: LocalizedError { ++ case bolusAmountAdjustedToZero( _ requestedAmountInUnits: Double, _ conflictStartDate: Date) ++ case invalidCalculation ++ ++ var errorDescription: String? { ++ switch self { ++ case .bolusAmountAdjustedToZero(let amountInUnits, let conflictStartDate): ++ let treatmentAmountFormatted = Self.numberFormatter.string(from: HKQuantity(unit: .internationalUnit(), doubleValue: amountInUnits), unit: .internationalUnit()) ?? "" ++ return String(format: NSLocalizedString("All boluses delivered after %1$@ were subtracted from the requested bolus amount for safety. Those amount(s) exceed the requested amount of %2$@ so the remote bolus was not delivered.", comment: "Bolus error description: no remote bolus delivered."), conflictStartDate.formatted(date: .omitted, time: .complete), treatmentAmountFormatted) ++ case .invalidCalculation: ++ return String(format: NSLocalizedString("An invalid bolus calculation occurred.", comment: "Bolus error description: invalid calculation.")) ++ } ++ } ++ ++ static var numberFormatter: NumberFormatter = { ++ let formatter = NumberFormatter() ++ formatter.numberStyle = .decimal ++ return formatter ++ }() ++ ++ static var dateFormatter: DateFormatter = { ++ let formatter = DateFormatter() ++ formatter.timeStyle = .medium ++ return formatter ++ }() ++ } + } + + // MARK: - Critical Event Log Export +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index d0d2b312..0a5c29eb 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -2501,7 +2501,7 @@ extension LoopDataManager: ServicesManagerDelegate { + + //Overrides + +- func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws { ++ func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws -> TemporaryScheduleOverride { + + guard let preset = settings.overridePresets.first(where: { $0.name == name }) else { + throw EnactOverrideError.unknownPreset(name) +@@ -2514,11 +2514,27 @@ extension LoopDataManager: ServicesManagerDelegate { + } + + await enactOverride(remoteOverride) ++ return remoteOverride + } + + +- func cancelCurrentOverride() async throws { ++ func cancelCurrentOverride() async throws -> TemporaryScheduleOverride { ++ guard let currentOverride = settings.scheduleOverride else { ++ throw OverrideCancelError.overrideNotActive ++ } + await enactOverride(nil) ++ return currentOverride ++ } ++ ++ enum OverrideCancelError: LocalizedError { ++ case overrideNotActive ++ ++ var errorDescription: String? { ++ switch self { ++ case .overrideNotActive: ++ return String(format: NSLocalizedString("No overrides active for cancellation", comment: "Cancel Override error description: override not active for cancellation.")) ++ } ++ } + } + + func enactOverride(_ override: TemporaryScheduleOverride?) async { +@@ -2539,7 +2555,7 @@ extension LoopDataManager: ServicesManagerDelegate { + + //Carb Entry + +- func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { ++ func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry { + + let absorptionTime = absorptionTime ?? carbStore.defaultAbsorptionTimes.medium + if absorptionTime < LoopConstants.minCarbAbsorptionTime || absorptionTime > LoopConstants.maxCarbAbsorptionTime { +@@ -2554,6 +2570,19 @@ extension LoopDataManager: ServicesManagerDelegate { + throw CarbActionError.exceedsMaxCarbs + } + ++ let maxAllowedConflictingAmountInGrams = 0.0 // Not allowing any carbs ++ let minTreatmentIntervalInMinutes = 10.0 ++ let entries = try await carbStore.getCarbEntries(start: Date().addingTimeInterval(-.days(1)), end: Date().addingTimeInterval(.days(1))) ++ let conflictStartDate = userCreatedDate.addingTimeInterval(-.minutes(minTreatmentIntervalInMinutes)) // Look back a little further for added safety (i.e. Network delays) ++ let conflictingEntries = entries.filter({ entry in ++ let createdDate = entry.userCreatedDate ?? entry.startDate ++ return createdDate >= conflictStartDate ++ }) ++ let conflictingCarbAmount = conflictingEntries.map({$0.quantity.doubleValue(for: .gram())}).reduce(0, {$0 + $1}) ++ guard conflictingCarbAmount <= maxAllowedConflictingAmountInGrams else { ++ throw CarbActionError.conflictingTreatments(conflictingCarbAmount, minTreatmentIntervalInMinutes) ++ } ++ + if let startDate = startDate { + let maxStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) + let minStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryPastTime) +@@ -2565,7 +2594,7 @@ extension LoopDataManager: ServicesManagerDelegate { + let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) + let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate ?? Date(), foodType: foodType, absorptionTime: absorptionTime) + +- let _ = try await devliverCarbEntry(candidateCarbEntry) ++ return try await deliverCarbEntry(candidateCarbEntry) + } + + enum CarbActionError: LocalizedError { +@@ -2574,9 +2603,10 @@ extension LoopDataManager: ServicesManagerDelegate { + case invalidStartDate(Date) + case exceedsMaxCarbs + case invalidCarbs ++ case conflictingTreatments(_ conflictingAmountInGrams: Double, _ minutesRequiredBetweenTreatments: TimeInterval) + + var errorDescription: String? { +- switch self { ++ switch self { + case .exceedsMaxCarbs: + return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Carb error description: carbs exceed maximum amount.") + case .invalidCarbs: +@@ -2587,6 +2617,10 @@ extension LoopDataManager: ServicesManagerDelegate { + case .invalidStartDate(let startDate): + let startDateFormatted = Self.dateFormatter.string(from: startDate) + return String(format: NSLocalizedString("Start time is out of range: %@", comment: "Carb error description: invalid start time is out of range."), startDateFormatted) ++ case .conflictingTreatments(let conflictingAmountInGrams, let minutesRequiredBetweenTreatments): ++ let treatmentAmountFormatted = Self.numberFormatter.string(from: HKQuantity(unit: .gram(), doubleValue: conflictingAmountInGrams), unit: .gram()) ?? "" ++ let minutesRequiredBetweenTreatmentsFormatted = Self.numberFormatter.string(from: minutesRequiredBetweenTreatments) ?? "" ++ return String(format: NSLocalizedString("Conflicting carb treatments (%1$@) have occurred. At least %2$@ minutes must pass between carb entries.", comment: "Carb error description: conflicting treatments occurred."), treatmentAmountFormatted, minutesRequiredBetweenTreatmentsFormatted) + } + } + +@@ -2604,7 +2638,7 @@ extension LoopDataManager: ServicesManagerDelegate { + } + + //Can't add this concurrency wrapper method to LoopKit due to the minimum iOS version +- func devliverCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry { ++ func deliverCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry { + return try await withCheckedThrowingContinuation { continuation in + carbStore.addCarbEntry(carbEntry) { result in + switch result { +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index c9d3e04f..f5f0d3c5 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -233,13 +233,13 @@ class ServicesManager { + } + + public protocol ServicesManagerDosingDelegate: AnyObject { +- func deliverBolus(amountInUnits: Double) async throws ++ func deliverBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry + } + + public protocol ServicesManagerDelegate: AnyObject { +- func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws +- func cancelCurrentOverride() async throws +- func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws ++ func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws -> TemporaryScheduleOverride ++ func cancelCurrentOverride() async throws -> TemporaryScheduleOverride ++ func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry + } + + // MARK: - StatefulPluggableDelegate +@@ -274,7 +274,10 @@ extension ServicesManager: ServiceDelegate { + return semanticVersion + } + +- func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws { ++ func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride { ++ guard let servicesManagerDelegate else { ++ throw OverrideActionError.internalError ++ } + + var duration: TemporaryScheduleOverride.Duration? = nil + if let durationTime = durationTime { +@@ -294,14 +297,16 @@ extension ServicesManager: ServiceDelegate { + } + } + +- try await servicesManagerDelegate?.enactOverride(name: name, duration: duration, remoteAddress: remoteAddress) ++ let override = try await servicesManagerDelegate.enactOverride(name: name, duration: duration, remoteAddress: remoteAddress) + await remoteDataServicesManager.triggerUpload(for: .overrides) ++ return override + } + + enum OverrideActionError: LocalizedError { + + case durationExceedsMax(TimeInterval) + case negativeDuration ++ case internalError + + var errorDescription: String? { + switch self { +@@ -309,29 +314,55 @@ extension ServicesManager: ServiceDelegate { + return String(format: NSLocalizedString("Duration exceeds: %1$.1f hours", comment: "Override error description: duration exceed max (1: max duration in hours)."), maxDurationTime.hours) + case .negativeDuration: + return String(format: NSLocalizedString("Negative duration not allowed", comment: "Override error description: negative duration error.")) ++ case .internalError: ++ return String(format: NSLocalizedString("Internal error", comment: "Override error description: Internal error.")) + } + } + } + +- func cancelRemoteOverride() async throws { +- try await servicesManagerDelegate?.cancelCurrentOverride() ++ func cancelRemoteOverride() async throws -> TemporaryScheduleOverride { ++ guard let servicesManagerDelegate else { ++ throw CarbActionError.internalError ++ } ++ ++ let cancelledOverride = try await servicesManagerDelegate.cancelCurrentOverride() + await remoteDataServicesManager.triggerUpload(for: .overrides) ++ return cancelledOverride + } + +- func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { ++ func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry { + do { +- try await servicesManagerDelegate?.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) ++ guard let servicesManagerDelegate else { ++ throw CarbActionError.internalError ++ } ++ ++ let carbEntry = try await servicesManagerDelegate.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate, userCreatedDate: userCreatedDate) + await NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: amountInGrams) + await remoteDataServicesManager.triggerUpload(for: .carb) + analyticsServicesManager.didAddCarbs(source: "Remote") ++ return carbEntry + } catch { + await NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: amountInGrams) + throw error + } + } + +- func deliverRemoteBolus(amountInUnits: Double) async throws { ++ enum CarbActionError: LocalizedError { ++ case internalError ++ ++ var errorDescription: String? { ++ switch self { ++ case .internalError: ++ return NSLocalizedString("Internal error", comment: "Carb error description: internal error.") ++ } ++ } ++ } ++ ++ func deliverRemoteBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry { + do { ++ guard let servicesManagerDosingDelegate else { ++ throw BolusActionError.internalError ++ } + + guard amountInUnits > 0 else { + throw BolusActionError.invalidBolus +@@ -345,10 +376,11 @@ extension ServicesManager: ServiceDelegate { + throw BolusActionError.exceedsMaxBolus + } + +- try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits) +- await NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) ++ let doseEntry = try await servicesManagerDosingDelegate.deliverBolus(amountInUnits: amountInUnits, userCreatedDate: userCreatedDate) ++ await NotificationManager.sendRemoteBolusNotification(amount: doseEntry.programmedUnits) + await remoteDataServicesManager.triggerUpload(for: .dose) + analyticsServicesManager.didBolus(source: "Remote") ++ return doseEntry + } catch { + await NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) + throw error +@@ -360,6 +392,7 @@ extension ServicesManager: ServiceDelegate { + case invalidBolus + case missingMaxBolus + case exceedsMaxBolus ++ case internalError + + var errorDescription: String? { + switch self { +@@ -369,6 +402,8 @@ extension ServicesManager: ServiceDelegate { + return NSLocalizedString("Missing maximum allowed bolus in settings", comment: "Bolus error description: missing maximum bolus in settings.") + case .exceedsMaxBolus: + return NSLocalizedString("Exceeds maximum allowed bolus in settings", comment: "Bolus error description: bolus exceeds maximum bolus in settings.") ++ case .internalError: ++ return NSLocalizedString("Internal error", comment: "Bolus error description: internal error.") + } + } + } +diff --git a/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +index a7ffef2e..41221127 100644 +--- a/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift ++++ b/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift +@@ -51,6 +51,8 @@ protocol CarbStoreProtocol: AnyObject { + func getTotalCarbs(since start: Date, completion: @escaping (_ result: CarbStoreResult) -> Void) + + func deleteCarbEntry(_ entry: StoredCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) ++ ++ func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] + } + + extension CarbStore: CarbStoreProtocol { } +diff --git a/Loop/Loop/View Models/BolusEntryViewModel.swift b/Loop/Loop/View Models/BolusEntryViewModel.swift +index 20735e7f..4f2691a4 100644 +--- a/Loop/Loop/View Models/BolusEntryViewModel.swift ++++ b/Loop/Loop/View Models/BolusEntryViewModel.swift +@@ -29,7 +29,7 @@ protocol BolusEntryViewModelDelegate: AnyObject { + + func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) + +- func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void) ++ func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ result: Swift.Result) -> Void) + + func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (_ samples: Swift.Result<[StoredGlucoseSample], Error>) -> Void) + +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit/InsulinKit/DoseStore.swift b/LoopKit/LoopKit/InsulinKit/DoseStore.swift +index 712f9048..be7c17dd 100644 +--- a/LoopKit/LoopKit/InsulinKit/DoseStore.swift ++++ b/LoopKit/LoopKit/InsulinKit/DoseStore.swift +@@ -1274,8 +1274,18 @@ extension DoseStore { + public func getDoses(start: Date? = nil, end: Date? = nil) async throws -> [DoseEntry] { + return try await insulinDeliveryStore.getDoses(start: start, end: end) + } +- +- ++ ++ /// Retrieves doses overlapping supplied range ++ /// ++ /// - Parameters: ++ /// - start: The earliest date of dose entries to retrieve. ++ /// - end: The latest date of dose entries to retrieve, if provided. ++ /// - includeMutable: Whether to include mutable dose entries or not. ++ /// - completion: A closure called once the dose entries have been retrieved. ++ /// - result: An array of dose entries, in chronological order by startDate, or error. ++ public func getDoses(start: Date, end: Date? = nil, includeMutable: Bool, completion: @escaping (_ result: Swift.Result<[DoseEntry], Error>) -> Void) { ++ insulinDeliveryStore.getDoseEntries(start: start, end: end, includeMutable: includeMutable, completion: completion) ++ } + + /// Retrieves the maximum insulin on-board value from the two timeline values nearest to the specified date + /// +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 00fe25ab..6f691598 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -15,8 +15,8 @@ public protocol RemoteProfileActionDelegate: AnyObject { + } + + public protocol RemoteActionDelegate: AnyObject { +- func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws +- func cancelRemoteOverride() async throws +- func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws +- func deliverRemoteBolus(amountInUnits: Double) async throws ++ func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride ++ func cancelRemoteOverride() async throws -> TemporaryScheduleOverride ++ func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry ++ func deliverRemoteBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry + } +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/NightscoutUploader.swift b/NightscoutService/NightscoutServiceKit/Extensions/NightscoutUploader.swift +index 4a85888..fa35651 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/NightscoutUploader.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/NightscoutUploader.swift +@@ -117,25 +117,27 @@ extension NightscoutClient { + + extension NightscoutClient { + +- func createDoses(_ data: [DoseEntry], usingObjectIdCache objectIdCache: ObjectIdCache, completion: @escaping (Result<[String], Error>) -> Void) { +- guard !data.isEmpty else { +- completion(.success([])) +- return +- } +- +- let source = "loop://\(UIDevice.current.name)" +- ++ func createDoses( ++ _ data: [DoseEntry], ++ sourceMessage: (DoseEntry) -> String, ++ usingObjectIdCache objectIdCache: ObjectIdCache, ++ completion: @escaping (Result<[String], Error>) -> Void ++ ) { + let treatments = data.compactMap { (dose) -> NightscoutTreatment? in + var objectId: String? = nil +- ++ + if let syncIdentifier = dose.syncIdentifier { + objectId = objectIdCache.findObjectIdBySyncIdentifier(syncIdentifier) + } +- +- return dose.treatment(enteredBy: source, withObjectId: objectId) ++ ++ return dose.treatment(enteredBy: sourceMessage(dose), withObjectId: objectId) + } +- +- ++ ++ guard !treatments.isEmpty else { ++ completion(.success([])) ++ return ++ } ++ + self.upload(treatments) { (result) in + switch result { + case .failure(let error): +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index ef69236..7adb37d 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -65,10 +65,12 @@ public final class NightscoutService: Service { + + private let log = OSLog(category: "NightscoutService") + ++ private let notificationExpirationInMinutes = 10.0 ++ + public init() { + self.isOnboarded = false + self.lockedObjectIdCache = Locked(ObjectIdCache()) +- self.otpManager = OTPManager(secretStore: KeychainManager()) ++ self.otpManager = OTPManager(secretStore: KeychainManager(), maxMinutesValid: notificationExpirationInMinutes) + self.commandSourceV1 = RemoteCommandSourceV1(otpManager: otpManager) + self.commandSourceV1.delegate = self + } +@@ -83,11 +85,11 @@ public final class NightscoutService: Service { + } else { + self.lockedObjectIdCache = Locked(ObjectIdCache()) + } +- +- self.otpManager = OTPManager(secretStore: KeychainManager()) ++ ++ self.otpManager = OTPManager(secretStore: KeychainManager(), maxMinutesValid: notificationExpirationInMinutes) + self.commandSourceV1 = RemoteCommandSourceV1(otpManager: otpManager) + self.commandSourceV1.delegate = self +- ++ + restoreCredentials() + } + +@@ -238,12 +240,35 @@ extension NightscoutService: RemoteDataService { + public var doseDataLimit: Int? { return 1000 } + + public func uploadDoseData(created: [DoseEntry], deleted: [DoseEntry], completion: @escaping (_ result: Result) -> Void) { ++ Task { ++ let notificationHistory = await notificationHistory() ++ uploadDoseData(created: created, deleted: deleted, notificationHistory: notificationHistory, completion: completion) ++ ++ // Upload pending stored notifications ++ await uploadPendingNotifications() ++ } ++ } ++ ++ func uploadPendingNotifications() async { ++ await commandSourceV1.uploadPendingNotifications() ++ } ++ ++ public func uploadDoseData(created: [DoseEntry], deleted: [DoseEntry], notificationHistory: [StoredRemoteNotification], completion: @escaping (_ result: Result) -> Void) { + guard hasConfiguration, let uploader = uploader else { + completion(.success(true)) + return + } + +- uploader.createDoses(created, usingObjectIdCache: self.objectIdCache) { (result) in ++ let deviceName = UIDevice.current.name ++ let sourceMessage: (DoseEntry) -> String = { (dose) -> String in ++ if notificationHistory.contains(where: { $0.containsDose(dose) }) { ++ return "Loop (via remote command)" ++ } else { ++ return "loop://\(deviceName)" ++ } ++ } ++ ++ uploader.createDoses(created, sourceMessage: sourceMessage, usingObjectIdCache: objectIdCache) { (result) in + switch (result) { + case .failure(let error): + completion(.failure(error)) +@@ -399,10 +424,34 @@ extension NightscoutService: RemoteDataService { + + + public func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async throws { ++ guard let serviceDelegate else { ++ return ++ } ++ ++ // Set the expiration to longer duration. Ideally this would be done in Nightscout instead. ++ var notification = notification ++ let dateFormatter = DateFormatter.iso8601DateDecoder ++ if let sentDateString = notification["sent-at"] as? String, let sentDate = dateFormatter.date(from: sentDateString) { ++ let expirationDate = sentDate.addingTimeInterval(60 * notificationExpirationInMinutes) ++ notification["expiration"] = dateFormatter.string(from: expirationDate) as AnyObject ++ } ++ + let commandSource = try commandSource(notification: notification) +- await commandSource.remoteNotificationWasReceived(notification) ++ await commandSource.remoteNotificationWasReceived(notification, serviceDelegate: serviceDelegate) + } +- ++ ++ public func notificationHistory() async -> [StoredRemoteNotification] { ++ return await commandSourceV1.notificationHistory() ++ } ++ ++ public func notificationPublisher() async -> AsyncStream<[StoredRemoteNotification]> { ++ return await commandSourceV1.notificationPublisher() ++ } ++ ++ public func deleteNotificationHistory() { ++ commandSourceV1.deleteNotificationHistory() ++ } ++ + private func commandSource(notification: [String: AnyObject]) throws -> RemoteCommandSource { + return commandSourceV1 + } +@@ -410,121 +459,29 @@ extension NightscoutService: RemoteDataService { + } + + extension NightscoutService: RemoteCommandSourceV1Delegate { +- +- func commandSourceV1(_: RemoteCommandSourceV1, handleAction action: Action, remoteNotification: RemoteNotification) async throws { +- +- let returnInfo = remoteNotification.getReturnNotificationInfo() +- if returnInfo == nil { +- os_log("No return notification info available, response will not be sent", log: .default, type: .info) +- } else { +- os_log("Return notification info available, will send response after command processing", log: .default, type: .info) +- } +- +- var commandType: RemoteNotificationResponseManager.CommandType = .bolus // Default, will be set in switch +- var success = false +- var message = "" +- +- do { +- switch action { +- case .temporaryScheduleOverride(let overrideCommand): +- commandType = .override +- try await self.serviceDelegate?.enactRemoteOverride( +- name: overrideCommand.name, +- durationTime: overrideCommand.durationTime, +- remoteAddress: overrideCommand.remoteAddress +- ) +- success = true +- message = "Override '\(overrideCommand.name)' enacted successfully" +- +- case .profileSwitch(let profileSwitchCommand): +- commandType = .profileSwitch +- guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { +- throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) +- } +- do { +- try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in +- let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) +- guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { +- throw RemoteProfileSwitchError.profileChanged +- } +- } +- } catch { +- throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) +- } +- success = true +- message = "Profile '\(profileSwitchCommand.name)' activated" +- +- case .cancelTemporaryOverride: +- commandType = .cancelOverride +- try await self.serviceDelegate?.cancelRemoteOverride() +- success = true +- message = "Override cancelled successfully" +- +- case .bolusEntry(let bolusCommand): +- commandType = .bolus +- try await self.serviceDelegate?.deliverRemoteBolus(amountInUnits: bolusCommand.amountInUnits) +- success = true +- message = String(format: "Bolus of %.2f units delivered successfully", bolusCommand.amountInUnits) +- +- case .carbsEntry(let carbCommand): +- commandType = .carbs +- try await self.serviceDelegate?.deliverRemoteCarbs( +- amountInGrams: carbCommand.amountInGrams, +- absorptionTime: carbCommand.absorptionTime, +- foodType: carbCommand.foodType, +- startDate: carbCommand.startDate +- ) +- success = true +- message = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) +- } +- } catch { +- message = "Command failed: \(error.localizedDescription)" +- // Send failure response before rethrowing +- if let returnInfo = returnInfo { +- await RemoteNotificationResponseManager.shared.sendResponseNotification( +- to: returnInfo, +- commandType: commandType, +- success: false, +- message: message +- ) +- } +- throw error +- } +- +- // Send success response +- if let returnInfo = returnInfo { +- await RemoteNotificationResponseManager.shared.sendResponseNotification( +- to: returnInfo, +- commandType: commandType, +- success: success, +- message: message +- ) +- } +- } +- +- func commandSourceV1(_: RemoteCommandSourceV1, uploadError error: Error, notification: [String: AnyObject]) async throws { +- ++ func commandSourceV1(_: RemoteCommandSourceV1, uploadError errorMessage: String, receivedDate: Date, notification: [String: AnyObject]) async throws { ++ + guard let uploader = self.uploader else {throw NightscoutServiceError.missingCredentials} + var commandDescription = "Loop Remote Action Error" + if let remoteNotification = try? notification.toRemoteNotification() { + commandDescription = remoteNotification.toRemoteAction().description + } +- ++ + let notificationJSON = try JSONSerialization.data(withJSONObject: notification) + let notificationJSONString = String(data: notificationJSON, encoding: .utf8) ?? "" +- ++ + let noteBody = """ +- \(error.localizedDescription) ++ \(errorMessage) + \(notificationJSONString) + """ + + let treatment = NightscoutTreatment( +- timestamp: Date(), ++ timestamp: receivedDate, + enteredBy: commandDescription, + notes: noteBody, + eventType: .note + ) +- ++ + return try await withCheckedThrowingContinuation { continuation in + uploader.upload([treatment], completionHandler: { result in + switch result { +diff --git a/NightscoutService/NightscoutServiceKit/OTPManager.swift b/NightscoutService/NightscoutServiceKit/OTPManager.swift +index 3d51b72..7a572d3 100644 +--- a/NightscoutService/NightscoutServiceKit/OTPManager.swift ++++ b/NightscoutService/NightscoutServiceKit/OTPManager.swift +@@ -77,8 +77,19 @@ public class OTPManager { + let maxOTPsToAccept: Int + + public static var defaultTokenPeriod: TimeInterval = 30 +- public static var defaultMaxOTPsToAccept = 2 +- ++ public static var defaultMaxOTPsToAccept = 30 ++ ++ public init(secretStore: OTPSecretStore = KeychainManager(), nowDateSource: @escaping () -> Date = {Date()}, maxMinutesValid: Double) { ++ self.secretStore = secretStore ++ self.nowDateSource = nowDateSource ++ self.tokenPeriod = OTPManager.defaultTokenPeriod ++ let secondsValid = 60.0 * maxMinutesValid ++ self.maxOTPsToAccept = Int(secondsValid / OTPManager.defaultTokenPeriod) ++ if secretStore.tokenSecretKey() == nil || secretStore.tokenSecretKeyName() == nil { ++ resetSecretKey() ++ } ++ } ++ + public init(secretStore: OTPSecretStore = KeychainManager(), nowDateSource: @escaping () -> Date = {Date()}, tokenPeriod: TimeInterval = OTPManager.defaultTokenPeriod, maxOTPsToAccept: Int = OTPManager.defaultMaxOTPsToAccept) { + self.secretStore = secretStore + self.nowDateSource = nowDateSource +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift +index 3a56d81..6db615c 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift +@@ -9,10 +9,12 @@ + import Foundation + + public struct BolusAction: Codable { +- ++ + public let amountInUnits: Double +- +- public init(amountInUnits: Double) { ++ public let userCreatedDate: Date ++ ++ public init(amountInUnits: Double, userCreatedDate: Date) { + self.amountInUnits = amountInUnits ++ self.userCreatedDate = userCreatedDate + } + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift +index 3865989..b315b16 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift +@@ -14,11 +14,13 @@ public struct CarbAction: Codable{ + public let absorptionTime: TimeInterval? + public let foodType: String? + public let startDate: Date? +- +- public init(amountInGrams: Double, absorptionTime: TimeInterval? = nil, foodType: String? = nil, startDate: Date? = nil) { ++ public let userCreatedDate: Date ++ ++ public init(amountInGrams: Double, absorptionTime: TimeInterval? = nil, foodType: String? = nil, startDate: Date? = nil, userCreatedDate: Date) { + self.amountInGrams = amountInGrams + self.absorptionTime = absorptionTime + self.foodType = foodType + self.startDate = startDate ++ self.userCreatedDate = userCreatedDate + } + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteCommandSource.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteCommandSource.swift +index e3cea7f..eb57fac 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteCommandSource.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteCommandSource.swift +@@ -9,5 +9,6 @@ + import LoopKit + + protocol RemoteCommandSource { +- func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async ++ func remoteNotificationWasReceived(_ notification: [String: AnyObject], serviceDelegate: ServiceDelegate) async ++ func notificationHistory() async -> [StoredRemoteNotification] + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift +index 1c14cdf..869abc2 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift +@@ -14,11 +14,11 @@ public struct BolusRemoteNotification: RemoteNotification, Codable { + public let amount: Double + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? +- ++ + enum CodingKeys: String, CodingKey { + case remoteAddress = "remote-address" + case amount = "bolus-entry" +@@ -28,9 +28,13 @@ public struct BolusRemoteNotification: RemoteNotification, Codable { + case enteredBy = "entered-by" + case encryptedReturnNotification = "encrypted_return_notification" + } +- ++ + func toRemoteAction() -> Action { +- return .bolusEntry(BolusAction(amountInUnits: amount)) ++ return .bolusEntry(toBolusAction()) ++ } ++ ++ func toBolusAction() -> BolusAction { ++ return BolusAction(amountInUnits: amount, userCreatedDate: sentAt) + } + + func otpValidationRequired() -> Bool { +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift +index 6c238ed..c069c34 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift +@@ -17,7 +17,7 @@ public struct CarbRemoteNotification: RemoteNotification, Codable { + public let startDate: Date? + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? +@@ -43,7 +43,7 @@ public struct CarbRemoteNotification: RemoteNotification, Codable { + } + + func toRemoteAction() -> Action { +- let action = CarbAction(amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate) ++ let action = CarbAction(amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate, userCreatedDate: sentAt) + return .carbsEntry(action) + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift +index fdd6d6b..babb71f 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift +@@ -13,7 +13,7 @@ public struct OverrideCancelRemoteNotification: RemoteNotification, Codable { + + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let cancelOverride: String + public let enteredBy: String? + public let otp: String? +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift +index 2060a74..426ff89 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift +@@ -15,7 +15,7 @@ public struct OverrideRemoteNotification: RemoteNotification, Codable { + public let durationInMinutes: Double? + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let enteredBy: String? + public let otp: String? + public let encryptedReturnNotification: String? +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +index 487c620..ebcd6ef 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -16,7 +16,7 @@ public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { + public let fingerprint: String + public let remoteAddress: String + public let expirationDate: Date +- public let sentAt: Date? ++ public let sentAt: Date + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 3362bf7..a0f58a0 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -14,7 +14,7 @@ protocol RemoteNotification: Codable { + + var id: String {get} + var expiration: Date? {get} +- var sentAt: Date? {get} ++ var sentAt: Date {get} + var otp: String? {get} + var remoteAddress: String {get} + var enteredBy: String? {get} +@@ -30,12 +30,8 @@ protocol RemoteNotification: Codable { + extension RemoteNotification { + + var id: String { +- //There is no unique identifier so we use the sent date when available +- if let sentAt = sentAt { +- return "\(sentAt.timeIntervalSince1970)" +- } else { +- return UUID().uuidString +- } ++ //There is no unique identifier so we use the sent date ++ return "\(sentAt.timeIntervalSince1970)" + } + + func getReturnNotificationInfo() -> ReturnNotificationInfo? { +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +index 0165004..0912feb 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +@@ -7,69 +7,509 @@ + // + + import Foundation ++import LoopKit + import OSLog + + class RemoteCommandSourceV1: RemoteCommandSource { +- ++ + weak var delegate: RemoteCommandSourceV1Delegate? + private let otpManager: OTPManager + private let log = OSLog(category: "Remote Command Source V1") + private var commandValidator: RemoteCommandValidator + private var recentNotifications = RecentNotifications() +- ++ + init(otpManager: OTPManager) { + self.otpManager = otpManager + self.commandValidator = RemoteCommandValidator(otpManager: otpManager) + } +- ++ + //MARK: RemoteCommandSource +- +- func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async { +- +- if let encryptedReturnNotification = notification["encrypted_return_notification"] { +- log.info("Found encrypted_return_notification in notification: %{public}@", String(describing: encryptedReturnNotification)) +- } else { +- log.info("No encrypted_return_notification found in notification. Available keys: %{public}@", Array(notification.keys).joined(separator: ", ")) ++ ++ func remoteNotificationWasReceived(_ notification: [String: AnyObject], serviceDelegate: ServiceDelegate) async { ++ ++ guard let remoteNotification = try? notification.toRemoteNotification() else { ++ log.error("Remote Notification: Malformed notification payload") ++ return ++ } ++ ++ guard await !recentNotifications.contains(pushIdentifier: remoteNotification.id) else { ++ // Duplicate notifications are expected after app is force killed ++ // https://github.com/LoopKit/Loop/issues/2174 ++ return ++ } ++ ++ // Extract APNS return info for sending responses back to the caregiver ++ let returnInfo = remoteNotification.getReturnNotificationInfo() ++ if returnInfo != nil { ++ log.info("Return notification info available, will send APNS response after command processing") + } +- ++ ++ var commandType: RemoteNotificationResponseManager.CommandType = .bolus ++ var successMessage = "" ++ + do { +- guard let delegate = delegate else {return} +- let remoteNotification = try notification.toRemoteNotification() +- +- // Log after parsing to see if the field was preserved +- if let encryptedReturnNotification = remoteNotification.encryptedReturnNotification { +- log.info("Parsed encrypted_return_notification successfully, length: %d", encryptedReturnNotification.count) +- } else { +- log.info("encrypted_return_notification is nil after parsing") ++ try await recentNotifications.trackReceivedRemoteNotification(remoteNotification, rawNotification: notification) ++ try commandValidator.validate(remoteNotification: remoteNotification) ++ ++ switch remoteNotification.toRemoteAction() { ++ case .bolusEntry(let bolusCommand): ++ commandType = .bolus ++ let doseEntry = try await serviceDelegate.deliverRemoteBolus( ++ amountInUnits: bolusCommand.amountInUnits, ++ userCreatedDate: bolusCommand.userCreatedDate ++ ) ++ var adjustmentMessage: String? = nil ++ if bolusCommand.amountInUnits > doseEntry.programmedUnits { ++ let quantityFormatter = QuantityFormatter(for: .internationalUnit()) ++ if let bolusAmountDescription = quantityFormatter.numberFormatter.string(from: bolusCommand.amountInUnits as NSNumber), ++ let doseAmountDescription = quantityFormatter.numberFormatter.string(from: doseEntry.programmedUnits as NSNumber){ ++ adjustmentMessage = "Bolus amount was reduced from \(bolusAmountDescription) U to \(doseAmountDescription) U due to other recent treatments." ++ } ++ } ++ successMessage = String(format: "Bolus of %.2f units delivered successfully", doseEntry.programmedUnits) ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: doseEntry.syncIdentifier ?? "", completionMessage: adjustmentMessage), ++ notificationJSON: notification ++ ) ++ case .cancelTemporaryOverride: ++ commandType = .cancelOverride ++ let cancelledOverride = try await serviceDelegate.cancelRemoteOverride() ++ successMessage = "Override cancelled successfully" ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: cancelledOverride.syncIdentifier.uuidString, completionMessage: nil), ++ notificationJSON: notification ++ ) ++ case .carbsEntry(let carbCommand): ++ commandType = .carbs ++ let carbEntry = try await serviceDelegate.deliverRemoteCarbs( ++ amountInGrams: carbCommand.amountInGrams, ++ absorptionTime: carbCommand.absorptionTime, ++ foodType: carbCommand.foodType, ++ startDate: carbCommand.startDate, ++ userCreatedDate: carbCommand.userCreatedDate ++ ) ++ successMessage = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: carbEntry.syncIdentifier ?? "", completionMessage: nil), ++ notificationJSON: notification ++ ) ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ successMessage = "Profile '\(profileSwitchCommand.name)' activated" ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: "", completionMessage: nil), ++ notificationJSON: notification ++ ) ++ case .temporaryScheduleOverride(let overrideCommand): ++ commandType = .override ++ let override = try await serviceDelegate.enactRemoteOverride( ++ name: overrideCommand.name, ++ durationTime: overrideCommand.durationTime, ++ remoteAddress: overrideCommand.remoteAddress ++ ) ++ successMessage = "Override '\(overrideCommand.name)' enacted successfully" ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: override.syncIdentifier.uuidString, completionMessage: nil), ++ notificationJSON: notification ++ ) + } +- +- guard await !recentNotifications.isDuplicate(remoteNotification) else { +- // Duplicate notifications are expected after app is force killed +- // https://github.com/LoopKit/Loop/issues/2174 +- return ++ ++ // Send APNS success response back to caregiver ++ if let returnInfo = returnInfo { ++ await RemoteNotificationResponseManager.shared.sendResponseNotification( ++ to: returnInfo, ++ commandType: commandType, ++ success: true, ++ message: successMessage ++ ) + } +- try commandValidator.validate(remoteNotification: remoteNotification) +- try await delegate.commandSourceV1(self, handleAction: remoteNotification.toRemoteAction(), remoteNotification: remoteNotification) + } catch { + log.error("Remote Notification: %{public}@. Error: %{public}@", String(describing: notification), String(describing: error)) +- try? await self.delegate?.commandSourceV1(self, uploadError: error, notification: notification) ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .failure(date: Date(), errorMessage: error.localizedDescription), ++ notificationJSON: notification ++ ) ++ ++ // Send APNS failure response back to caregiver ++ if let returnInfo = returnInfo { ++ await RemoteNotificationResponseManager.shared.sendResponseNotification( ++ to: returnInfo, ++ commandType: commandType, ++ success: false, ++ message: "Command failed: \(error.localizedDescription)" ++ ) ++ } ++ } ++ } ++ ++ func handleEnactmentCompletion( ++ remoteNotification: RemoteNotification, ++ status: RemoteNotificationStatus, ++ notificationJSON: [String: AnyObject] ++ ) async { ++ do { ++ let storedNotification = try await recentNotifications.updateStatus(status, for: remoteNotification.id) ++ await uploadStoredNotification(storedNotification) ++ } catch { ++ log.error("Remote Notification: %{public}@. Error: %{public}@", String(describing: notificationJSON), String(describing: error)) ++ } ++ } ++ ++ func uploadStoredNotification(_ storedNotification: StoredRemoteNotification) async { ++ do { ++ switch storedNotification.status { ++ case .success(_, _, let completionMessage): ++ if let completionMessage { ++ // Store adjustments as an error note to Nightscout ++ // try await self.delegate?.commandSourceV1(self, uploadError: completionMessage, receivedDate: storedNotification.receivedDate, notification: storedNotification.notificationJSON()) ++ } ++ case .failure(_, let errorMessage): ++ try await self.delegate?.commandSourceV1(self, uploadError: errorMessage, receivedDate: storedNotification.receivedDate, notification: storedNotification.notificationJSON()) ++ case .none: ++ return ++ } ++ try await recentNotifications.updateUploadStatus(true, for: storedNotification.pushIdentifier) ++ } catch { ++ log.error("Remote Notification: %{public}@. Error: %{public}@", String(describing: storedNotification), String(describing: error)) ++ } ++ } ++ ++ func notificationHistory() async -> [StoredRemoteNotification] { ++ return await recentNotifications.notifications ++ } ++ ++ /// Uploads pending notifications. Limited to a few at a time to avoid long background delays. ++ func uploadPendingNotifications() async { ++ guard let mostRecentPendingNotification = await notificationHistory().filter({$0.isPendingUpload}).sorted(by: {$0.receivedDate > $1.receivedDate}).last else { ++ return ++ } ++ await uploadStoredNotification(mostRecentPendingNotification) ++ } ++ ++ func notificationPublisher() async -> AsyncStream<[StoredRemoteNotification]> { ++ return await recentNotifications.notificationPublisher() ++ } ++ ++ func deleteNotificationHistory() { ++ Task { ++ await recentNotifications.deleteNotificationHistory() + } + } + } + + protocol RemoteCommandSourceV1Delegate: AnyObject { +- func commandSourceV1(_: RemoteCommandSourceV1, handleAction action: Action, remoteNotification: RemoteNotification) async throws +- func commandSourceV1(_: RemoteCommandSourceV1, uploadError error: Error, notification: [String: AnyObject]) async throws ++ func commandSourceV1(_: RemoteCommandSourceV1, uploadError errorMessage: String, receivedDate: Date, notification: [String: AnyObject]) async throws + } + +-private actor RecentNotifications { +- private var recentNotifications = [RemoteNotification]() +- +- func isDuplicate(_ remoteNotification: RemoteNotification) -> Bool { +- if recentNotifications.contains(where: {remoteNotification.id == $0.id}) { ++// MARK: Notification history ++ ++public class StoredRemoteNotification: NSObject, Codable { ++ public let remoteNotificationType: RemoteNotificationType ++ public let notificationJSONData: Data ++ public var status: RemoteNotificationStatus? = nil ++ public var receivedDate: Date ++ public var uploaded: Bool = false ++ ++ init(notificationType: RemoteNotificationType, notificationJSONData: Data) { ++ self.remoteNotificationType = notificationType ++ self.notificationJSONData = notificationJSONData ++ self.receivedDate = Date() ++ self.uploaded = false ++ } ++ ++ convenience init(bolusNotification: BolusRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .bolus(bolusNotification), notificationJSONData: notificationJSONData) ++ } ++ ++ convenience init(carbNotification: CarbRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .carbs(carbNotification), notificationJSONData: notificationJSONData) ++ } ++ ++ convenience init(profileSwitchNotification: ProfileSwitchRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .profileSwitch(profileSwitchNotification), notificationJSONData: notificationJSONData) ++ } ++ ++ convenience init(overrideNotification: OverrideRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .override(overrideNotification), notificationJSONData: notificationJSONData) ++ } ++ ++ convenience init(overrideCancelNotification: OverrideCancelRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .overrideCancel(overrideCancelNotification), notificationJSONData: notificationJSONData) ++ } ++ ++ public var pushIdentifier: String { ++ return remoteNotification().id ++ } ++ ++ var isPendingUpload: Bool { ++ guard !uploaded else { ++ return false ++ } ++ switch status { ++ case .success, .failure: + return true ++ case nil: ++ return false ++ } ++ } ++ ++ func containsDose(_ dose: DoseEntry) -> Bool { ++ guard case let .bolus(bolusNotification) = remoteNotificationType else { ++ return false ++ } ++ ++ if case let .success(_, syncIdentifier: syncIdentifier, _) = status { ++ return dose.syncIdentifier == syncIdentifier ++ } ++ ++ // If sync identifier not set yet, that could mean either a failure occurred ++ // or we are in the middle of processing this notification. ++ // Doses start uploading during remote bolus action, ++ // before syncIdentifier is set to StoredRemoteAction. ++ // Heuristics are used to match the dose. ++ guard isDateWithinEnactmentPeriod(dose.startDate) else { ++ return false + } +- recentNotifications.append(remoteNotification) +- return false ++ ++ return dose.programmedUnits <= bolusNotification.amount ++ } ++ ++ func isDateWithinEnactmentPeriod(_ date: Date) -> Bool { ++ guard date.isAfterOrEqual(otherDate: receivedDate) else { ++ return false ++ } ++ ++ guard let completionDate else { ++ // Either enactment is in progress or there was an app crash during enactment ++ // For the corner case of an app crash, we want only want to match if the ++ // received date is within a few minutes. It should be within seconds really ++ // but minutes help when paused in the Xcode debugger. ++ return date.timeIntervalSince(receivedDate) < 60 * 5 ++ } ++ ++ guard date.isBeforeOrEqual(otherDate: completionDate) else { ++ return false ++ } ++ ++ return true ++ } ++ ++ var completionDate: Date? { ++ guard let status else { ++ return nil ++ } ++ switch status { ++ case .success(let completionDate, _, _): ++ return completionDate ++ case .failure(let completionDate, _): ++ return completionDate ++ } ++ } ++ ++ func remoteNotification() -> RemoteNotification { ++ switch remoteNotificationType { ++ case let .bolus(bolusNotification): ++ return bolusNotification ++ case let .carbs(carbNotification): ++ return carbNotification ++ case let .profileSwitch(profileSwitchNotification): ++ return profileSwitchNotification ++ case let .override(overrideNotification): ++ return overrideNotification ++ case let .overrideCancel(overrideCancelNotification): ++ return overrideCancelNotification ++ } ++ } ++ ++ public func remoteAction() -> Action { ++ return remoteNotification().toRemoteAction() ++ } ++ ++ func notificationJSON() throws -> [String: AnyObject] { ++ let jsonObject = try JSONSerialization.jsonObject(with: notificationJSONData, options: []) ++ guard let notificationJSON = jsonObject as? [String: AnyObject] else { ++ throw StoredRemoteNotificationError.notificationJSONTypeIncorrect ++ } ++ return notificationJSON ++ } ++ ++ enum StoredRemoteNotificationError: Error { ++ case notificationJSONTypeIncorrect ++ } ++ ++ public enum RemoteNotificationType: Codable { ++ case bolus(BolusRemoteNotification) ++ case carbs(CarbRemoteNotification) ++ case override(OverrideRemoteNotification) ++ case overrideCancel(OverrideCancelRemoteNotification) ++ case profileSwitch(ProfileSwitchRemoteNotification) ++ } ++} ++ ++public enum RemoteNotificationStatus: Codable, Equatable { ++ case success(date: Date, syncIdentifier: String, completionMessage: String?) ++ case failure(date: Date, errorMessage: String) ++} ++ ++actor RecentNotifications { ++ var notifications: [StoredRemoteNotification] = [] ++ private var continuation: AsyncStream<[StoredRemoteNotification]>.Continuation? ++ ++ init() { ++ Task { ++ await loadNotifications() ++ } ++ } ++ ++ // Publish ++ ++ func notificationPublisher() -> AsyncStream<[StoredRemoteNotification]> { ++ return AsyncStream { continuation in ++ self.continuation = continuation ++ continuation.yield(notifications) ++ } ++ } ++ ++ private func publish(notifications: [StoredRemoteNotification]) { ++ self.notifications = notifications ++ continuation?.yield(notifications) ++ } ++ ++ // Misc ++ ++ func contains(pushIdentifier: String) -> Bool { ++ return storedNotification(for: pushIdentifier) != nil ++ } ++ ++ func storedNotification(for id: String) -> StoredRemoteNotification? { ++ return notifications.first(where: {$0.pushIdentifier == id}) ++ } ++ ++ func trackReceivedRemoteNotification(_ remoteNotification: RemoteNotification, rawNotification: [String : AnyObject]) throws { ++ let data = try JSONSerialization.data(withJSONObject: rawNotification, options: []) ++ if let bolusNotification = remoteNotification as? BolusRemoteNotification { ++ let storedNotification = StoredRemoteNotification(bolusNotification: bolusNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) ++ } else if let carbNotification = remoteNotification as? CarbRemoteNotification { ++ let storedNotification = StoredRemoteNotification(carbNotification: carbNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) ++ } else if let profileSwitchNotification = remoteNotification as? ProfileSwitchRemoteNotification { ++ let storedNotification = StoredRemoteNotification(profileSwitchNotification: profileSwitchNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) ++ } else if let overrideNotification = remoteNotification as? OverrideRemoteNotification { ++ let storedNotification = StoredRemoteNotification(overrideNotification: overrideNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) ++ } else if let overrideCancelNotification = remoteNotification as? OverrideCancelRemoteNotification { ++ let storedNotification = StoredRemoteNotification(overrideCancelNotification: overrideCancelNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) ++ } else { ++ fatalError() ++ } ++ } ++ ++ func updateStatus(_ status: RemoteNotificationStatus, for pushIdentifier: String) throws -> StoredRemoteNotification { ++ guard let storedNotification = storedNotification(for: pushIdentifier) else { ++ throw RemoteNotificationError.notificationNotFound(pushIdentifier) ++ } ++ storedNotification.status = status ++ try storeNotification(storedNotification) ++ return storedNotification ++ } ++ ++ func updateUploadStatus(_ uploaded: Bool, for pushIdentifier: String) throws { ++ guard let storedNotification = storedNotification(for: pushIdentifier) else { ++ throw RemoteNotificationError.notificationNotFound(pushIdentifier) ++ } ++ storedNotification.uploaded = uploaded ++ try storeNotification(storedNotification) ++ } ++ ++ // Disk Storage ++ ++ static let maxStoredNotifications = 50 ++ ++ static var storageURL: URL { ++ let appSupportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! ++ return appSupportDir.appendingPathComponent("remote_notifications.json") ++ } ++ ++ private func storeNotification(_ notification: StoredRemoteNotification) throws { ++ if let existingIndex = notifications.firstIndex(where: {$0.pushIdentifier == notification.pushIdentifier}) { ++ notifications[existingIndex] = notification ++ } else { ++ notifications.append(notification) ++ } ++ ++ if notifications.count > Self.maxStoredNotifications { ++ notifications = Array(notifications.suffix(Self.maxStoredNotifications)) ++ } ++ ++ try saveNotifications() ++ publish(notifications: notifications) ++ } ++ ++ private func saveNotifications() throws { ++ let encoder = JSONEncoder() ++ encoder.dateEncodingStrategy = .iso8601 ++ let data = try encoder.encode(notifications) ++ try data.write(to: Self.storageURL) ++ } ++ ++ private func loadNotifications() { ++ let decoder = JSONDecoder() ++ decoder.dateDecodingStrategy = .iso8601 ++ guard let data = try? Data(contentsOf: Self.storageURL) else { ++ return ++ } ++ guard let loaded = try? decoder.decode([StoredRemoteNotification].self, from: data) else { ++ return ++ } ++ self.notifications = loaded ++ } ++ ++ func deleteNotificationHistory() { ++ notifications = [] ++ try? saveNotifications() ++ publish(notifications: notifications) ++ } ++} ++ ++enum RemoteNotificationError: LocalizedError { ++ case notificationNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .notificationNotFound(let id): ++ return "Notification not found: \(id)" ++ } ++ } ++} ++ ++extension Date { ++ func isBeforeOrEqual(otherDate: Date) -> Bool { ++ return self <= otherDate ++ } ++ ++ func isAfterOrEqual(otherDate: Date) -> Bool { ++ return self >= otherDate + } + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +index 634eb4e..fce4623 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +@@ -23,13 +23,13 @@ struct RemoteCommandValidator { + } + + private func validateExpirationDate(remoteNotification: RemoteNotification) throws { +- ++ + guard let expirationDate = remoteNotification.expiration else { + return //Skip validation if no date included + } +- ++ + if nowDateSource() > expirationDate { +- throw NotificationValidationError.expiredNotification ++ throw NotificationValidationError.expiredNotification(sentDate: remoteNotification.sentAt, receivedDate: nowDateSource()) + } + } + +@@ -44,14 +44,16 @@ struct RemoteCommandValidator { + + enum NotificationValidationError: LocalizedError { + case missingOTP +- case expiredNotification +- ++ case expiredNotification(sentDate: Date, receivedDate: Date) ++ + var errorDescription: String? { + switch self { + case .missingOTP: + return LocalizedString("Missing OTP", comment: "Remote command error description: Missing OTP.") +- case .expiredNotification: +- return LocalizedString("Expired", comment: "Remote command error description: expired.") ++ case .expiredNotification(let sentDate, let receivedDate): ++ let dateFormatter = DateFormatter() ++ dateFormatter.dateFormat = "h:mm:ss a" ++ return String(format: LocalizedString("Expired. Sent: %@. Received: %@", comment: "Remote command error description: expired."), dateFormatter.string(from: sentDate), dateFormatter.string(from: receivedDate)) + } + } + } +diff --git a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +index 3d54080..4ce6ab7 100644 +--- a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift ++++ b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +@@ -14,6 +14,9 @@ import LoopKit + protocol ServiceStatusViewModelDelegate { + func verifyConfiguration(completion: @escaping (Error?) -> Void) + var siteURL: URL? { get } ++ func notificationHistory() async -> [StoredRemoteNotification] ++ func notificationPublisher() async -> AsyncStream<[StoredRemoteNotification]> ++ func deleteNotificationHistory() + } + + enum ServiceStatus { +@@ -35,19 +38,92 @@ extension ServiceStatus: CustomStringConvertible { + } + } + ++struct RemoteCommand: Identifiable { ++ let id: String ++ let actionName: String ++ let createdDate: String ++ let details: String ++ let statusMessage: String ++ let isError: Bool ++} ++ ++extension StoredRemoteNotification { ++ var actionName: String { ++ switch remoteNotificationType { ++ case .bolus: ++ return "Bolus" ++ case .carbs: ++ return "Carbs" ++ case .override: ++ return "Override" ++ case .profileSwitch: ++ return "Profile Switch" ++ case .overrideCancel: ++ return "Cancel Override" ++ } ++ } ++ ++ var createdDateDescription: String { ++ let formatter = DateFormatter() ++ formatter.dateStyle = .short ++ formatter.timeStyle = .medium ++ return formatter.string(from: receivedDate) ++ } ++ ++ var details: String { ++ switch remoteNotificationType { ++ case .bolus(let notification): ++ return String(format: "%.2f U", notification.amount) ++ case .carbs(let notification): ++ return String(format: "%.0f g", notification.amount) ++ case .override(let notification): ++ return notification.name ++ case .profileSwitch(let notification): ++ return notification.profileName ++ case .overrideCancel: ++ return "" ++ } ++ } ++ ++ var statusMessage: String { ++ guard let status else { ++ return "Pending..." ++ } ++ switch status { ++ case .success(_, _, let completionMessage): ++ return completionMessage ?? "Success" ++ case .failure(_, let errorMessage): ++ return errorMessage ++ } ++ } ++ ++ var isError: Bool { ++ guard let status else { ++ return false ++ } ++ switch status { ++ case .success: ++ return false ++ case .failure: ++ return true ++ } ++ } ++} ++ + class ServiceStatusViewModel: ObservableObject { + @Published var status: ServiceStatus = .checking +- ++ @Published var remoteCommands: [RemoteCommand] = [] ++ + let delegate: ServiceStatusViewModelDelegate + var didLogout: (() -> Void)? +- ++ + var urlString: String { + return delegate.siteURL?.absoluteString ?? LocalizedString("Not Available", comment: "Error when nightscout service url is not set") + } + + init(delegate: ServiceStatusViewModelDelegate) { + self.delegate = delegate +- ++ + delegate.verifyConfiguration { (error) in + DispatchQueue.main.async { + if let error = error { +@@ -57,5 +133,24 @@ class ServiceStatusViewModel: ObservableObject { + } + } + } ++ ++ Task { ++ let stream = await delegate.notificationPublisher() ++ for await notifications in stream { ++ let commands = notifications.reversed().map { notification in ++ RemoteCommand( ++ id: notification.pushIdentifier, ++ actionName: notification.actionName, ++ createdDate: notification.createdDateDescription, ++ details: notification.details, ++ statusMessage: notification.statusMessage, ++ isError: notification.isError ++ ) ++ } ++ await MainActor.run { ++ self.remoteCommands = commands ++ } ++ } ++ } + } + } +diff --git a/NightscoutService/NightscoutServiceKitUI/Views/ServiceStatusView.swift b/NightscoutService/NightscoutServiceKitUI/Views/ServiceStatusView.swift +index 4ecbdde..f213ccb 100644 +--- a/NightscoutService/NightscoutServiceKitUI/Views/ServiceStatusView.swift ++++ b/NightscoutService/NightscoutServiceKitUI/Views/ServiceStatusView.swift +@@ -17,56 +17,86 @@ struct ServiceStatusView: View, HorizontalSizeClassOverride { + @ObservedObject var otpViewModel: OTPViewModel + @State private var selectedItem: String? + var body: some View { +- VStack { +- Text("Nightscout") +- .font(.largeTitle) +- .fontWeight(.semibold) +- Image(frameworkImage: "nightscout", decorative: true) +- .resizable() +- .aspectRatio(contentMode: .fill) +- .frame(width: 150, height: 150) +- ++ List { ++ Section { ++ VStack { ++ Text("Nightscout") ++ .font(.largeTitle) ++ .fontWeight(.semibold) ++ Image(frameworkImage: "nightscout", decorative: true) ++ .resizable() ++ .aspectRatio(contentMode: .fill) ++ .frame(width: 150, height: 150) ++ } ++ .frame(maxWidth: .infinity) ++ .listRowBackground(Color.clear) ++ } + +- VStack(spacing: 0) { ++ Section { + HStack { + Text("URL") + Spacer() + Text(viewModel.urlString) + } +- .padding() +- Divider() + HStack { + Text("Status") + Spacer() + Text(String(describing: viewModel.status)) + } +- .padding() +- Divider() + NavigationLink(destination: OTPSelectionView(otpViewModel: otpViewModel), tag: "otp-view", selection: $selectedItem) { + HStack { + Text("One-Time Password") + Spacer() + Text(otpViewModel.otpCode) +- Image(systemName: "chevron.right") +- .font(.caption) + } +- }.foregroundColor(Color.primary) +- .padding() ++ } + } +- .background(Color(UIColor.secondarySystemBackground)) +- .cornerRadius(10) +- +- Button(action: { +- viewModel.didLogout?() +- } ) { +- Text("Logout").padding(.top, 20) ++ ++ Section(header: Text("Remote Commands")) { ++ if viewModel.remoteCommands.isEmpty { ++ Text("No remote commands received") ++ .foregroundColor(.secondary) ++ } else { ++ ForEach(viewModel.remoteCommands) { command in ++ VStack(alignment: .leading, spacing: 4) { ++ HStack { ++ Text(command.actionName) ++ .fontWeight(.medium) ++ if !command.details.isEmpty { ++ Text(command.details) ++ .foregroundColor(.secondary) ++ } ++ Spacer() ++ Text(command.createdDate) ++ .font(.caption) ++ .foregroundColor(.secondary) ++ } ++ Text(command.statusMessage) ++ .font(.caption) ++ .foregroundColor(command.isError ? .red : .secondary) ++ } ++ } ++ } ++ Button(role: .destructive) { ++ viewModel.delegate.deleteNotificationHistory() ++ } label: { ++ Text("Remove History") ++ } ++ } ++ ++ Section { ++ Button(action: { ++ viewModel.didLogout?() ++ } ) { ++ Text("Logout") ++ .foregroundColor(.red) ++ } + } + } +- .padding([.leading, .trailing]) + .navigationBarTitle("") + .navigationBarItems(trailing: dismissButton) + } +- ++ + private var dismissButton: some View { + Button(action: dismiss) { + Text("Done").bold() +diff --git a/remote_window.loopcustomization b/remote_window.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/remote_window/nextdev_remote_window_remote_carb_edit_remote_profile.patch b/remote_window/nextdev_remote_window_remote_carb_edit_remote_profile.patch new file mode 100644 index 0000000..94212b4 --- /dev/null +++ b/remote_window/nextdev_remote_window_remote_carb_edit_remote_profile.patch @@ -0,0 +1,625 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift +index fe3c3e7f..0b8248c2 100644 +--- a/Loop/Loop/Managers/LoopAppManager.swift ++++ b/Loop/Loop/Managers/LoopAppManager.swift +@@ -1071,8 +1071,42 @@ extension LoopAppManager: ResetLoopManagerDelegate { + // MARK: - ServicesManagerDosingDelegate + + extension LoopAppManager: ServicesManagerDosingDelegate { +- func deliverBolus(amountInUnits: Double, decisionId: UUID?) async throws { +- try await deviceDataManager.enactBolus(units: amountInUnits, decisionId: decisionId, activationType: .manualNoRecommendation) ++ func deliverBolus(amountInUnits: Double, decisionId: UUID?, userCreatedDate: Date) async throws -> Double { ++ // Guard B (double-bolus protection): subtract any bolus delivered within ++ // minTreatmentIntervalInMinutes of the command's userCreatedDate from the requested ++ // amount (look back further for safety in case of upload delays), refusing entirely ++ // if the remainder nets to zero. Returns the amount actually delivered. ++ let minTreatmentIntervalInMinutes = 10.0 ++ let boluses = try await doseStore.getBoluses(start: Date().addingTimeInterval(-.days(1))) ++ let conflictStartDate = userCreatedDate.addingTimeInterval(-.minutes(minTreatmentIntervalInMinutes)) ++ let conflictingBolusAmount = boluses.filter({ $0.startDate >= conflictStartDate }).map({ $0.programmedUnits }).reduce(0, +) ++ let adjustedDoseAmount = amountInUnits - conflictingBolusAmount ++ guard adjustedDoseAmount <= amountInUnits else { // sanity check on the bolus math ++ throw BolusActionError.invalidCalculation ++ } ++ guard adjustedDoseAmount > 0.0 else { ++ throw BolusActionError.bolusAmountAdjustedToZero(amountInUnits, conflictStartDate) ++ } ++ try await deviceDataManager.enactBolus(units: adjustedDoseAmount, decisionId: decisionId, activationType: .manualNoRecommendation) ++ return adjustedDoseAmount ++ } ++ ++ enum BolusActionError: LocalizedError { ++ case bolusAmountAdjustedToZero(_ requestedAmountInUnits: Double, _ conflictStartDate: Date) ++ case invalidCalculation ++ ++ var errorDescription: String? { ++ switch self { ++ case .bolusAmountAdjustedToZero(let amountInUnits, let conflictStartDate): ++ let numberFormatter = NumberFormatter() ++ numberFormatter.numberStyle = .decimal ++ numberFormatter.maximumFractionDigits = 2 ++ let treatmentAmountFormatted = (numberFormatter.string(from: NSNumber(value: amountInUnits)) ?? "") + " U" ++ return String(format: NSLocalizedString("All boluses delivered after %1$@ were subtracted from the requested bolus amount for safety. Those amount(s) exceed the requested amount of %2$@ so the remote bolus was not delivered.", comment: "Bolus error description: no remote bolus delivered."), conflictStartDate.formatted(date: .omitted, time: .complete), treatmentAmountFormatted) ++ case .invalidCalculation: ++ return NSLocalizedString("An invalid bolus calculation occurred.", comment: "Bolus error description: invalid calculation.") ++ } ++ } + } + } + +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index 61cdb5c0..feb53338 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -1415,11 +1415,27 @@ extension LoopDataManager: ServicesManagerDelegate { + + //Carb Entry + +- func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { ++ func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws { + let absorptionTime = absorptionTime ?? LoopCoreConstants.defaultCarbAbsorptionTimes.medium + let startDate = startDate ?? now + try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) + ++ // Guard B (conflicting-treatment protection): reject remote carbs if any carb entry was ++ // created within minTreatmentIntervalInMinutes of the command's userCreatedDate. Look back ++ // a little further for safety (e.g. upload delays); zero tolerance for carbs. ++ let maxAllowedConflictingAmountInGrams = 0.0 ++ let minTreatmentIntervalInMinutes = 10.0 ++ let entries = try await carbStore.getCarbEntries(start: Date().addingTimeInterval(-.days(1)), end: Date().addingTimeInterval(.days(1))) ++ let conflictStartDate = userCreatedDate.addingTimeInterval(-.minutes(minTreatmentIntervalInMinutes)) ++ let conflictingEntries = entries.filter({ entry in ++ let createdDate = entry.userCreatedDate ?? entry.startDate ++ return createdDate >= conflictStartDate ++ }) ++ let conflictingCarbAmount = conflictingEntries.map({ $0.quantity.doubleValue(for: .gram) }).reduce(0, +) ++ guard conflictingCarbAmount <= maxAllowedConflictingAmountInGrams else { ++ throw CarbActionError.conflictingTreatments(conflictingCarbAmount, minTreatmentIntervalInMinutes) ++ } ++ + let quantity = LoopQuantity(unit: .gram, doubleValue: amountInGrams) + let candidateCarbEntry = NewCarbEntry(syncIdentifier: UUID().uuidString, quantity: quantity, startDate: startDate, foodType: foodType, absorptionTime: absorptionTime) + +@@ -1485,9 +1501,14 @@ extension LoopDataManager: ServicesManagerDelegate { + case invalidCarbs + case entryNotFound + case entryNotEditable +- ++ case conflictingTreatments(_ conflictingAmountInGrams: Double, _ minutesRequiredBetweenTreatments: TimeInterval) ++ + var errorDescription: String? { + switch self { ++ case .conflictingTreatments(let conflictingAmountInGrams, let minutesRequiredBetweenTreatments): ++ let treatmentAmountFormatted = (Self.numberFormatter.string(from: NSNumber(value: conflictingAmountInGrams)) ?? "") + " g" ++ let minutesRequiredBetweenTreatmentsFormatted = Self.numberFormatter.string(from: NSNumber(value: minutesRequiredBetweenTreatments)) ?? "" ++ return String(format: NSLocalizedString("Conflicting carb treatments (%1$@) have occurred. At least %2$@ minutes must pass between carb entries.", comment: "Carb error description: conflicting treatments occurred."), treatmentAmountFormatted, minutesRequiredBetweenTreatmentsFormatted) + case .exceedsMaxCarbs: + return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Carb error description: carbs exceed maximum amount.") + case .invalidCarbs: +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index ffafea70..65a47001 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -245,13 +245,13 @@ class ServicesManager { + } + + public protocol ServicesManagerDosingDelegate: AnyObject { +- func deliverBolus(amountInUnits: Double, decisionId: UUID?) async throws ++ func deliverBolus(amountInUnits: Double, decisionId: UUID?, userCreatedDate: Date) async throws -> Double + } + + public protocol ServicesManagerDelegate: AnyObject { + func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws + func cancelCurrentOverride() async throws +- func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws ++ func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws + func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry + func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry + } +@@ -327,9 +327,10 @@ extension ServicesManager: ServiceDelegate { + await remoteDataServicesManager.performUpload(for: .overrides) + } + +- func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { ++ func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws { + do { +- try await servicesManagerDelegate?.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) ++ guard let servicesManagerDelegate else { throw BolusActionError.internalError } ++ try await servicesManagerDelegate.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate, userCreatedDate: userCreatedDate) + NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: amountInGrams) + await remoteDataServicesManager.performUpload(for: .carb) + analyticsServicesManager.didAddCarbs(source: "Remote") +@@ -381,25 +382,30 @@ extension ServicesManager: ServiceDelegate { + } + } + +- func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?) async throws { ++ func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?, userCreatedDate: Date) async throws -> Double { + do { +- ++ + guard amountInUnits > 0 else { + throw BolusActionError.invalidBolus + } +- ++ + guard let maxBolusAmount = settingsManager.loopSettings.maximumBolus else { + throw BolusActionError.missingMaxBolus + } +- ++ + guard amountInUnits <= maxBolusAmount else { + throw BolusActionError.exceedsMaxBolus + } +- +- try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits, decisionId: decisionId) +- NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) ++ ++ guard let servicesManagerDosingDelegate else { ++ throw BolusActionError.internalError ++ } ++ // May be reduced by the conflicting-treatment guard; report the amount actually delivered. ++ let deliveredUnits = try await servicesManagerDosingDelegate.deliverBolus(amountInUnits: amountInUnits, decisionId: decisionId, userCreatedDate: userCreatedDate) ++ NotificationManager.sendRemoteBolusNotification(amount: deliveredUnits) + await remoteDataServicesManager.performUpload(for: .dose) + analyticsServicesManager.didBolus(source: "Remote") ++ return deliveredUnits + } catch { + NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) + throw error +@@ -411,7 +417,8 @@ extension ServicesManager: ServiceDelegate { + case invalidBolus + case missingMaxBolus + case exceedsMaxBolus +- ++ case internalError ++ + var errorDescription: String? { + switch self { + case .invalidBolus: +@@ -420,6 +427,8 @@ extension ServicesManager: ServiceDelegate { + return NSLocalizedString("Missing maximum allowed bolus in settings", comment: "Bolus error description: missing maximum bolus in settings.") + case .exceedsMaxBolus: + return NSLocalizedString("Exceeds maximum allowed bolus in settings", comment: "Bolus error description: bolus exceeds maximum bolus in settings.") ++ case .internalError: ++ return NSLocalizedString("An internal error occurred.", comment: "Bolus error description: internal error.") + } + } + } +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 5f1f37f3..39c0eee9 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -17,8 +17,8 @@ public protocol RemoteProfileActionDelegate: AnyObject { + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws + func cancelRemoteOverride() async throws +- func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws +- func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?) async throws ++ func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws ++ func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?, userCreatedDate: Date) async throws -> Double + func deleteRemoteCarbs(syncIdentifier: String) async throws + func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws + } +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index 3a513fb..64a668c 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -14,6 +14,7 @@ public enum NightscoutServiceError: Error { + case incompatibleTherapySettings + case missingCredentials + case missingCommandSource ++ case missingServiceDelegate + } + + +@@ -66,10 +67,12 @@ public final class NightscoutService: Service { + + private let log = OSLog(category: "NightscoutService") + ++ private let notificationExpirationInMinutes = 10.0 ++ + public init() { + self.isOnboarded = false + self.lockedObjectIdCache = Locked(ObjectIdCache()) +- self.otpManager = OTPManager(secretStore: KeychainManager()) ++ self.otpManager = OTPManager(secretStore: KeychainManager(), maxMinutesValid: notificationExpirationInMinutes) + self.commandSourceV1 = RemoteCommandSourceV1(otpManager: otpManager) + self.commandSourceV1.delegate = self + } +@@ -85,7 +88,7 @@ public final class NightscoutService: Service { + self.lockedObjectIdCache = Locked(ObjectIdCache()) + } + +- self.otpManager = OTPManager(secretStore: KeychainManager()) ++ self.otpManager = OTPManager(secretStore: KeychainManager(), maxMinutesValid: notificationExpirationInMinutes) + self.commandSourceV1 = RemoteCommandSourceV1(otpManager: otpManager) + self.commandSourceV1.delegate = self + +@@ -422,6 +425,14 @@ extension NightscoutService: RemoteDataService { + + + public func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async throws { ++ var notification = notification ++ // Widen the acceptance window: override the expiration to sent-at + notificationExpirationInMinutes. ++ // Ideally this would be done in Nightscout instead. ++ if let sentDateString = notification["sent-at"] as? String, ++ let sentDate = DateFormatter.iso8601DateDecoder.date(from: sentDateString) { ++ let expirationDate = sentDate.addingTimeInterval(60 * notificationExpirationInMinutes) ++ notification["expiration"] = DateFormatter.iso8601DateDecoder.string(from: expirationDate) as AnyObject ++ } + let commandSource = try commandSource(notification: notification) + await commandSource.remoteNotificationWasReceived(notification) + } +@@ -449,10 +460,13 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + var syncIdentifier: String? = nil + + do { ++ guard let serviceDelegate = self.serviceDelegate else { ++ throw NightscoutServiceError.missingServiceDelegate ++ } + switch action { + case .temporaryScheduleOverride(let overrideCommand): + commandType = .override +- try await self.serviceDelegate?.enactRemoteOverride( ++ try await serviceDelegate.enactRemoteOverride( + name: overrideCommand.name, + durationTime: overrideCommand.durationTime, + remoteAddress: overrideCommand.remoteAddress +@@ -480,23 +494,28 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + + case .cancelTemporaryOverride: + commandType = .cancelOverride +- try await self.serviceDelegate?.cancelRemoteOverride() ++ try await serviceDelegate.cancelRemoteOverride() + success = true + message = "Override cancelled successfully" + + case .bolusEntry(let bolusCommand): + commandType = .bolus +- try await self.serviceDelegate?.deliverRemoteBolus(amountInUnits: bolusCommand.amountInUnits, decisionId: nil) ++ let deliveredUnits = try await serviceDelegate.deliverRemoteBolus(amountInUnits: bolusCommand.amountInUnits, decisionId: nil, userCreatedDate: bolusCommand.userCreatedDate) + success = true +- message = String(format: "Bolus of %.2f units delivered successfully", bolusCommand.amountInUnits) ++ if deliveredUnits < bolusCommand.amountInUnits { ++ message = String(format: "Bolus reduced to %.2f units (from %.2f) due to other recent treatments", deliveredUnits, bolusCommand.amountInUnits) ++ } else { ++ message = String(format: "Bolus of %.2f units delivered successfully", deliveredUnits) ++ } + + case .carbsEntry(let carbCommand): + commandType = .carbs +- try await self.serviceDelegate?.deliverRemoteCarbs( ++ try await serviceDelegate.deliverRemoteCarbs( + amountInGrams: carbCommand.amountInGrams, + absorptionTime: carbCommand.absorptionTime, + foodType: carbCommand.foodType, +- startDate: carbCommand.startDate ++ startDate: carbCommand.startDate, ++ userCreatedDate: carbCommand.userCreatedDate + ) + success = true + message = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) +@@ -504,14 +523,14 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + case .carbsDelete(let deleteCommand): + commandType = .carbsDelete + syncIdentifier = deleteCommand.syncIdentifier +- try await self.serviceDelegate?.deleteRemoteCarbs(syncIdentifier: deleteCommand.syncIdentifier) ++ try await serviceDelegate.deleteRemoteCarbs(syncIdentifier: deleteCommand.syncIdentifier) + success = true + message = "Carb entry deleted successfully" + + case .carbsEdit(let editCommand): + commandType = .carbsEdit + syncIdentifier = editCommand.syncIdentifier +- try await self.serviceDelegate?.replaceRemoteCarbs( ++ try await serviceDelegate.replaceRemoteCarbs( + syncIdentifier: editCommand.syncIdentifier, + amountInGrams: editCommand.amountInGrams, + absorptionTime: editCommand.absorptionTime, +diff --git a/NightscoutService/NightscoutServiceKit/OTPManager.swift b/NightscoutService/NightscoutServiceKit/OTPManager.swift +index f558a46..921ff02 100644 +--- a/NightscoutService/NightscoutServiceKit/OTPManager.swift ++++ b/NightscoutService/NightscoutServiceKit/OTPManager.swift +@@ -77,8 +77,19 @@ public class OTPManager { + let maxOTPsToAccept: Int + + public static var defaultTokenPeriod: TimeInterval = 30 +- public static var defaultMaxOTPsToAccept = 2 +- ++ public static var defaultMaxOTPsToAccept = 30 ++ ++ public init(secretStore: OTPSecretStore = KeychainManager(), nowDateSource: @escaping () -> Date = {Date()}, maxMinutesValid: Double) { ++ self.secretStore = secretStore ++ self.nowDateSource = nowDateSource ++ self.tokenPeriod = OTPManager.defaultTokenPeriod ++ let secondsValid = 60.0 * maxMinutesValid ++ self.maxOTPsToAccept = Int(secondsValid / OTPManager.defaultTokenPeriod) ++ if secretStore.tokenSecretKey() == nil || secretStore.tokenSecretKeyName() == nil { ++ resetSecretKey() ++ } ++ } ++ + public init(secretStore: OTPSecretStore = KeychainManager(), nowDateSource: @escaping () -> Date = {Date()}, tokenPeriod: TimeInterval = OTPManager.defaultTokenPeriod, maxOTPsToAccept: Int = OTPManager.defaultMaxOTPsToAccept) { + self.secretStore = secretStore + self.nowDateSource = nowDateSource +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift +index 3a56d81..6db615c 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift +@@ -9,10 +9,12 @@ + import Foundation + + public struct BolusAction: Codable { +- ++ + public let amountInUnits: Double +- +- public init(amountInUnits: Double) { ++ public let userCreatedDate: Date ++ ++ public init(amountInUnits: Double, userCreatedDate: Date) { + self.amountInUnits = amountInUnits ++ self.userCreatedDate = userCreatedDate + } + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift +index 3865989..b315b16 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift +@@ -14,11 +14,13 @@ public struct CarbAction: Codable{ + public let absorptionTime: TimeInterval? + public let foodType: String? + public let startDate: Date? +- +- public init(amountInGrams: Double, absorptionTime: TimeInterval? = nil, foodType: String? = nil, startDate: Date? = nil) { ++ public let userCreatedDate: Date ++ ++ public init(amountInGrams: Double, absorptionTime: TimeInterval? = nil, foodType: String? = nil, startDate: Date? = nil, userCreatedDate: Date) { + self.amountInGrams = amountInGrams + self.absorptionTime = absorptionTime + self.foodType = foodType + self.startDate = startDate ++ self.userCreatedDate = userCreatedDate + } + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift +index 1c14cdf..869abc2 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift +@@ -14,11 +14,11 @@ public struct BolusRemoteNotification: RemoteNotification, Codable { + public let amount: Double + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? +- ++ + enum CodingKeys: String, CodingKey { + case remoteAddress = "remote-address" + case amount = "bolus-entry" +@@ -28,9 +28,13 @@ public struct BolusRemoteNotification: RemoteNotification, Codable { + case enteredBy = "entered-by" + case encryptedReturnNotification = "encrypted_return_notification" + } +- ++ + func toRemoteAction() -> Action { +- return .bolusEntry(BolusAction(amountInUnits: amount)) ++ return .bolusEntry(toBolusAction()) ++ } ++ ++ func toBolusAction() -> BolusAction { ++ return BolusAction(amountInUnits: amount, userCreatedDate: sentAt) + } + + func otpValidationRequired() -> Bool { +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift +index 1593a74..eda9134 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift +@@ -11,7 +11,7 @@ public struct CarbDeleteRemoteNotification: RemoteNotification, Codable { + public let syncIdentifier: String + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift +index fde3e4c..f1ffb51 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift +@@ -16,7 +16,7 @@ public struct CarbEditRemoteNotification: RemoteNotification, Codable { + public let startDate: Date + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift +index 6c238ed..c069c34 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift +@@ -17,7 +17,7 @@ public struct CarbRemoteNotification: RemoteNotification, Codable { + public let startDate: Date? + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? +@@ -43,7 +43,7 @@ public struct CarbRemoteNotification: RemoteNotification, Codable { + } + + func toRemoteAction() -> Action { +- let action = CarbAction(amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate) ++ let action = CarbAction(amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate, userCreatedDate: sentAt) + return .carbsEntry(action) + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift +index fdd6d6b..babb71f 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift +@@ -13,7 +13,7 @@ public struct OverrideCancelRemoteNotification: RemoteNotification, Codable { + + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let cancelOverride: String + public let enteredBy: String? + public let otp: String? +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift +index 2060a74..426ff89 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift +@@ -15,7 +15,7 @@ public struct OverrideRemoteNotification: RemoteNotification, Codable { + public let durationInMinutes: Double? + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let enteredBy: String? + public let otp: String? + public let encryptedReturnNotification: String? +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +index 487c620..ebcd6ef 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -16,7 +16,7 @@ public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { + public let fingerprint: String + public let remoteAddress: String + public let expirationDate: Date +- public let sentAt: Date? ++ public let sentAt: Date + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 87f1e92..1ea8b96 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -14,7 +14,7 @@ protocol RemoteNotification: Codable { + + var id: String {get} + var expiration: Date? {get} +- var sentAt: Date? {get} ++ var sentAt: Date {get} + var otp: String? {get} + var remoteAddress: String {get} + var enteredBy: String? {get} +@@ -40,12 +40,8 @@ extension RemoteNotification { + } + + var id: String { +- //There is no unique identifier so we use the sent date when available +- if let sentAt = sentAt { +- return "\(sentAt.timeIntervalSince1970)" +- } else { +- return UUID().uuidString +- } ++ //There is no unique identifier so we use the sent date ++ return "\(sentAt.timeIntervalSince1970)" + } + + func getReturnNotificationInfo() -> ReturnNotificationInfo? { +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +index 21d0e0a..5be6e1c 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +@@ -20,9 +20,6 @@ struct RemoteCommandValidator { + guard remoteNotification.expiration != nil else { + throw NotificationValidationError.missingExpiration + } +- guard remoteNotification.sentAt != nil else { +- throw NotificationValidationError.missingSentAt +- } + } + try validateExpirationDate(remoteNotification: remoteNotification) + if remoteNotification.otpValidationRequired() { +@@ -31,13 +28,13 @@ struct RemoteCommandValidator { + } + + private func validateExpirationDate(remoteNotification: RemoteNotification) throws { +- ++ + guard let expirationDate = remoteNotification.expiration else { + return //Skip validation if no date included + } +- ++ + if nowDateSource() > expirationDate { +- throw NotificationValidationError.expiredNotification ++ throw NotificationValidationError.expiredNotification(sentDate: remoteNotification.sentAt, receivedDate: nowDateSource()) + } + } + +@@ -52,20 +49,19 @@ struct RemoteCommandValidator { + + enum NotificationValidationError: LocalizedError { + case missingOTP +- case expiredNotification ++ case expiredNotification(sentDate: Date, receivedDate: Date) + case missingExpiration +- case missingSentAt +- ++ + var errorDescription: String? { + switch self { + case .missingOTP: + return LocalizedString("Missing OTP", comment: "Remote command error description: Missing OTP.") + case .missingExpiration: + return LocalizedString("Missing expiration", comment: "Remote command error description: missing expiration.") +- case .missingSentAt: +- return LocalizedString("Missing sent-at time", comment: "Remote command error description: missing sent-at.") +- case .expiredNotification: +- return LocalizedString("Expired", comment: "Remote command error description: expired.") ++ case .expiredNotification(let sentDate, let receivedDate): ++ let dateFormatter = DateFormatter() ++ dateFormatter.dateFormat = "h:mm:ss a" ++ return String(format: LocalizedString("Expired. Sent: %@. Received: %@", comment: "Remote command error description: expired."), dateFormatter.string(from: sentDate), dateFormatter.string(from: receivedDate)) + } + } + } +diff --git a/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift +index 6a3aba6..f03768c 100644 +--- a/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift ++++ b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift +@@ -48,20 +48,14 @@ final class CarbDeleteRemoteNotificationTestCase: XCTestCase { + } + } + +- func testValidate_MissingSentAt_Throws() throws { ++ func testParseCarbDeleteNotification_MissingSentAt_Throws() throws { + let notification: [String: AnyObject] = [ + "remote-address": "::ffff:11.2.44.155" as AnyObject, + "expiration": "2099-02-25T20:51:35.778Z" as AnyObject, + "carbs-delete": "abc" as AnyObject, + "otp": "12345" as AnyObject + ] +- let remoteNotification = try notification.toRemoteNotification() +- let validator = RemoteCommandValidator(otpManager: OTPTestCoordinator().createOTPManager()) + +- XCTAssertThrowsError(try validator.validate(remoteNotification: remoteNotification)) { error in +- guard case RemoteCommandValidator.NotificationValidationError.missingSentAt = error else { +- return XCTFail("Unexpected error \(error)") +- } +- } ++ XCTAssertThrowsError(try notification.toRemoteNotification()) + } + } +diff --git a/remote_window.loopcustomization b/remote_window.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/remote_window/nextdev_remote_window_remote_profile.patch b/remote_window/nextdev_remote_window_remote_profile.patch new file mode 100644 index 0000000..33b0bad --- /dev/null +++ b/remote_window/nextdev_remote_window_remote_profile.patch @@ -0,0 +1,542 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift +index fe3c3e7f..0b8248c2 100644 +--- a/Loop/Loop/Managers/LoopAppManager.swift ++++ b/Loop/Loop/Managers/LoopAppManager.swift +@@ -1071,8 +1071,42 @@ extension LoopAppManager: ResetLoopManagerDelegate { + // MARK: - ServicesManagerDosingDelegate + + extension LoopAppManager: ServicesManagerDosingDelegate { +- func deliverBolus(amountInUnits: Double, decisionId: UUID?) async throws { +- try await deviceDataManager.enactBolus(units: amountInUnits, decisionId: decisionId, activationType: .manualNoRecommendation) ++ func deliverBolus(amountInUnits: Double, decisionId: UUID?, userCreatedDate: Date) async throws -> Double { ++ // Guard B (double-bolus protection): subtract any bolus delivered within ++ // minTreatmentIntervalInMinutes of the command's userCreatedDate from the requested ++ // amount (look back further for safety in case of upload delays), refusing entirely ++ // if the remainder nets to zero. Returns the amount actually delivered. ++ let minTreatmentIntervalInMinutes = 10.0 ++ let boluses = try await doseStore.getBoluses(start: Date().addingTimeInterval(-.days(1))) ++ let conflictStartDate = userCreatedDate.addingTimeInterval(-.minutes(minTreatmentIntervalInMinutes)) ++ let conflictingBolusAmount = boluses.filter({ $0.startDate >= conflictStartDate }).map({ $0.programmedUnits }).reduce(0, +) ++ let adjustedDoseAmount = amountInUnits - conflictingBolusAmount ++ guard adjustedDoseAmount <= amountInUnits else { // sanity check on the bolus math ++ throw BolusActionError.invalidCalculation ++ } ++ guard adjustedDoseAmount > 0.0 else { ++ throw BolusActionError.bolusAmountAdjustedToZero(amountInUnits, conflictStartDate) ++ } ++ try await deviceDataManager.enactBolus(units: adjustedDoseAmount, decisionId: decisionId, activationType: .manualNoRecommendation) ++ return adjustedDoseAmount ++ } ++ ++ enum BolusActionError: LocalizedError { ++ case bolusAmountAdjustedToZero(_ requestedAmountInUnits: Double, _ conflictStartDate: Date) ++ case invalidCalculation ++ ++ var errorDescription: String? { ++ switch self { ++ case .bolusAmountAdjustedToZero(let amountInUnits, let conflictStartDate): ++ let numberFormatter = NumberFormatter() ++ numberFormatter.numberStyle = .decimal ++ numberFormatter.maximumFractionDigits = 2 ++ let treatmentAmountFormatted = (numberFormatter.string(from: NSNumber(value: amountInUnits)) ?? "") + " U" ++ return String(format: NSLocalizedString("All boluses delivered after %1$@ were subtracted from the requested bolus amount for safety. Those amount(s) exceed the requested amount of %2$@ so the remote bolus was not delivered.", comment: "Bolus error description: no remote bolus delivered."), conflictStartDate.formatted(date: .omitted, time: .complete), treatmentAmountFormatted) ++ case .invalidCalculation: ++ return NSLocalizedString("An invalid bolus calculation occurred.", comment: "Bolus error description: invalid calculation.") ++ } ++ } + } + } + +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index 28f07307..1966dcde 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -1415,8 +1415,8 @@ extension LoopDataManager: ServicesManagerDelegate { + + //Carb Entry + +- func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { +- ++ func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws { ++ + let absorptionTime = absorptionTime ?? LoopCoreConstants.defaultCarbAbsorptionTimes.medium + if absorptionTime < LoopConstants.minCarbAbsorptionTime || absorptionTime > LoopConstants.maxCarbAbsorptionTime { + throw CarbActionError.invalidAbsorptionTime(absorptionTime) +@@ -1438,6 +1438,22 @@ extension LoopDataManager: ServicesManagerDelegate { + } + } + ++ // Guard B (conflicting-treatment protection): reject remote carbs if any carb entry was ++ // created within minTreatmentIntervalInMinutes of the command's userCreatedDate. Look back ++ // a little further for safety (e.g. upload delays); zero tolerance for carbs. ++ let maxAllowedConflictingAmountInGrams = 0.0 ++ let minTreatmentIntervalInMinutes = 10.0 ++ let entries = try await carbStore.getCarbEntries(start: Date().addingTimeInterval(-.days(1)), end: Date().addingTimeInterval(.days(1))) ++ let conflictStartDate = userCreatedDate.addingTimeInterval(-.minutes(minTreatmentIntervalInMinutes)) ++ let conflictingEntries = entries.filter({ entry in ++ let createdDate = entry.userCreatedDate ?? entry.startDate ++ return createdDate >= conflictStartDate ++ }) ++ let conflictingCarbAmount = conflictingEntries.map({ $0.quantity.doubleValue(for: .gram) }).reduce(0, +) ++ guard conflictingCarbAmount <= maxAllowedConflictingAmountInGrams else { ++ throw CarbActionError.conflictingTreatments(conflictingCarbAmount, minTreatmentIntervalInMinutes) ++ } ++ + let quantity = LoopQuantity(unit: .gram, doubleValue: amountInGrams) + let candidateCarbEntry = NewCarbEntry(syncIdentifier: UUID().uuidString, quantity: quantity, startDate: startDate ?? now, foodType: foodType, absorptionTime: absorptionTime) + +@@ -1450,9 +1466,14 @@ extension LoopDataManager: ServicesManagerDelegate { + case invalidStartDate(Date) + case exceedsMaxCarbs + case invalidCarbs +- ++ case conflictingTreatments(_ conflictingAmountInGrams: Double, _ minutesRequiredBetweenTreatments: TimeInterval) ++ + var errorDescription: String? { + switch self { ++ case .conflictingTreatments(let conflictingAmountInGrams, let minutesRequiredBetweenTreatments): ++ let treatmentAmountFormatted = (Self.numberFormatter.string(from: NSNumber(value: conflictingAmountInGrams)) ?? "") + " g" ++ let minutesRequiredBetweenTreatmentsFormatted = Self.numberFormatter.string(from: NSNumber(value: minutesRequiredBetweenTreatments)) ?? "" ++ return String(format: NSLocalizedString("Conflicting carb treatments (%1$@) have occurred. At least %2$@ minutes must pass between carb entries.", comment: "Carb error description: conflicting treatments occurred."), treatmentAmountFormatted, minutesRequiredBetweenTreatmentsFormatted) + case .exceedsMaxCarbs: + return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Carb error description: carbs exceed maximum amount.") + case .invalidCarbs: +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 15602f08..8e7c86ef 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -245,13 +245,13 @@ class ServicesManager { + } + + public protocol ServicesManagerDosingDelegate: AnyObject { +- func deliverBolus(amountInUnits: Double, decisionId: UUID?) async throws ++ func deliverBolus(amountInUnits: Double, decisionId: UUID?, userCreatedDate: Date) async throws -> Double + } + + public protocol ServicesManagerDelegate: AnyObject { + func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws + func cancelCurrentOverride() async throws +- func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws ++ func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws + } + + // MARK: - StatefulPluggableDelegate +@@ -325,9 +325,10 @@ extension ServicesManager: ServiceDelegate { + await remoteDataServicesManager.performUpload(for: .overrides) + } + +- func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { ++ func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws { + do { +- try await servicesManagerDelegate?.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) ++ guard let servicesManagerDelegate else { throw BolusActionError.internalError } ++ try await servicesManagerDelegate.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate, userCreatedDate: userCreatedDate) + NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: amountInGrams) + await remoteDataServicesManager.performUpload(for: .carb) + analyticsServicesManager.didAddCarbs(source: "Remote") +@@ -337,25 +338,30 @@ extension ServicesManager: ServiceDelegate { + } + } + +- func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?) async throws { ++ func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?, userCreatedDate: Date) async throws -> Double { + do { +- ++ + guard amountInUnits > 0 else { + throw BolusActionError.invalidBolus + } +- ++ + guard let maxBolusAmount = settingsManager.loopSettings.maximumBolus else { + throw BolusActionError.missingMaxBolus + } +- ++ + guard amountInUnits <= maxBolusAmount else { + throw BolusActionError.exceedsMaxBolus + } +- +- try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits, decisionId: decisionId) +- NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) ++ ++ guard let servicesManagerDosingDelegate else { ++ throw BolusActionError.internalError ++ } ++ // May be reduced by the conflicting-treatment guard; report the amount actually delivered. ++ let deliveredUnits = try await servicesManagerDosingDelegate.deliverBolus(amountInUnits: amountInUnits, decisionId: decisionId, userCreatedDate: userCreatedDate) ++ NotificationManager.sendRemoteBolusNotification(amount: deliveredUnits) + await remoteDataServicesManager.performUpload(for: .dose) + analyticsServicesManager.didBolus(source: "Remote") ++ return deliveredUnits + } catch { + NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) + throw error +@@ -367,7 +373,8 @@ extension ServicesManager: ServiceDelegate { + case invalidBolus + case missingMaxBolus + case exceedsMaxBolus +- ++ case internalError ++ + var errorDescription: String? { + switch self { + case .invalidBolus: +@@ -376,6 +383,8 @@ extension ServicesManager: ServiceDelegate { + return NSLocalizedString("Missing maximum allowed bolus in settings", comment: "Bolus error description: missing maximum bolus in settings.") + case .exceedsMaxBolus: + return NSLocalizedString("Exceeds maximum allowed bolus in settings", comment: "Bolus error description: bolus exceeds maximum bolus in settings.") ++ case .internalError: ++ return NSLocalizedString("An internal error occurred.", comment: "Bolus error description: internal error.") + } + } + } +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index a7c9c653..b82f71ed 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -17,6 +17,6 @@ public protocol RemoteProfileActionDelegate: AnyObject { + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws + func cancelRemoteOverride() async throws +- func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws +- func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?) async throws ++ func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws ++ func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?, userCreatedDate: Date) async throws -> Double + } +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index 73db786..ec997ef 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -14,6 +14,7 @@ public enum NightscoutServiceError: Error { + case incompatibleTherapySettings + case missingCredentials + case missingCommandSource ++ case missingServiceDelegate + } + + +@@ -66,10 +67,12 @@ public final class NightscoutService: Service { + + private let log = OSLog(category: "NightscoutService") + ++ private let notificationExpirationInMinutes = 10.0 ++ + public init() { + self.isOnboarded = false + self.lockedObjectIdCache = Locked(ObjectIdCache()) +- self.otpManager = OTPManager(secretStore: KeychainManager()) ++ self.otpManager = OTPManager(secretStore: KeychainManager(), maxMinutesValid: notificationExpirationInMinutes) + self.commandSourceV1 = RemoteCommandSourceV1(otpManager: otpManager) + self.commandSourceV1.delegate = self + } +@@ -85,7 +88,7 @@ public final class NightscoutService: Service { + self.lockedObjectIdCache = Locked(ObjectIdCache()) + } + +- self.otpManager = OTPManager(secretStore: KeychainManager()) ++ self.otpManager = OTPManager(secretStore: KeychainManager(), maxMinutesValid: notificationExpirationInMinutes) + self.commandSourceV1 = RemoteCommandSourceV1(otpManager: otpManager) + self.commandSourceV1.delegate = self + +@@ -422,6 +425,14 @@ extension NightscoutService: RemoteDataService { + + + public func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async throws { ++ var notification = notification ++ // Widen the acceptance window: override the expiration to sent-at + notificationExpirationInMinutes. ++ // Ideally this would be done in Nightscout instead. ++ if let sentDateString = notification["sent-at"] as? String, ++ let sentDate = DateFormatter.iso8601DateDecoder.date(from: sentDateString) { ++ let expirationDate = sentDate.addingTimeInterval(60 * notificationExpirationInMinutes) ++ notification["expiration"] = DateFormatter.iso8601DateDecoder.string(from: expirationDate) as AnyObject ++ } + let commandSource = try commandSource(notification: notification) + await commandSource.remoteNotificationWasReceived(notification) + } +@@ -448,10 +459,13 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + var message = "" + + do { ++ guard let serviceDelegate = self.serviceDelegate else { ++ throw NightscoutServiceError.missingServiceDelegate ++ } + switch action { + case .temporaryScheduleOverride(let overrideCommand): + commandType = .override +- try await self.serviceDelegate?.enactRemoteOverride( ++ try await serviceDelegate.enactRemoteOverride( + name: overrideCommand.name, + durationTime: overrideCommand.durationTime, + remoteAddress: overrideCommand.remoteAddress +@@ -479,23 +493,28 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + + case .cancelTemporaryOverride: + commandType = .cancelOverride +- try await self.serviceDelegate?.cancelRemoteOverride() ++ try await serviceDelegate.cancelRemoteOverride() + success = true + message = "Override cancelled successfully" + + case .bolusEntry(let bolusCommand): + commandType = .bolus +- try await self.serviceDelegate?.deliverRemoteBolus(amountInUnits: bolusCommand.amountInUnits, decisionId: nil) ++ let deliveredUnits = try await serviceDelegate.deliverRemoteBolus(amountInUnits: bolusCommand.amountInUnits, decisionId: nil, userCreatedDate: bolusCommand.userCreatedDate) + success = true +- message = String(format: "Bolus of %.2f units delivered successfully", bolusCommand.amountInUnits) ++ if deliveredUnits < bolusCommand.amountInUnits { ++ message = String(format: "Bolus reduced to %.2f units (from %.2f) due to other recent treatments", deliveredUnits, bolusCommand.amountInUnits) ++ } else { ++ message = String(format: "Bolus of %.2f units delivered successfully", deliveredUnits) ++ } + + case .carbsEntry(let carbCommand): + commandType = .carbs +- try await self.serviceDelegate?.deliverRemoteCarbs( ++ try await serviceDelegate.deliverRemoteCarbs( + amountInGrams: carbCommand.amountInGrams, + absorptionTime: carbCommand.absorptionTime, + foodType: carbCommand.foodType, +- startDate: carbCommand.startDate ++ startDate: carbCommand.startDate, ++ userCreatedDate: carbCommand.userCreatedDate + ) + success = true + message = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) +diff --git a/NightscoutService/NightscoutServiceKit/OTPManager.swift b/NightscoutService/NightscoutServiceKit/OTPManager.swift +index f558a46..921ff02 100644 +--- a/NightscoutService/NightscoutServiceKit/OTPManager.swift ++++ b/NightscoutService/NightscoutServiceKit/OTPManager.swift +@@ -77,8 +77,19 @@ public class OTPManager { + let maxOTPsToAccept: Int + + public static var defaultTokenPeriod: TimeInterval = 30 +- public static var defaultMaxOTPsToAccept = 2 +- ++ public static var defaultMaxOTPsToAccept = 30 ++ ++ public init(secretStore: OTPSecretStore = KeychainManager(), nowDateSource: @escaping () -> Date = {Date()}, maxMinutesValid: Double) { ++ self.secretStore = secretStore ++ self.nowDateSource = nowDateSource ++ self.tokenPeriod = OTPManager.defaultTokenPeriod ++ let secondsValid = 60.0 * maxMinutesValid ++ self.maxOTPsToAccept = Int(secondsValid / OTPManager.defaultTokenPeriod) ++ if secretStore.tokenSecretKey() == nil || secretStore.tokenSecretKeyName() == nil { ++ resetSecretKey() ++ } ++ } ++ + public init(secretStore: OTPSecretStore = KeychainManager(), nowDateSource: @escaping () -> Date = {Date()}, tokenPeriod: TimeInterval = OTPManager.defaultTokenPeriod, maxOTPsToAccept: Int = OTPManager.defaultMaxOTPsToAccept) { + self.secretStore = secretStore + self.nowDateSource = nowDateSource +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift +index 3a56d81..6db615c 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift +@@ -9,10 +9,12 @@ + import Foundation + + public struct BolusAction: Codable { +- ++ + public let amountInUnits: Double +- +- public init(amountInUnits: Double) { ++ public let userCreatedDate: Date ++ ++ public init(amountInUnits: Double, userCreatedDate: Date) { + self.amountInUnits = amountInUnits ++ self.userCreatedDate = userCreatedDate + } + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift +index 3865989..b315b16 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift +@@ -14,11 +14,13 @@ public struct CarbAction: Codable{ + public let absorptionTime: TimeInterval? + public let foodType: String? + public let startDate: Date? +- +- public init(amountInGrams: Double, absorptionTime: TimeInterval? = nil, foodType: String? = nil, startDate: Date? = nil) { ++ public let userCreatedDate: Date ++ ++ public init(amountInGrams: Double, absorptionTime: TimeInterval? = nil, foodType: String? = nil, startDate: Date? = nil, userCreatedDate: Date) { + self.amountInGrams = amountInGrams + self.absorptionTime = absorptionTime + self.foodType = foodType + self.startDate = startDate ++ self.userCreatedDate = userCreatedDate + } + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift +index 1c14cdf..869abc2 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift +@@ -14,11 +14,11 @@ public struct BolusRemoteNotification: RemoteNotification, Codable { + public let amount: Double + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? +- ++ + enum CodingKeys: String, CodingKey { + case remoteAddress = "remote-address" + case amount = "bolus-entry" +@@ -28,9 +28,13 @@ public struct BolusRemoteNotification: RemoteNotification, Codable { + case enteredBy = "entered-by" + case encryptedReturnNotification = "encrypted_return_notification" + } +- ++ + func toRemoteAction() -> Action { +- return .bolusEntry(BolusAction(amountInUnits: amount)) ++ return .bolusEntry(toBolusAction()) ++ } ++ ++ func toBolusAction() -> BolusAction { ++ return BolusAction(amountInUnits: amount, userCreatedDate: sentAt) + } + + func otpValidationRequired() -> Bool { +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift +index 6c238ed..c069c34 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift +@@ -17,7 +17,7 @@ public struct CarbRemoteNotification: RemoteNotification, Codable { + public let startDate: Date? + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? +@@ -43,7 +43,7 @@ public struct CarbRemoteNotification: RemoteNotification, Codable { + } + + func toRemoteAction() -> Action { +- let action = CarbAction(amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate) ++ let action = CarbAction(amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate, userCreatedDate: sentAt) + return .carbsEntry(action) + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift +index fdd6d6b..babb71f 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift +@@ -13,7 +13,7 @@ public struct OverrideCancelRemoteNotification: RemoteNotification, Codable { + + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let cancelOverride: String + public let enteredBy: String? + public let otp: String? +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift +index 2060a74..426ff89 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift +@@ -15,7 +15,7 @@ public struct OverrideRemoteNotification: RemoteNotification, Codable { + public let durationInMinutes: Double? + public let remoteAddress: String + public let expiration: Date? +- public let sentAt: Date? ++ public let sentAt: Date + public let enteredBy: String? + public let otp: String? + public let encryptedReturnNotification: String? +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +index 487c620..ebcd6ef 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -16,7 +16,7 @@ public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { + public let fingerprint: String + public let remoteAddress: String + public let expirationDate: Date +- public let sentAt: Date? ++ public let sentAt: Date + public let otp: String? + public let enteredBy: String? + public let encryptedReturnNotification: String? +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 3362bf7..a0f58a0 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -14,7 +14,7 @@ protocol RemoteNotification: Codable { + + var id: String {get} + var expiration: Date? {get} +- var sentAt: Date? {get} ++ var sentAt: Date {get} + var otp: String? {get} + var remoteAddress: String {get} + var enteredBy: String? {get} +@@ -30,12 +30,8 @@ protocol RemoteNotification: Codable { + extension RemoteNotification { + + var id: String { +- //There is no unique identifier so we use the sent date when available +- if let sentAt = sentAt { +- return "\(sentAt.timeIntervalSince1970)" +- } else { +- return UUID().uuidString +- } ++ //There is no unique identifier so we use the sent date ++ return "\(sentAt.timeIntervalSince1970)" + } + + func getReturnNotificationInfo() -> ReturnNotificationInfo? { +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +index 634eb4e..fce4623 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift +@@ -23,13 +23,13 @@ struct RemoteCommandValidator { + } + + private func validateExpirationDate(remoteNotification: RemoteNotification) throws { +- ++ + guard let expirationDate = remoteNotification.expiration else { + return //Skip validation if no date included + } +- ++ + if nowDateSource() > expirationDate { +- throw NotificationValidationError.expiredNotification ++ throw NotificationValidationError.expiredNotification(sentDate: remoteNotification.sentAt, receivedDate: nowDateSource()) + } + } + +@@ -44,14 +44,16 @@ struct RemoteCommandValidator { + + enum NotificationValidationError: LocalizedError { + case missingOTP +- case expiredNotification +- ++ case expiredNotification(sentDate: Date, receivedDate: Date) ++ + var errorDescription: String? { + switch self { + case .missingOTP: + return LocalizedString("Missing OTP", comment: "Remote command error description: Missing OTP.") +- case .expiredNotification: +- return LocalizedString("Expired", comment: "Remote command error description: expired.") ++ case .expiredNotification(let sentDate, let receivedDate): ++ let dateFormatter = DateFormatter() ++ dateFormatter.dateFormat = "h:mm:ss a" ++ return String(format: LocalizedString("Expired. Sent: %@. Received: %@", comment: "Remote command error description: expired."), dateFormatter.string(from: sentDate), dateFormatter.string(from: receivedDate)) + } + } + } +diff --git a/remote_window.loopcustomization b/remote_window.loopcustomization +new file mode 100644 +index 0000000..e69de29 From 0e7cd3c22dc6c8795d90a360415c549eb9420f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 25 Oct 2026 18:06:45 +0100 Subject: [PATCH 2/6] Move remote profile switching into the profiles customization profiles now includes the remote profile switch instead of a separate remote_profile customization. The profiles patches without it are archived as *_noremote, so workspaces that have them applied show an update. profiles, remote_carb_edit and remote_window get variants for each other in every order, on dev also together with basal_lock. --- {remote_profile => profiles}/README.md | 22 +- .../dev_profiles_basal_lock_noremote.patch | 1430 +++++++++++ profiles/archive/dev_profiles_noremote.patch | 1433 +++++++++++ .../archive/nextdev_profiles_noremote.patch | 1395 +++++++++++ profiles/dev_profiles.patch | 613 ++++- profiles/dev_profiles_basal_lock.patch | 617 ++++- ...profiles_basal_lock_remote_carb_edit.patch | 2032 ++++++++++++++++ ..._lock_remote_carb_edit_remote_window.patch | 2095 ++++++++++++++++ ...ev_profiles_basal_lock_remote_window.patch | 2096 +++++++++++++++++ profiles/dev_profiles_remote_carb_edit.patch | 2031 ++++++++++++++++ ...files_remote_carb_edit_remote_window.patch | 2094 ++++++++++++++++ profiles/dev_profiles_remote_window.patch | 2095 ++++++++++++++++ profiles/nextdev_profiles.patch | 615 ++++- .../nextdev_profiles_remote_carb_edit.patch | 1997 ++++++++++++++++ ...files_remote_carb_edit_remote_window.patch | 1997 ++++++++++++++++ profiles/nextdev_profiles_remote_window.patch | 1996 ++++++++++++++++ remote_carb_edit/README.md | 10 +- ...ch => dev_remote_carb_edit_profiles.patch} | 0 ...te_carb_edit_profiles_remote_window.patch} | 0 ...> nextdev_remote_carb_edit_profiles.patch} | 0 ...te_carb_edit_profiles_remote_window.patch} | 0 remote_profile/dev_remote_profile.patch | 651 ----- .../dev_remote_profile_remote_carb_edit.patch | 650 ----- ...ofile_remote_carb_edit_remote_window.patch | 713 ------ .../dev_remote_profile_remote_window.patch | 714 ------ remote_profile/nextdev_remote_profile.patch | 660 ------ ...tdev_remote_profile_remote_carb_edit.patch | 661 ------ ...ofile_remote_carb_edit_remote_window.patch | 661 ------ ...nextdev_remote_profile_remote_window.patch | 660 ------ ...patch => dev_remote_window_profiles.patch} | 0 ...te_window_profiles_remote_carb_edit.patch} | 0 ...h => nextdev_remote_window_profiles.patch} | 0 ...te_window_profiles_remote_carb_edit.patch} | 0 33 files changed, 24533 insertions(+), 5405 deletions(-) rename {remote_profile => profiles}/README.md (65%) create mode 100644 profiles/archive/dev_profiles_basal_lock_noremote.patch create mode 100644 profiles/archive/dev_profiles_noremote.patch create mode 100644 profiles/archive/nextdev_profiles_noremote.patch create mode 100644 profiles/dev_profiles_basal_lock_remote_carb_edit.patch create mode 100644 profiles/dev_profiles_basal_lock_remote_carb_edit_remote_window.patch create mode 100644 profiles/dev_profiles_basal_lock_remote_window.patch create mode 100644 profiles/dev_profiles_remote_carb_edit.patch create mode 100644 profiles/dev_profiles_remote_carb_edit_remote_window.patch create mode 100644 profiles/dev_profiles_remote_window.patch create mode 100644 profiles/nextdev_profiles_remote_carb_edit.patch create mode 100644 profiles/nextdev_profiles_remote_carb_edit_remote_window.patch create mode 100644 profiles/nextdev_profiles_remote_window.patch rename remote_carb_edit/{dev_remote_carb_edit_remote_profile.patch => dev_remote_carb_edit_profiles.patch} (100%) rename remote_carb_edit/{dev_remote_carb_edit_remote_profile_remote_window.patch => dev_remote_carb_edit_profiles_remote_window.patch} (100%) rename remote_carb_edit/{nextdev_remote_carb_edit_remote_profile.patch => nextdev_remote_carb_edit_profiles.patch} (100%) rename remote_carb_edit/{nextdev_remote_carb_edit_remote_profile_remote_window.patch => nextdev_remote_carb_edit_profiles_remote_window.patch} (100%) delete mode 100644 remote_profile/dev_remote_profile.patch delete mode 100644 remote_profile/dev_remote_profile_remote_carb_edit.patch delete mode 100644 remote_profile/dev_remote_profile_remote_carb_edit_remote_window.patch delete mode 100644 remote_profile/dev_remote_profile_remote_window.patch delete mode 100644 remote_profile/nextdev_remote_profile.patch delete mode 100644 remote_profile/nextdev_remote_profile_remote_carb_edit.patch delete mode 100644 remote_profile/nextdev_remote_profile_remote_carb_edit_remote_window.patch delete mode 100644 remote_profile/nextdev_remote_profile_remote_window.patch rename remote_window/{dev_remote_window_remote_profile.patch => dev_remote_window_profiles.patch} (100%) rename remote_window/{dev_remote_window_remote_carb_edit_remote_profile.patch => dev_remote_window_profiles_remote_carb_edit.patch} (100%) rename remote_window/{nextdev_remote_window_remote_profile.patch => nextdev_remote_window_profiles.patch} (100%) rename remote_window/{nextdev_remote_window_remote_carb_edit_remote_profile.patch => nextdev_remote_window_profiles_remote_carb_edit.patch} (100%) diff --git a/remote_profile/README.md b/profiles/README.md similarity index 65% rename from remote_profile/README.md rename to profiles/README.md index 977b0ed..7c1a23c 100644 --- a/remote_profile/README.md +++ b/profiles/README.md @@ -1,6 +1,10 @@ -# Remote profile switch +# Profiles -Lets a caregiver app (LoopFollow) switch Loop to one of the profiles saved with the `profiles` customization, through the same OTP-protected APNS remote command path as remote carbs and boluses. Requires `profiles`; without it this customization shows as not compatible. +Save the current therapy settings (correction range, carb ratios, basal rates and insulin sensitivities) as named profiles under Settings → Profiles, and load one later. + +## Remote profile switch + +A caregiver app (LoopFollow) can switch Loop to one of the saved profiles, through the same OTP-protected APNS remote command path as remote carbs and boluses. Switching a profile changes the correction range, carb ratios, basal rates and insulin sensitivities Loop doses with. It runs the same steps as loading the profile on the phone: Loop checks the profile against the guardrails and the pump's supported basal rates, writes the basal schedule to the pump, and only then saves the new settings. If any step fails, nothing changes and the caregiver is told why. This is not medical advice; agree on the saved profiles and when to use them with your care team. @@ -24,20 +28,20 @@ Loop answers through the existing return push (`encrypted_return_notification`) ## Patches -Touches Loop, LoopKit and NightscoutService. `dev_remote_profile.patch` is for LoopWorkspace `dev` and `main` (3.14.11), `nextdev_remote_profile.patch` for `next-dev`. Apply after `profiles`, from the LoopWorkspace folder: +Touches Loop, LoopKit, LoopOnboarding and NightscoutService. `dev_profiles.patch` is for LoopWorkspace `dev` and `main` (3.14.11), `nextdev_profiles.patch` for `next-dev`. `main380_*` and `main382_*` are for older main releases and do not include remote switching. Apply from the LoopWorkspace folder: ``` -git apply --whitespace=nowarn dev_remote_profile.patch +git apply --whitespace=nowarn dev_profiles.patch ``` -It coexists with `basal_lock` and `negative_insulin` in any order. `profiles` cannot be removed while this customization is applied; remove this one first. +On `dev`, `dev_profiles_basal_lock*.patch` apply on top of basal_lock; on `next-dev` the two coexist without a variant. Profiles coexists with negative_insulin. The profiles patches without remote switching are in `archive/*_noremote.patch`, so the selector reports an update for workspaces that have them applied. ## Combining with remote_carb_edit and remote_window -Both also change the remote command code, so each has variants for the other customizations already being in the tree: +Both also change the remote command code, so each has variants for the others already being in the tree: -- `_remote_profile_remote_carb_edit.patch`, `_remote_profile_remote_window.patch`, `_remote_profile_remote_carb_edit_remote_window.patch`: this customization on top of the named ones. -- `remote_carb_edit/_remote_carb_edit_remote_profile.patch` and `..._remote_profile_remote_window.patch`: remote_carb_edit on top of this one (and remote_window). -- `remote_window/_remote_window_remote_profile.patch` and `..._remote_carb_edit_remote_profile.patch`: remote_window on top of this one (and remote_carb_edit). +- `_profiles[_basal_lock]_remote_carb_edit.patch`, `..._remote_window.patch` and `..._remote_carb_edit_remote_window.patch`: profiles on top of the named customizations. +- `remote_carb_edit/_remote_carb_edit_profiles.patch` and `..._profiles_remote_window.patch`: remote_carb_edit on top of profiles (and remote_window). +- `remote_window/_remote_window_profiles.patch` and `..._profiles_remote_carb_edit.patch`: remote_window on top of profiles (and remote_carb_edit). Every order produces the same tree, and each customization can be removed on its own. With remote_carb_edit, `remoteCommands` lists all three commands. With remote_window, `sent-at` is required, and on `dev` profile switches also appear in the remote command history. diff --git a/profiles/archive/dev_profiles_basal_lock_noremote.patch b/profiles/archive/dev_profiles_basal_lock_noremote.patch new file mode 100644 index 0000000..5737fa2 --- /dev/null +++ b/profiles/archive/dev_profiles_basal_lock_noremote.patch @@ -0,0 +1,1430 @@ +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index cabca1e6..e7316b68 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -1673,6 +1673,10 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { + } + } + ++ func updateCurrentProfileName() { ++ loopManager.updateCurrentProfileName() ++ } ++ + func saveCompletion(therapySettings: TherapySettings) { + + loopManager.mutateSettings { settings in +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index 1a6e00d7..dd378316 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -244,6 +244,10 @@ final class LoopDataManager { + lockedSettings.value + } + ++ func updateCurrentProfileName () { ++ notify(forChange: .preferences) ++ } ++ + func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { + var oldValue: LoopSettings! + let newValue = lockedSettings.mutate { settings in +diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift +index f31763de..4df43b2c 100644 +--- a/Loop/Loop/Views/SettingsView.swift ++++ b/Loop/Loop/Views/SettingsView.swift +@@ -51,6 +51,7 @@ public struct SettingsView: View { + + case favoriteFoods + case preferences ++ case profiles + } + } + +@@ -125,6 +126,19 @@ public struct SettingsView: View { + FavoriteFoodsView() + case .preferences: + PreferencesView(viewModel: PreferencesViewModel(preferencesProvider: Preferences.shared)).environmentObject(displayGlucosePreference) ++ case .profiles: ++ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), ++ sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, ++ adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, ++ delegate: self.viewModel.therapySettingsViewModelDelegate)) ++ .environmentObject(displayGlucosePreference) ++ .environment(\.dismissAction, self.dismiss) ++ .environment(\.appName, self.appName) ++ .environment(\.chartColorPalette, .primary) ++ .environment(\.carbTintColor, self.carbTintColor) ++ .environment(\.glucoseTintColor, self.glucoseTintColor) ++ .environment(\.guidanceColors, self.guidanceColors) ++ .environment(\.insulinTintColor, self.insulinTintColor) + } + } + } +@@ -285,6 +299,11 @@ extension SettingsView { + descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) + } + ++ LargeButton(action: { sheet = .profiles }, ++ includeArrow: true, ++ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), ++ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), ++ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) + ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in + item.view + } +diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj +index c36bfda3..5e9ec17d 100644 +--- a/LoopKit/LoopKit.xcodeproj/project.pbxproj ++++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj +@@ -873,6 +873,12 @@ + D1A0B000000000000000000C /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; + D1A0B000000000000000000D /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; + D1A0B0000000000000000081 /* PersistenceControllerMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */; }; ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; + DD13BC702C3C71A70062313B /* BasalLockEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC6F2C3C71A70062313B /* BasalLockEditor.swift */; }; + DD13BC722C3C74CD0062313B /* PreferencesGuardrailWarning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC712C3C74CD0062313B /* PreferencesGuardrailWarning.swift */; }; + DD28B3712B80C074001044D4 /* PreferencesSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD28B3702B80C074001044D4 /* PreferencesSetting.swift */; }; +@@ -1820,6 +1826,12 @@ + D1A0B0000000000000000006 /* Modelv5ToModelv6.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = Modelv5ToModelv6.xcmappingmodel; sourceTree = ""; }; + D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelMappingv4v5Tov6Policy.swift; sourceTree = ""; }; + D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; ++ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; + DD13BC6F2C3C71A70062313B /* BasalLockEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalLockEditor.swift; sourceTree = ""; }; + DD13BC712C3C74CD0062313B /* PreferencesGuardrailWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesGuardrailWarning.swift; sourceTree = ""; }; + DD28B3702B80C074001044D4 /* PreferencesSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesSetting.swift; sourceTree = ""; }; +@@ -3027,12 +3039,14 @@ + isa = PBXGroup; + children = ( + B455F3A325FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift */, ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, + B4B7C1BB2604E3FC007379F6 /* CorrectionRangeScheduleEditorViewModel.swift */, + B4D4C20C25F95A8700DA809D /* DisplayGlucosePreference.swift */, + B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, + B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, + 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, + DD545A5E2B80EFA900915F95 /* PreferencesViewModel.swift */, ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, + ); + path = ViewModels; + sourceTree = ""; +@@ -3230,6 +3244,10 @@ + E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, + 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, + 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, ++ DDAF746629F422C600719F0A /* ProfileView.swift */, ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, + ); + path = "Settings Editors"; + sourceTree = ""; +@@ -3940,6 +3958,7 @@ + E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, + B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, + 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, + 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, + E9E5E56A24D5CCE800B5DFFE /* OverrideViewCell.swift in Sources */, + 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, +@@ -3949,6 +3968,7 @@ + B43D69D62A26642300DF0925 /* DemoPlaceHolderView.swift in Sources */, + E949E38F24B3711E00024DA0 /* InsulinModelInformationView.swift in Sources */, + 895FE08B22011F0C00FCF18A /* EmojiInputHeaderView.swift in Sources */, ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, + 895FE08322011F0C00FCF18A /* OverrideSelectionFooterView.swift in Sources */, + 89AF78C22447E353002B4FCC /* Splat.swift in Sources */, + 893C9F8C2447DBD900CD4185 /* CardBuilder.swift in Sources */, +@@ -4000,9 +4020,11 @@ + E9C58A6E24DA65E400487A17 /* HistoricalOverrideDetailView.swift in Sources */, + 895FE08222011F0C00FCF18A /* LabeledTextFieldTableViewCell.swift in Sources */, + B43DA44224D9CD8500CAFF4E /* Environment+Colors.swift in Sources */, ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, + 892A5DB22231E191008961AB /* LoadingTableViewCell.swift in Sources */, + B4D3D4AD25B8A94E0085BA0F /* DisplayGlucoseUnitObserver.swift in Sources */, + A9D3FF102A6C19CA000C891D /* ChartAxisValueDoubleLog.swift in Sources */, ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, + E96DCB5A24AF74AC007117BC /* SuspendThresholdEditor.swift in Sources */, + E96DCB5824AEF50F007117BC /* SuspendThresholdInformationView.swift in Sources */, + A9D3FF0C2A6C198C000C891D /* CollectionType.swift in Sources */, +@@ -4079,6 +4101,7 @@ + E93C86B624D08CAD0073089B /* InsulinSensitivityInformationView.swift in Sources */, + C18733AF29B9492300519CDF /* Collection.swift in Sources */, + 892A5DA22231E137008961AB /* HUDProvider.swift in Sources */, ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, + 898E6E6C224194060019E459 /* UIColor.swift in Sources */, + 43BA717D201EE7090058961E /* GlucoseRangeTableViewCell.swift in Sources */, + B455F3A425FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift in Sources */, +@@ -4106,6 +4129,7 @@ + 1D1065E9282DC54700026A70 /* VideoPlayView.swift in Sources */, + E99A132E2557548300D3F5B3 /* SegmentedGaugeBar.swift in Sources */, + B455F31825FBEC5F000ED456 /* SuspendThresholdEditorViewModel.swift in Sources */, ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, + 892A5DB32231E191008961AB /* LevelHUDView.swift in Sources */, + B41A60B223D1DBC700636320 /* UIFont.swift in Sources */, + B41A60AF23D1DB5B00636320 /* TableViewTitleLabel.swift in Sources */, +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +new file mode 100644 +index 00000000..74724fb7 +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +@@ -0,0 +1,408 @@ ++// ++// ProfileViewModel+FileManagement.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import LoopKit ++import HealthKit ++ ++public enum ProfileValidationError: Error, LocalizedError { ++ case fileLoadError ++ case unsupportedIncrement ++ case correctionRangeError ++ case insulinSensitivityError ++ case carbRatioError ++ case basalRateError ++ case maxBasalRateNotSet ++ ++ public var errorDescription: String? { ++ switch self { ++ case .fileLoadError: ++ return "Unable to load the profile file." ++ case .unsupportedIncrement: ++ return "The profile contains unsupported increments." ++ case .correctionRangeError: ++ return "Correction Range values are out of bounds." ++ case .insulinSensitivityError: ++ return "Insulin Sensitivity values are out of bounds." ++ case .carbRatioError: ++ return "Carb Ratio values are out of bounds." ++ case .basalRateError: ++ return "Basal Rate values are out of bounds." ++ case .maxBasalRateNotSet: ++ return "Maximum Basal Rate is not set." ++ } ++ } ++} ++ ++public enum LoadProfileResult { ++ case success ++ case failure(Error) ++} ++ ++// MARK: File management ++extension ProfileViewModel { ++ private func setCurrentProfile(name: String, syncChange: Bool = false) { ++ let sanitizedProfileName = sanitizeProfileName(name) ++ currentProfileName = sanitizedProfileName ++ if syncChange { ++ triggerProfileSync() ++ } ++ } ++ ++ private func clearCurrentProfile() { ++ currentProfileName = nil ++ triggerProfileSync() ++ } ++ ++ private func getCurrentProfileName() -> String? { ++ return currentProfileName ++ } ++ ++ private var profilesDirectory: URL { ++ let fileManager = FileManager.default ++ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] ++ return documentsDirectory.appendingPathComponent("LoopProfile") ++ } ++ ++ private func createProfileDirectoryIfNotExists() throws { ++ let fileManager = FileManager.default ++ var isDir : ObjCBool = false ++ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { ++ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) ++ } else if !isDir.boolValue { ++ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) ++ } ++ } ++ ++ private func getAllProfileFiles() throws -> [URL] { ++ let fileManager = FileManager.default ++ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) ++ return files.filter { $0.pathExtension == "json" } ++ } ++ ++ private func encodeProfile(_ profile: Profile) throws -> Data { ++ return try JSONEncoder().encode(profile) ++ } ++ ++ private func decodeProfile(from data: Data) throws -> Profile { ++ return try JSONDecoder().decode(Profile.self, from: data) ++ } ++ ++ public func sanitizeProfileName(_ name: String) -> String { ++ // Replace problematic characters ++ var sanitized = name.replacingOccurrences(of: ".", with: "_") ++ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") ++ ++ // Limit the name to 32 characters ++ if sanitized.count > 32 { ++ sanitized = String(sanitized.prefix(32)) ++ } ++ ++ return sanitized ++ } ++ ++ public func saveProfile(withName name: String) { ++ let sanitizedName = sanitizeProfileName(name) ++ ++ do { ++ try createProfileDirectoryIfNotExists() ++ ++ if let existingProfile = getProfileReference(withName: sanitizedName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let profile = Profile( ++ name: sanitizedName, ++ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ carbRatioSchedule: therapySettings.carbRatioSchedule!, ++ basalRateSchedule: therapySettings.basalRateSchedule!, ++ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ sortOrder: -1 ++ ) ++ ++ let jsonData = try encodeProfile(profile) ++ ++ let dateFormatter = DateFormatter() ++ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" ++ let fileName = dateFormatter.string(from: Date()) + ".json" ++ let fileURL = profilesDirectory.appendingPathComponent(fileName) ++ ++ try jsonData.write(to: fileURL) ++ setCurrentProfile(name: sanitizedName, syncChange: true) ++ ++ self.loadProfiles() ++ } catch { ++ print("An error occurred while saving profile: \(error)") ++ } ++ } ++ ++ public func renameProfile(oldName: String, newName: String) { ++ let sanitizedProfileName = sanitizeProfileName(newName) ++ ++ if currentProfileName == oldName { ++ setCurrentProfile(name: sanitizedProfileName, syncChange: true) ++ } ++ ++ do { ++ guard let profileReference = getProfileReference(withName: oldName) else { ++ print("Profile with old name not found.") ++ return ++ } ++ ++ let profile = try getProfile(from: profileReference) ++ ++ let newProfile = Profile( ++ name: sanitizedProfileName, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ ++ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ ++ try jsonData.write(to: fileURL) ++ ++ self.loadProfiles() ++ } catch { ++ print("An error occurred while renaming profile: \(error)") ++ } ++ } ++ ++ public func updateProfilesOrder() { ++ for (index, profileReference) in profiles.enumerated() { ++ do { ++ var profile = try getProfile(from: profileReference) ++ let newProfile = Profile( ++ name: profile.name, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: index ++ ) ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try jsonData.write(to: fileURL) ++ ++ } catch { ++ print("An error occurred while updating profile order: \(error)") ++ } ++ } ++ loadProfiles() ++ } ++ ++ public func loadProfiles() { ++ do { ++ let profileFiles = try getAllProfileFiles() ++ ++ var newProfiles = [ProfileReference]() ++ for fileURL in profileFiles { ++ let data = try Data(contentsOf: fileURL) ++ let profile = try decodeProfile(from: data) ++ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) ++ newProfiles.append(profileRef) ++ } ++ ++ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } ++ ++ self.profiles = newProfiles ++ } catch { ++ print("An error occurred while loading profiles: \(error)") ++ } ++ } ++ ++ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { ++ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && ++ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && ++ therapySettings.basalRateSchedule == profile.basalRateSchedule && ++ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { ++ self.setCurrentProfile(name: profile.name, syncChange: true) ++ completion(.success) ++ } ++ else ++ { ++ do { ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ switch result { ++ case .success(let syncedSchedule): ++ DispatchQueue.main.async { ++ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) ++ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) ++ self?.saveBasalRates(basalRates: syncedSchedule) ++ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ print("New profile loaded") ++ self?.setCurrentProfile(name: profile.name, syncChange: false) ++ completion(.success) ++ } ++ case .failure(let error): ++ print("An error occurred while syncing basal rates: \(error)") ++ completion(.failure(error)) ++ } ++ }) ++ } ++ } ++ } ++ ++ func getProfileReference(withName name: String) -> ProfileReference? { ++ let sanitizedInputName = sanitizeProfileName(name) ++ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) ++ } ++ ++ public func removeProfile(profile: Profile) { ++ guard let profileReference = getProfileReference(withName: profile.name) else { ++ print("No ProfileReference found for given Profile") ++ return ++ } ++ ++ removeProfile(profileReference: profileReference) ++ if getCurrentProfileName() == profile.name { ++ clearCurrentProfile() ++ } ++ } ++ ++ public func removeProfile(profileReference: ProfileReference) { ++ do { ++ let fileManager = FileManager.default ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try fileManager.removeItem(at: fileURL) ++ loadProfiles() ++ } catch { ++ print("An error occurred while removing profile: \(error)") ++ } ++ } ++ ++ func doesProfileExist(withName name: String) -> Bool { ++ return profiles.contains(where: { $0.name == name }) ++ } ++ ++ public func getProfile(from profileReference: ProfileReference) throws -> Profile { ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ let data = try Data(contentsOf: fileURL) ++ let getProfile = try decodeProfile(from: data) ++ return getProfile ++ } ++ ++ public func validateProfile(_ profile: Profile) -> Result { ++ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { ++ return .failure(.fileLoadError) ++ } ++ ++ // Checking Correction Range Schedule ++ for item in profile.correctionRange.items { ++ let minValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) ++ let maxValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) ++ ++ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { ++ return .failure(.correctionRangeError) ++ } ++ } ++ ++ // Checking Insulin Sensitivity Schedule ++ for item in profile.insulinSensitivitySchedule.items { ++ let value = HKQuantity(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: .internationalUnit()), doubleValue: item.value) ++ ++ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { ++ return .failure(.insulinSensitivityError) ++ } ++ } ++ ++ // Checking Carb Ratio Schedule ++ for item in profile.carbRatioSchedule.items { ++ let value = HKQuantity(unit: .gramsPerUnit, doubleValue: item.value) ++ ++ if !Guardrail.carbRatio.absoluteBounds.contains(value) { ++ return .failure(.carbRatioError) ++ } ++ } ++ ++ // Checking Basal Rate Schedule ++ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { ++ for item in profile.basalRateSchedule.items { ++ let value = item.value ++ ++ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { ++ return .failure(.basalRateError) ++ } ++ } ++ } else { ++ return .failure(.maxBasalRateNotSet) ++ } ++ ++ return .success(()) ++ } ++ ++ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value.minValue - item2.value.minValue) > 0.01 || ++ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value - item2.value) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ public func isCurrentProfileOutOfSync() -> Bool { ++ guard let currentProfileName = self.currentProfileName, ++ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), ++ let profile = try? getProfile(from: currentProfileReference), ++ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: HKUnit.milligramsPerDeciliter), ++ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: HKUnit.milligramsPerDeciliter) else { ++ return false // No current profile set or profile not found or schedules not available ++ } ++ ++ var isOutOfSync = false ++ ++ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: HKUnit.milligramsPerDeciliter)!) { ++ print("Glucose target range schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ print("Carb ratio schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ print("Basal rate schedule differs") ++ isOutOfSync = true ++ } ++ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: HKUnit.milligramsPerDeciliter)!) { ++ print("Insulin sensitivity schedule differs") ++ isOutOfSync = true ++ } ++ ++ return isOutOfSync ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +new file mode 100644 +index 00000000..a48fa667 +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +@@ -0,0 +1,86 @@ ++// ++// ProfileViewModel.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import Combine ++import LoopKit ++import HealthKit ++import SwiftUI ++ ++public class ProfileViewModel: ObservableObject { ++ ++ @Published public var therapySettings: TherapySettings ++ @Published public var profiles = [ProfileReference]() ++ @Published public var currentProfileName: String? { ++ didSet { ++ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") ++ } ++ } ++ ++ internal weak var delegate: TherapySettingsViewModelDelegate? ++ ++ public init(therapySettings: TherapySettings, ++ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, ++ sensitivityOverridesEnabled: Bool = false, ++ adultChildInsulinModelSelectionEnabled: Bool = false, ++ prescription: Prescription? = nil, ++ delegate: TherapySettingsViewModelDelegate? = nil) { ++ self.therapySettings = therapySettings ++ self.delegate = delegate ++ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") ++ self.loadProfiles() ++ } ++ ++ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return delegate?.pumpSupportedIncrements() ++ } ++} ++ ++public struct Profile: Codable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ var sortOrder: Int? ++} ++ ++public struct ProfileReference: Codable, Equatable { ++ var name: String ++ var fileName: String ++ var sortOrder: Int? ++} ++ ++ ++// MARK: Saving ++extension ProfileViewModel { ++ ++ public func saveCorrectionRange(range: GlucoseRangeSchedule) { ++ therapySettings.glucoseTargetRangeSchedule = range ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveBasalRates(basalRates: BasalRateSchedule) { ++ therapySettings.basalRateSchedule = basalRates ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { ++ therapySettings.carbRatioSchedule = carbRatioSchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { ++ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func triggerProfileSync() { ++ delegate?.updateCurrentProfileName() ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +index 3650ef60..c5c1dc01 100644 +--- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift ++++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { + func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) + func saveCompletion(therapySettings: TherapySettings) + func pumpSupportedIncrements() -> PumpSupportedIncrements? ++ func updateCurrentProfileName() + } + + public class TherapySettingsViewModel: ObservableObject { +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +new file mode 100644 +index 00000000..446910e4 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +@@ -0,0 +1,79 @@ ++// ++// NewProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct NewProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var newProfileName: String ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("New Profile") ++ .font(.headline) ++ }, ++ trailing: { addButton } ++ ) ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var addButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Add") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +new file mode 100644 +index 00000000..6de3598f +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +@@ -0,0 +1,326 @@ ++// ++// ProfilePreviewView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-23. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++import HealthKit ++import LoopKit ++ ++enum ActiveAlert { ++ case load ++ case delete ++ case error ++} ++ ++struct ProfilePreviewView: View { ++ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference ++ @ObservedObject var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @Environment(\.presentationMode) var presentationMode ++ var profile: Profile ++ ++ @State private var showingAlert = false ++ @State private var activeAlert: ActiveAlert = .load ++ @State private var errorText: String? ++ @State private var newProfileName: String = "" ++ @State private var isRenamingProfile = false ++ @State private var shouldDismissSelf: Bool = false ++ ++ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } ++ ++ var body: some View { ++ ZStack { ++ ScrollView { ++ VStack(alignment: .leading) { ++ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ .font(.body) ++ .foregroundColor(.primary) ++ .padding([.leading, .trailing]) ++ .fixedSize(horizontal: false, vertical: true) ++ .textCase(nil) ++ ++ profileCardStack ++ ++ Button(action: { ++ activeAlert = .load ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Load") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.blue) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ isRenamingProfile = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Rename") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.orange) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ activeAlert = .delete ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Delete") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.red) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ } ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .navigationTitle(Text(profile.name)) ++ .alert(isPresented: $showingAlert) { ++ switch activeAlert { ++ case .load: ++ return Alert( ++ title: Text("Load Profile"), ++ message: Text("Do you want to load the profile \(profile.name)?"), ++ primaryButton: .default(Text("Yes"), action: { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .delete: ++ return Alert( ++ title: Text("Delete Profile"), ++ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), ++ primaryButton: .destructive(Text("Delete"), action: { ++ viewModel.removeProfile(profile: profile) ++ self.presentationMode.wrappedValue.dismiss() ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .error: ++ return Alert( ++ title: Text("Error loading profile"), ++ message: Text(errorText ?? "Unknown error"), ++ dismissButton: .default(Text("OK")) ++ ) ++ } ++ } ++ if isRenamingProfile { ++ // Some hacks here to mimic Alert behaviour. ++ // This function should be migrateed to Alert when iOS 16 is required. ++ Color.black.opacity(0.5) ++ .edgesIgnoringSafeArea(.all) ++ .onTapGesture { ++ isRenamingProfile = false ++ } ++ ++ RenameProfileEditor( ++ isPresented: $isRenamingProfile, ++ currentProfileName: profile.name, ++ viewModel: viewModel, ++ shouldDismissParent: $shouldDismissSelf ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ .offset(y: -22) ++ } ++ } ++ .onChange(of: shouldDismissSelf) { shouldDismiss in ++ if shouldDismiss { ++ self.presentationMode.wrappedValue.dismiss() ++ } ++ } ++ } ++ ++ private var profileCardStack: CardStack { ++ var cards: [Card] = [] ++ ++ cards.append(correctionRangeSection) ++ cards.append(carbRatioSection) ++ cards.append(basalRatesSection) ++ cards.append(insulinSensitivitiesSection) ++ ++ return CardStack(cards: cards) ++ } ++ ++ private var correctionRangeSection: Card { ++ card(for: .glucoseTargetRange) { ++ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleRangeItem(time: items[index].startTime, ++ range: items[index].value, ++ unit: glucoseUnit, ++ guardrail: .correctionRange) ++ } ++ } ++ } ++ } ++ ++ private var carbRatioSection: Card { ++ card(for: .carbRatio) { ++ SectionDivider() ++ let items = profile.carbRatioSchedule.items ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .gramsPerUnit, ++ guardrail: .carbRatio) ++ } ++ } ++ } ++ ++ private var basalRatesSection: Card { ++ card(for: .basalRate) { ++ let items = profile.basalRateSchedule.items ++ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates ++ { ++ SectionDivider() ++ let total = profile.basalRateSchedule.total() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .internationalUnitsPerHour, ++ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) ++ } ++ HStack { ++ Text("Total") ++ .bold() ++ .foregroundColor(.primary) ++ Spacer() ++ Text(String(format: "%.2f ",total)) ++ .foregroundColor(.primary) + ++ Text("U/day") ++ .foregroundColor(.secondary) ++ } ++ } ++ } ++ } ++ ++ private var insulinSensitivitiesSection: Card { ++ card(for: .insulinSensitivity) { ++ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items ++ { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: sensitivityUnit, ++ guardrail: .insulinSensitivity) ++ } ++ } ++ } ++ } ++} ++ ++ ++struct SectionWithContent: View where Content: View { ++ let title: String ++ let content: Content ++ ++ init(title: String, ++ content: () -> Content) ++ { ++ self.title = title ++ self.content = content() ++ } ++ ++ public var body: some View { ++ Section { ++ Text(title) ++ .bold() ++ .frame(maxWidth: .infinity, alignment: .leading) ++ content ++ } ++ } ++} ++ ++// MARK: Utilities ++extension ProfilePreviewView { ++ ++ private var glucoseUnit: HKUnit { ++ displayGlucosePreference.unit ++ } ++ ++ private var sensitivityUnit: HKUnit { ++ glucoseUnit.unitDivided(by: .internationalUnit()) ++ } ++ ++ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { ++ Card { ++ SectionWithContent(title: therapySetting.title, ++ content: content) ++ } ++ } ++} ++ ++fileprivate struct SectionDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -16) ++ } ++} ++ ++fileprivate struct SettingsDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -8) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +new file mode 100644 +index 00000000..1c6244a4 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +@@ -0,0 +1,173 @@ ++// ++// ProfileView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import AVFoundation ++import HealthKit ++import LoopKit ++import SwiftUI ++ ++public struct ProfileView: View { ++ @ObservedObject public var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @State private var newProfileName: String = "" ++ @State private var isAddingNewProfile = false ++ @State private var selectedProfileIndex: Int? = nil ++ @State private var refreshID = UUID() ++ ++ enum ActiveAlert: String, Identifiable { ++ case update, info ++ ++ var id: String { ++ return self.rawValue ++ } ++ } ++ @State private var activeAlert: ActiveAlert? ++ ++ public init(viewModel: ProfileViewModel) { ++ self.viewModel = viewModel ++ } ++ ++ private var dismissButton: some View { ++ Button(action: dismissAction) { ++ Text(LocalizedString("Done", comment: "Text for dismiss button")) ++ .bold() ++ } ++ } ++ ++ public var body: some View { ++ ZStack { ++ NavigationView { ++ ConfigurationPageScrollView( ++ content: { ++ VStack(alignment: .leading) { ++ ++ if !viewModel.profiles.isEmpty { ++ List { ++ ForEach(viewModel.profiles.indices, id: \.self) { index in ++ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { ++ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { ++ HStack { ++ if viewModel.currentProfileName == viewModel.profiles[index].name { ++ Image(systemName: "checkmark") ++ .foregroundColor(Color.blue) ++ } else { ++ // Add indentation to align with the checkmark for the active profile ++ Image(systemName: "checkmark") ++ .opacity(0) // Invisible ++ } ++ ++ Text(viewModel.profiles[index].name) ++ } ++ } ++ } ++ } ++ .onMove(perform: moveProfile) ++ }.id(refreshID) ++ ++ } else { ++ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") ++ .font(.subheadline) ++ .foregroundColor(.secondary) ++ .padding([.leading, .trailing]) ++ .multilineTextAlignment(.leading) ++ .lineLimit(nil) ++ .textCase(nil) ++ } ++ } ++ }, ++ actionArea: { EmptyView() } // no action area in this case ++ ) ++ .navigationBarItems( ++ leading: HStack { ++ Button(action: { withAnimation { isAddingNewProfile = true } }) { ++ Image(systemName: "plus") ++ } ++ ++ Button(action: { activeAlert = .info }) { ++ Image(systemName: "info.circle") ++ .resizable() ++ .frame(width: 24, height: 24) ++ } ++ }, ++ trailing: dismissButton ++ ) ++ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .onAppear { ++ if viewModel.isCurrentProfileOutOfSync() { ++ activeAlert = .update ++ } ++ } ++ ++ .alert(item: $activeAlert) { alertType in ++ switch alertType { ++ case .update: ++ return Alert( ++ title: Text("Profile Outdated"), ++ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), ++ primaryButton: .default(Text("Update")) { ++ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") ++ }, ++ secondaryButton: .cancel() ++ ) ++ case .info: ++ return Alert(title: Text("Information"), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ dismissButton: .default(Text("Got it!"))) ++ } ++ } ++ ++ if isAddingNewProfile { ++ DarkenedOverlay() ++ ++ NewProfileEditor( ++ isPresented: $isAddingNewProfile, ++ newProfileName: newProfileName, ++ viewModel: viewModel ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ } ++ } ++ } ++ ++ func moveProfile(from source: IndexSet, to destination: Int) { ++ viewModel.profiles.move(fromOffsets: source, toOffset: destination) ++ refreshID = UUID() ++ viewModel.updateProfilesOrder() ++ } ++} ++ ++struct ProfileView_Previews: PreviewProvider { ++ static let preview_glucoseScheduleItems = [ ++ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), ++ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), ++ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) ++ ] ++ ++ static let preview_therapySettings = TherapySettings( ++ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), ++ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), ++ workout: DoubleRange(99...111), ++ unit: .milligramsPerDeciliter), ++ maximumBolus: 4, ++ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), ++ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: HKUnit.internationalUnit()), dailyItems: []), ++ carbRatioSchedule: nil, ++ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) ++ ++ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] ++ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] ++ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] ++ ++ static var previews: some View { ++ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +new file mode 100644 +index 00000000..8fd26665 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +@@ -0,0 +1,92 @@ ++// ++// RenameProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-09-07. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct RenameProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var currentProfileName: String ++ @State var newProfileName: String = "" ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ @Binding var shouldDismissParent: Bool ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("Rename Profile") ++ .font(.subheadline) ++ ++ }, ++ trailing: { renameButton } ++ ) ++ .onAppear { ++ self.newProfileName = currentProfileName ++ } ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var renameButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ guard newProfileName != currentProfileName else { ++ self.isPresented = false ++ return ++ } ++ ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Rename") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +index cc61f2d..6462e2c 100644 +--- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift ++++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +@@ -379,6 +379,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { + maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount + ) + } ++ ++ func updateCurrentProfileName() { ++ } + } + + +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +index 2adadb9..a1769d8 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +@@ -94,12 +94,20 @@ extension StoredSettings { + return nil + } + ++ var store = ["Default": profile] ++ var defaultProfile = "Default" ++ ++ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { ++ defaultProfile = name ++ store[name] = profile ++ } ++ + return ProfileSet( + startDate: date, + units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), + enteredBy: "Loop", +- defaultProfile: "Default", +- store: ["Default": profile], ++ defaultProfile: defaultProfile, ++ store: store, + settings: loopSettings, + syncIdentifier: syncIdentifier.uuidString, + isAPNSProduction: Bundle.main.isAPNSProductionEnvironment) +diff --git a/profiles.loopcustomization b/profiles.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/profiles/archive/dev_profiles_noremote.patch b/profiles/archive/dev_profiles_noremote.patch new file mode 100644 index 0000000..d6d69a0 --- /dev/null +++ b/profiles/archive/dev_profiles_noremote.patch @@ -0,0 +1,1433 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index cabca1e6..e7316b68 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -1673,6 +1673,10 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { + } + } + ++ func updateCurrentProfileName() { ++ loopManager.updateCurrentProfileName() ++ } ++ + func saveCompletion(therapySettings: TherapySettings) { + + loopManager.mutateSettings { settings in +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index c9aef285..d0d2b312 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -244,6 +244,10 @@ final class LoopDataManager { + lockedSettings.value + } + ++ func updateCurrentProfileName () { ++ notify(forChange: .preferences) ++ } ++ + func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { + var oldValue: LoopSettings! + let newValue = lockedSettings.mutate { settings in +diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift +index 9f9e3aa0..c1c9ea87 100644 +--- a/Loop/Loop/Views/SettingsView.swift ++++ b/Loop/Loop/Views/SettingsView.swift +@@ -50,6 +50,7 @@ public struct SettingsView: View { + } + + case favoriteFoods ++ case profiles + } + } + +@@ -119,6 +120,19 @@ public struct SettingsView: View { + switch sheet { + case .favoriteFoods: + FavoriteFoodsView() ++ case .profiles: ++ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), ++ sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, ++ adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, ++ delegate: self.viewModel.therapySettingsViewModelDelegate)) ++ .environmentObject(displayGlucosePreference) ++ .environment(\.dismissAction, self.dismiss) ++ .environment(\.appName, self.appName) ++ .environment(\.chartColorPalette, .primary) ++ .environment(\.carbTintColor, self.carbTintColor) ++ .environment(\.glucoseTintColor, self.glucoseTintColor) ++ .environment(\.guidanceColors, self.guidanceColors) ++ .environment(\.insulinTintColor, self.insulinTintColor) + } + } + } +@@ -279,6 +293,11 @@ extension SettingsView { + descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) + } + ++ LargeButton(action: { sheet = .profiles }, ++ includeArrow: true, ++ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), ++ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), ++ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) + ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in + item.view + } +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj +index e4423ce6..89045896 100644 +--- a/LoopKit/LoopKit.xcodeproj/project.pbxproj ++++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj +@@ -873,6 +873,12 @@ + D1A0B000000000000000000C /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; + D1A0B000000000000000000D /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; + D1A0B0000000000000000081 /* PersistenceControllerMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */; }; ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; + E9077D2724ACD59F0066A88D /* InformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9077D2624ACD59F0066A88D /* InformationView.swift */; }; + E9077D2A24ACDE2C0066A88D /* CorrectionRangeInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9077D2924ACDE2C0066A88D /* CorrectionRangeInformationView.swift */; }; + E9086B2924B39EDC0062F5C8 /* ChartsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9086B2824B39EDC0062F5C8 /* ChartsTableViewController.swift */; }; +@@ -1813,6 +1819,12 @@ + D1A0B0000000000000000006 /* Modelv5ToModelv6.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = Modelv5ToModelv6.xcmappingmodel; sourceTree = ""; }; + D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelMappingv4v5Tov6Policy.swift; sourceTree = ""; }; + D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; ++ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; + E9077D2624ACD59F0066A88D /* InformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InformationView.swift; sourceTree = ""; }; + E9077D2924ACDE2C0066A88D /* CorrectionRangeInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionRangeInformationView.swift; sourceTree = ""; }; + E9086B2824B39EDC0062F5C8 /* ChartsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartsTableViewController.swift; sourceTree = ""; }; +@@ -3009,11 +3021,13 @@ + isa = PBXGroup; + children = ( + B455F3A325FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift */, ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, + B4B7C1BB2604E3FC007379F6 /* CorrectionRangeScheduleEditorViewModel.swift */, + B4D4C20C25F95A8700DA809D /* DisplayGlucosePreference.swift */, + B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, + B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, + 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, + ); + path = ViewModels; + sourceTree = ""; +@@ -3201,6 +3215,10 @@ + E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, + 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, + 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, ++ DDAF746629F422C600719F0A /* ProfileView.swift */, ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, + ); + path = "Settings Editors"; + sourceTree = ""; +@@ -3910,6 +3928,7 @@ + E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, + B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, + 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, + 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, + E9E5E56A24D5CCE800B5DFFE /* OverrideViewCell.swift in Sources */, + 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, +@@ -3919,6 +3938,7 @@ + B43D69D62A26642300DF0925 /* DemoPlaceHolderView.swift in Sources */, + E949E38F24B3711E00024DA0 /* InsulinModelInformationView.swift in Sources */, + 895FE08B22011F0C00FCF18A /* EmojiInputHeaderView.swift in Sources */, ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, + 895FE08322011F0C00FCF18A /* OverrideSelectionFooterView.swift in Sources */, + 89AF78C22447E353002B4FCC /* Splat.swift in Sources */, + 893C9F8C2447DBD900CD4185 /* CardBuilder.swift in Sources */, +@@ -3969,9 +3989,11 @@ + E9C58A6E24DA65E400487A17 /* HistoricalOverrideDetailView.swift in Sources */, + 895FE08222011F0C00FCF18A /* LabeledTextFieldTableViewCell.swift in Sources */, + B43DA44224D9CD8500CAFF4E /* Environment+Colors.swift in Sources */, ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, + 892A5DB22231E191008961AB /* LoadingTableViewCell.swift in Sources */, + B4D3D4AD25B8A94E0085BA0F /* DisplayGlucoseUnitObserver.swift in Sources */, + A9D3FF102A6C19CA000C891D /* ChartAxisValueDoubleLog.swift in Sources */, ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, + E96DCB5A24AF74AC007117BC /* SuspendThresholdEditor.swift in Sources */, + E96DCB5824AEF50F007117BC /* SuspendThresholdInformationView.swift in Sources */, + A9D3FF0C2A6C198C000C891D /* CollectionType.swift in Sources */, +@@ -4047,6 +4069,7 @@ + E93C86B624D08CAD0073089B /* InsulinSensitivityInformationView.swift in Sources */, + C18733AF29B9492300519CDF /* Collection.swift in Sources */, + 892A5DA22231E137008961AB /* HUDProvider.swift in Sources */, ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, + 898E6E6C224194060019E459 /* UIColor.swift in Sources */, + 43BA717D201EE7090058961E /* GlucoseRangeTableViewCell.swift in Sources */, + B455F3A425FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift in Sources */, +@@ -4073,6 +4096,7 @@ + 1D1065E9282DC54700026A70 /* VideoPlayView.swift in Sources */, + E99A132E2557548300D3F5B3 /* SegmentedGaugeBar.swift in Sources */, + B455F31825FBEC5F000ED456 /* SuspendThresholdEditorViewModel.swift in Sources */, ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, + 892A5DB32231E191008961AB /* LevelHUDView.swift in Sources */, + B41A60B223D1DBC700636320 /* UIFont.swift in Sources */, + B41A60AF23D1DB5B00636320 /* TableViewTitleLabel.swift in Sources */, +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +new file mode 100644 +index 00000000..74724fb7 +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +@@ -0,0 +1,408 @@ ++// ++// ProfileViewModel+FileManagement.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import LoopKit ++import HealthKit ++ ++public enum ProfileValidationError: Error, LocalizedError { ++ case fileLoadError ++ case unsupportedIncrement ++ case correctionRangeError ++ case insulinSensitivityError ++ case carbRatioError ++ case basalRateError ++ case maxBasalRateNotSet ++ ++ public var errorDescription: String? { ++ switch self { ++ case .fileLoadError: ++ return "Unable to load the profile file." ++ case .unsupportedIncrement: ++ return "The profile contains unsupported increments." ++ case .correctionRangeError: ++ return "Correction Range values are out of bounds." ++ case .insulinSensitivityError: ++ return "Insulin Sensitivity values are out of bounds." ++ case .carbRatioError: ++ return "Carb Ratio values are out of bounds." ++ case .basalRateError: ++ return "Basal Rate values are out of bounds." ++ case .maxBasalRateNotSet: ++ return "Maximum Basal Rate is not set." ++ } ++ } ++} ++ ++public enum LoadProfileResult { ++ case success ++ case failure(Error) ++} ++ ++// MARK: File management ++extension ProfileViewModel { ++ private func setCurrentProfile(name: String, syncChange: Bool = false) { ++ let sanitizedProfileName = sanitizeProfileName(name) ++ currentProfileName = sanitizedProfileName ++ if syncChange { ++ triggerProfileSync() ++ } ++ } ++ ++ private func clearCurrentProfile() { ++ currentProfileName = nil ++ triggerProfileSync() ++ } ++ ++ private func getCurrentProfileName() -> String? { ++ return currentProfileName ++ } ++ ++ private var profilesDirectory: URL { ++ let fileManager = FileManager.default ++ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] ++ return documentsDirectory.appendingPathComponent("LoopProfile") ++ } ++ ++ private func createProfileDirectoryIfNotExists() throws { ++ let fileManager = FileManager.default ++ var isDir : ObjCBool = false ++ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { ++ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) ++ } else if !isDir.boolValue { ++ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) ++ } ++ } ++ ++ private func getAllProfileFiles() throws -> [URL] { ++ let fileManager = FileManager.default ++ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) ++ return files.filter { $0.pathExtension == "json" } ++ } ++ ++ private func encodeProfile(_ profile: Profile) throws -> Data { ++ return try JSONEncoder().encode(profile) ++ } ++ ++ private func decodeProfile(from data: Data) throws -> Profile { ++ return try JSONDecoder().decode(Profile.self, from: data) ++ } ++ ++ public func sanitizeProfileName(_ name: String) -> String { ++ // Replace problematic characters ++ var sanitized = name.replacingOccurrences(of: ".", with: "_") ++ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") ++ ++ // Limit the name to 32 characters ++ if sanitized.count > 32 { ++ sanitized = String(sanitized.prefix(32)) ++ } ++ ++ return sanitized ++ } ++ ++ public func saveProfile(withName name: String) { ++ let sanitizedName = sanitizeProfileName(name) ++ ++ do { ++ try createProfileDirectoryIfNotExists() ++ ++ if let existingProfile = getProfileReference(withName: sanitizedName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let profile = Profile( ++ name: sanitizedName, ++ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ carbRatioSchedule: therapySettings.carbRatioSchedule!, ++ basalRateSchedule: therapySettings.basalRateSchedule!, ++ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ sortOrder: -1 ++ ) ++ ++ let jsonData = try encodeProfile(profile) ++ ++ let dateFormatter = DateFormatter() ++ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" ++ let fileName = dateFormatter.string(from: Date()) + ".json" ++ let fileURL = profilesDirectory.appendingPathComponent(fileName) ++ ++ try jsonData.write(to: fileURL) ++ setCurrentProfile(name: sanitizedName, syncChange: true) ++ ++ self.loadProfiles() ++ } catch { ++ print("An error occurred while saving profile: \(error)") ++ } ++ } ++ ++ public func renameProfile(oldName: String, newName: String) { ++ let sanitizedProfileName = sanitizeProfileName(newName) ++ ++ if currentProfileName == oldName { ++ setCurrentProfile(name: sanitizedProfileName, syncChange: true) ++ } ++ ++ do { ++ guard let profileReference = getProfileReference(withName: oldName) else { ++ print("Profile with old name not found.") ++ return ++ } ++ ++ let profile = try getProfile(from: profileReference) ++ ++ let newProfile = Profile( ++ name: sanitizedProfileName, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ ++ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ ++ try jsonData.write(to: fileURL) ++ ++ self.loadProfiles() ++ } catch { ++ print("An error occurred while renaming profile: \(error)") ++ } ++ } ++ ++ public func updateProfilesOrder() { ++ for (index, profileReference) in profiles.enumerated() { ++ do { ++ var profile = try getProfile(from: profileReference) ++ let newProfile = Profile( ++ name: profile.name, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: index ++ ) ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try jsonData.write(to: fileURL) ++ ++ } catch { ++ print("An error occurred while updating profile order: \(error)") ++ } ++ } ++ loadProfiles() ++ } ++ ++ public func loadProfiles() { ++ do { ++ let profileFiles = try getAllProfileFiles() ++ ++ var newProfiles = [ProfileReference]() ++ for fileURL in profileFiles { ++ let data = try Data(contentsOf: fileURL) ++ let profile = try decodeProfile(from: data) ++ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) ++ newProfiles.append(profileRef) ++ } ++ ++ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } ++ ++ self.profiles = newProfiles ++ } catch { ++ print("An error occurred while loading profiles: \(error)") ++ } ++ } ++ ++ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { ++ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && ++ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && ++ therapySettings.basalRateSchedule == profile.basalRateSchedule && ++ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { ++ self.setCurrentProfile(name: profile.name, syncChange: true) ++ completion(.success) ++ } ++ else ++ { ++ do { ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ switch result { ++ case .success(let syncedSchedule): ++ DispatchQueue.main.async { ++ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) ++ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) ++ self?.saveBasalRates(basalRates: syncedSchedule) ++ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ print("New profile loaded") ++ self?.setCurrentProfile(name: profile.name, syncChange: false) ++ completion(.success) ++ } ++ case .failure(let error): ++ print("An error occurred while syncing basal rates: \(error)") ++ completion(.failure(error)) ++ } ++ }) ++ } ++ } ++ } ++ ++ func getProfileReference(withName name: String) -> ProfileReference? { ++ let sanitizedInputName = sanitizeProfileName(name) ++ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) ++ } ++ ++ public func removeProfile(profile: Profile) { ++ guard let profileReference = getProfileReference(withName: profile.name) else { ++ print("No ProfileReference found for given Profile") ++ return ++ } ++ ++ removeProfile(profileReference: profileReference) ++ if getCurrentProfileName() == profile.name { ++ clearCurrentProfile() ++ } ++ } ++ ++ public func removeProfile(profileReference: ProfileReference) { ++ do { ++ let fileManager = FileManager.default ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try fileManager.removeItem(at: fileURL) ++ loadProfiles() ++ } catch { ++ print("An error occurred while removing profile: \(error)") ++ } ++ } ++ ++ func doesProfileExist(withName name: String) -> Bool { ++ return profiles.contains(where: { $0.name == name }) ++ } ++ ++ public func getProfile(from profileReference: ProfileReference) throws -> Profile { ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ let data = try Data(contentsOf: fileURL) ++ let getProfile = try decodeProfile(from: data) ++ return getProfile ++ } ++ ++ public func validateProfile(_ profile: Profile) -> Result { ++ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { ++ return .failure(.fileLoadError) ++ } ++ ++ // Checking Correction Range Schedule ++ for item in profile.correctionRange.items { ++ let minValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) ++ let maxValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) ++ ++ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { ++ return .failure(.correctionRangeError) ++ } ++ } ++ ++ // Checking Insulin Sensitivity Schedule ++ for item in profile.insulinSensitivitySchedule.items { ++ let value = HKQuantity(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: .internationalUnit()), doubleValue: item.value) ++ ++ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { ++ return .failure(.insulinSensitivityError) ++ } ++ } ++ ++ // Checking Carb Ratio Schedule ++ for item in profile.carbRatioSchedule.items { ++ let value = HKQuantity(unit: .gramsPerUnit, doubleValue: item.value) ++ ++ if !Guardrail.carbRatio.absoluteBounds.contains(value) { ++ return .failure(.carbRatioError) ++ } ++ } ++ ++ // Checking Basal Rate Schedule ++ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { ++ for item in profile.basalRateSchedule.items { ++ let value = item.value ++ ++ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { ++ return .failure(.basalRateError) ++ } ++ } ++ } else { ++ return .failure(.maxBasalRateNotSet) ++ } ++ ++ return .success(()) ++ } ++ ++ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value.minValue - item2.value.minValue) > 0.01 || ++ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value - item2.value) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ public func isCurrentProfileOutOfSync() -> Bool { ++ guard let currentProfileName = self.currentProfileName, ++ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), ++ let profile = try? getProfile(from: currentProfileReference), ++ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: HKUnit.milligramsPerDeciliter), ++ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: HKUnit.milligramsPerDeciliter) else { ++ return false // No current profile set or profile not found or schedules not available ++ } ++ ++ var isOutOfSync = false ++ ++ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: HKUnit.milligramsPerDeciliter)!) { ++ print("Glucose target range schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ print("Carb ratio schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ print("Basal rate schedule differs") ++ isOutOfSync = true ++ } ++ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: HKUnit.milligramsPerDeciliter)!) { ++ print("Insulin sensitivity schedule differs") ++ isOutOfSync = true ++ } ++ ++ return isOutOfSync ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +new file mode 100644 +index 00000000..a48fa667 +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +@@ -0,0 +1,86 @@ ++// ++// ProfileViewModel.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import Combine ++import LoopKit ++import HealthKit ++import SwiftUI ++ ++public class ProfileViewModel: ObservableObject { ++ ++ @Published public var therapySettings: TherapySettings ++ @Published public var profiles = [ProfileReference]() ++ @Published public var currentProfileName: String? { ++ didSet { ++ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") ++ } ++ } ++ ++ internal weak var delegate: TherapySettingsViewModelDelegate? ++ ++ public init(therapySettings: TherapySettings, ++ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, ++ sensitivityOverridesEnabled: Bool = false, ++ adultChildInsulinModelSelectionEnabled: Bool = false, ++ prescription: Prescription? = nil, ++ delegate: TherapySettingsViewModelDelegate? = nil) { ++ self.therapySettings = therapySettings ++ self.delegate = delegate ++ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") ++ self.loadProfiles() ++ } ++ ++ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return delegate?.pumpSupportedIncrements() ++ } ++} ++ ++public struct Profile: Codable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ var sortOrder: Int? ++} ++ ++public struct ProfileReference: Codable, Equatable { ++ var name: String ++ var fileName: String ++ var sortOrder: Int? ++} ++ ++ ++// MARK: Saving ++extension ProfileViewModel { ++ ++ public func saveCorrectionRange(range: GlucoseRangeSchedule) { ++ therapySettings.glucoseTargetRangeSchedule = range ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveBasalRates(basalRates: BasalRateSchedule) { ++ therapySettings.basalRateSchedule = basalRates ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { ++ therapySettings.carbRatioSchedule = carbRatioSchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { ++ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func triggerProfileSync() { ++ delegate?.updateCurrentProfileName() ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +index 3650ef60..c5c1dc01 100644 +--- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift ++++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { + func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) + func saveCompletion(therapySettings: TherapySettings) + func pumpSupportedIncrements() -> PumpSupportedIncrements? ++ func updateCurrentProfileName() + } + + public class TherapySettingsViewModel: ObservableObject { +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +new file mode 100644 +index 00000000..446910e4 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +@@ -0,0 +1,79 @@ ++// ++// NewProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct NewProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var newProfileName: String ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("New Profile") ++ .font(.headline) ++ }, ++ trailing: { addButton } ++ ) ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var addButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Add") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +new file mode 100644 +index 00000000..6de3598f +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +@@ -0,0 +1,326 @@ ++// ++// ProfilePreviewView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-23. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++import HealthKit ++import LoopKit ++ ++enum ActiveAlert { ++ case load ++ case delete ++ case error ++} ++ ++struct ProfilePreviewView: View { ++ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference ++ @ObservedObject var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @Environment(\.presentationMode) var presentationMode ++ var profile: Profile ++ ++ @State private var showingAlert = false ++ @State private var activeAlert: ActiveAlert = .load ++ @State private var errorText: String? ++ @State private var newProfileName: String = "" ++ @State private var isRenamingProfile = false ++ @State private var shouldDismissSelf: Bool = false ++ ++ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } ++ ++ var body: some View { ++ ZStack { ++ ScrollView { ++ VStack(alignment: .leading) { ++ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ .font(.body) ++ .foregroundColor(.primary) ++ .padding([.leading, .trailing]) ++ .fixedSize(horizontal: false, vertical: true) ++ .textCase(nil) ++ ++ profileCardStack ++ ++ Button(action: { ++ activeAlert = .load ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Load") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.blue) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ isRenamingProfile = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Rename") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.orange) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ activeAlert = .delete ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Delete") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.red) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ } ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .navigationTitle(Text(profile.name)) ++ .alert(isPresented: $showingAlert) { ++ switch activeAlert { ++ case .load: ++ return Alert( ++ title: Text("Load Profile"), ++ message: Text("Do you want to load the profile \(profile.name)?"), ++ primaryButton: .default(Text("Yes"), action: { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .delete: ++ return Alert( ++ title: Text("Delete Profile"), ++ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), ++ primaryButton: .destructive(Text("Delete"), action: { ++ viewModel.removeProfile(profile: profile) ++ self.presentationMode.wrappedValue.dismiss() ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .error: ++ return Alert( ++ title: Text("Error loading profile"), ++ message: Text(errorText ?? "Unknown error"), ++ dismissButton: .default(Text("OK")) ++ ) ++ } ++ } ++ if isRenamingProfile { ++ // Some hacks here to mimic Alert behaviour. ++ // This function should be migrateed to Alert when iOS 16 is required. ++ Color.black.opacity(0.5) ++ .edgesIgnoringSafeArea(.all) ++ .onTapGesture { ++ isRenamingProfile = false ++ } ++ ++ RenameProfileEditor( ++ isPresented: $isRenamingProfile, ++ currentProfileName: profile.name, ++ viewModel: viewModel, ++ shouldDismissParent: $shouldDismissSelf ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ .offset(y: -22) ++ } ++ } ++ .onChange(of: shouldDismissSelf) { shouldDismiss in ++ if shouldDismiss { ++ self.presentationMode.wrappedValue.dismiss() ++ } ++ } ++ } ++ ++ private var profileCardStack: CardStack { ++ var cards: [Card] = [] ++ ++ cards.append(correctionRangeSection) ++ cards.append(carbRatioSection) ++ cards.append(basalRatesSection) ++ cards.append(insulinSensitivitiesSection) ++ ++ return CardStack(cards: cards) ++ } ++ ++ private var correctionRangeSection: Card { ++ card(for: .glucoseTargetRange) { ++ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleRangeItem(time: items[index].startTime, ++ range: items[index].value, ++ unit: glucoseUnit, ++ guardrail: .correctionRange) ++ } ++ } ++ } ++ } ++ ++ private var carbRatioSection: Card { ++ card(for: .carbRatio) { ++ SectionDivider() ++ let items = profile.carbRatioSchedule.items ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .gramsPerUnit, ++ guardrail: .carbRatio) ++ } ++ } ++ } ++ ++ private var basalRatesSection: Card { ++ card(for: .basalRate) { ++ let items = profile.basalRateSchedule.items ++ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates ++ { ++ SectionDivider() ++ let total = profile.basalRateSchedule.total() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .internationalUnitsPerHour, ++ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) ++ } ++ HStack { ++ Text("Total") ++ .bold() ++ .foregroundColor(.primary) ++ Spacer() ++ Text(String(format: "%.2f ",total)) ++ .foregroundColor(.primary) + ++ Text("U/day") ++ .foregroundColor(.secondary) ++ } ++ } ++ } ++ } ++ ++ private var insulinSensitivitiesSection: Card { ++ card(for: .insulinSensitivity) { ++ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items ++ { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: sensitivityUnit, ++ guardrail: .insulinSensitivity) ++ } ++ } ++ } ++ } ++} ++ ++ ++struct SectionWithContent: View where Content: View { ++ let title: String ++ let content: Content ++ ++ init(title: String, ++ content: () -> Content) ++ { ++ self.title = title ++ self.content = content() ++ } ++ ++ public var body: some View { ++ Section { ++ Text(title) ++ .bold() ++ .frame(maxWidth: .infinity, alignment: .leading) ++ content ++ } ++ } ++} ++ ++// MARK: Utilities ++extension ProfilePreviewView { ++ ++ private var glucoseUnit: HKUnit { ++ displayGlucosePreference.unit ++ } ++ ++ private var sensitivityUnit: HKUnit { ++ glucoseUnit.unitDivided(by: .internationalUnit()) ++ } ++ ++ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { ++ Card { ++ SectionWithContent(title: therapySetting.title, ++ content: content) ++ } ++ } ++} ++ ++fileprivate struct SectionDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -16) ++ } ++} ++ ++fileprivate struct SettingsDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -8) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +new file mode 100644 +index 00000000..1c6244a4 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +@@ -0,0 +1,173 @@ ++// ++// ProfileView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import AVFoundation ++import HealthKit ++import LoopKit ++import SwiftUI ++ ++public struct ProfileView: View { ++ @ObservedObject public var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @State private var newProfileName: String = "" ++ @State private var isAddingNewProfile = false ++ @State private var selectedProfileIndex: Int? = nil ++ @State private var refreshID = UUID() ++ ++ enum ActiveAlert: String, Identifiable { ++ case update, info ++ ++ var id: String { ++ return self.rawValue ++ } ++ } ++ @State private var activeAlert: ActiveAlert? ++ ++ public init(viewModel: ProfileViewModel) { ++ self.viewModel = viewModel ++ } ++ ++ private var dismissButton: some View { ++ Button(action: dismissAction) { ++ Text(LocalizedString("Done", comment: "Text for dismiss button")) ++ .bold() ++ } ++ } ++ ++ public var body: some View { ++ ZStack { ++ NavigationView { ++ ConfigurationPageScrollView( ++ content: { ++ VStack(alignment: .leading) { ++ ++ if !viewModel.profiles.isEmpty { ++ List { ++ ForEach(viewModel.profiles.indices, id: \.self) { index in ++ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { ++ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { ++ HStack { ++ if viewModel.currentProfileName == viewModel.profiles[index].name { ++ Image(systemName: "checkmark") ++ .foregroundColor(Color.blue) ++ } else { ++ // Add indentation to align with the checkmark for the active profile ++ Image(systemName: "checkmark") ++ .opacity(0) // Invisible ++ } ++ ++ Text(viewModel.profiles[index].name) ++ } ++ } ++ } ++ } ++ .onMove(perform: moveProfile) ++ }.id(refreshID) ++ ++ } else { ++ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") ++ .font(.subheadline) ++ .foregroundColor(.secondary) ++ .padding([.leading, .trailing]) ++ .multilineTextAlignment(.leading) ++ .lineLimit(nil) ++ .textCase(nil) ++ } ++ } ++ }, ++ actionArea: { EmptyView() } // no action area in this case ++ ) ++ .navigationBarItems( ++ leading: HStack { ++ Button(action: { withAnimation { isAddingNewProfile = true } }) { ++ Image(systemName: "plus") ++ } ++ ++ Button(action: { activeAlert = .info }) { ++ Image(systemName: "info.circle") ++ .resizable() ++ .frame(width: 24, height: 24) ++ } ++ }, ++ trailing: dismissButton ++ ) ++ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .onAppear { ++ if viewModel.isCurrentProfileOutOfSync() { ++ activeAlert = .update ++ } ++ } ++ ++ .alert(item: $activeAlert) { alertType in ++ switch alertType { ++ case .update: ++ return Alert( ++ title: Text("Profile Outdated"), ++ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), ++ primaryButton: .default(Text("Update")) { ++ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") ++ }, ++ secondaryButton: .cancel() ++ ) ++ case .info: ++ return Alert(title: Text("Information"), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ dismissButton: .default(Text("Got it!"))) ++ } ++ } ++ ++ if isAddingNewProfile { ++ DarkenedOverlay() ++ ++ NewProfileEditor( ++ isPresented: $isAddingNewProfile, ++ newProfileName: newProfileName, ++ viewModel: viewModel ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ } ++ } ++ } ++ ++ func moveProfile(from source: IndexSet, to destination: Int) { ++ viewModel.profiles.move(fromOffsets: source, toOffset: destination) ++ refreshID = UUID() ++ viewModel.updateProfilesOrder() ++ } ++} ++ ++struct ProfileView_Previews: PreviewProvider { ++ static let preview_glucoseScheduleItems = [ ++ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), ++ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), ++ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) ++ ] ++ ++ static let preview_therapySettings = TherapySettings( ++ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), ++ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), ++ workout: DoubleRange(99...111), ++ unit: .milligramsPerDeciliter), ++ maximumBolus: 4, ++ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), ++ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: HKUnit.internationalUnit()), dailyItems: []), ++ carbRatioSchedule: nil, ++ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) ++ ++ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] ++ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] ++ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] ++ ++ static var previews: some View { ++ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +new file mode 100644 +index 00000000..8fd26665 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +@@ -0,0 +1,92 @@ ++// ++// RenameProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-09-07. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct RenameProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var currentProfileName: String ++ @State var newProfileName: String = "" ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ @Binding var shouldDismissParent: Bool ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("Rename Profile") ++ .font(.subheadline) ++ ++ }, ++ trailing: { renameButton } ++ ) ++ .onAppear { ++ self.newProfileName = currentProfileName ++ } ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var renameButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ guard newProfileName != currentProfileName else { ++ self.isPresented = false ++ return ++ } ++ ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Rename") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +Submodule LoopOnboarding contains modified content +diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +index cc61f2d..6462e2c 100644 +--- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift ++++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +@@ -379,6 +379,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { + maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount + ) + } ++ ++ func updateCurrentProfileName() { ++ } + } + + +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +index 2adadb9..a1769d8 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +@@ -94,12 +94,20 @@ extension StoredSettings { + return nil + } + ++ var store = ["Default": profile] ++ var defaultProfile = "Default" ++ ++ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { ++ defaultProfile = name ++ store[name] = profile ++ } ++ + return ProfileSet( + startDate: date, + units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), + enteredBy: "Loop", +- defaultProfile: "Default", +- store: ["Default": profile], ++ defaultProfile: defaultProfile, ++ store: store, + settings: loopSettings, + syncIdentifier: syncIdentifier.uuidString, + isAPNSProduction: Bundle.main.isAPNSProductionEnvironment) +diff --git a/profiles.loopcustomization b/profiles.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/profiles/archive/nextdev_profiles_noremote.patch b/profiles/archive/nextdev_profiles_noremote.patch new file mode 100644 index 0000000..ce9bf2d --- /dev/null +++ b/profiles/archive/nextdev_profiles_noremote.patch @@ -0,0 +1,1395 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index 3c9d61cb..6b70853e 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -1407,6 +1407,12 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { + return try await pumpManager?.syncDeliveryLimits(limits: deliveryLimits) ?? deliveryLimits + } + ++ func updateCurrentProfileName() { ++ NotificationCenter.default.post(name: .LoopDataUpdated, ++ object: nil, ++ userInfo: [LoopDataManager.LoopUpdateContextKey: LoopUpdateContext.preferences.rawValue]) ++ } ++ + func saveCompletion(therapySettings: TherapySettings) { + settingsManager.mutateLoopSettings { settings in + settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index 30ab8fb2..28f07307 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -1684,7 +1684,7 @@ extension LoopDataManager: ManualDoseViewModelDelegate { + var settings: StoredSettings { + settingsProvider.settings + } +- ++ + var scheduleOverride: TemporaryScheduleOverride? { + temporaryPresetsManager.scheduleOverride + } +diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift +index 1faefbc5..737acf28 100644 +--- a/Loop/Loop/Views/SettingsView.swift ++++ b/Loop/Loop/Views/SettingsView.swift +@@ -55,6 +55,7 @@ struct SettingsView: View { + + case favoriteFoods + case presets ++ case profiles + } + } + +@@ -155,6 +156,9 @@ struct SettingsView: View { + } + case .favoriteFoods: + FavoriteFoodsView(insightsDelegate: viewModel.favoriteFoodInsightsDelegate) ++ case .profiles: ++ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), ++ delegate: self.viewModel.therapySettingsViewModelDelegate)) + } + } + .environmentObject(displayGlucosePreference) +@@ -416,6 +420,11 @@ extension SettingsView { + .accessibilityIdentifier("button_TherapySettings") + } + ++ LargeButton(action: { sheet = .profiles }, ++ includeArrow: true, ++ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), ++ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), ++ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) + ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in + item.view + } +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj +index cc88ad17..c543b73a 100644 +--- a/LoopKit/LoopKit.xcodeproj/project.pbxproj ++++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj +@@ -7,6 +7,12 @@ + objects = { + + /* Begin PBXBuildFile section */ ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; + 9D3D2DA3B9824EF48981F0E4 /* StoredInsulinModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CAB9E826A3254800A57273 /* StoredInsulinModel.swift */; }; + 485ACF65C30744BFBC979CEF /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A985464E251449FE0099C1A6 /* NotificationSettings.swift */; }; + 2383C3D7967B4602B8EB4A5B /* SettingsObject+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = A912BE2A245B9E8600CBE199 /* SettingsObject+CoreDataProperties.swift */; }; +@@ -1065,6 +1071,12 @@ + /* End PBXCopyFilesBuildPhase section */ + + /* Begin PBXFileReference section */ ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; ++ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; + D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; + 142CB75C2A60C01E0075748A /* ListButtonStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListButtonStyle.swift; sourceTree = ""; }; + 142CB7602A60C0440075748A /* FavoriteFood.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FavoriteFood.swift; sourceTree = ""; }; +@@ -2971,6 +2983,8 @@ + B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, + B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, + 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, + ); + path = ViewModels; + sourceTree = ""; +@@ -3146,6 +3160,10 @@ + E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, + 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, + 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, ++ DDAF746629F422C600719F0A /* ProfileView.swift */, ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, + ); + path = "Settings Editors"; + sourceTree = ""; +@@ -3815,6 +3833,12 @@ + E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, + B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, + 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, + 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, + 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, + 892A5DA02231E130008961AB /* CompletionNotifying.swift in Sources */, +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +new file mode 100644 +index 00000000..cd8d48b8 +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +@@ -0,0 +1,409 @@ ++// ++// ProfileViewModel+FileManagement.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import LoopKit ++import HealthKit ++import LoopAlgorithm ++ ++public enum ProfileValidationError: Error, LocalizedError { ++ case fileLoadError ++ case unsupportedIncrement ++ case correctionRangeError ++ case insulinSensitivityError ++ case carbRatioError ++ case basalRateError ++ case maxBasalRateNotSet ++ ++ public var errorDescription: String? { ++ switch self { ++ case .fileLoadError: ++ return "Unable to load the profile file." ++ case .unsupportedIncrement: ++ return "The profile contains unsupported increments." ++ case .correctionRangeError: ++ return "Correction Range values are out of bounds." ++ case .insulinSensitivityError: ++ return "Insulin Sensitivity values are out of bounds." ++ case .carbRatioError: ++ return "Carb Ratio values are out of bounds." ++ case .basalRateError: ++ return "Basal Rate values are out of bounds." ++ case .maxBasalRateNotSet: ++ return "Maximum Basal Rate is not set." ++ } ++ } ++} ++ ++public enum LoadProfileResult { ++ case success ++ case failure(Error) ++} ++ ++// MARK: File management ++extension ProfileViewModel { ++ private func setCurrentProfile(name: String, syncChange: Bool = false) { ++ let sanitizedProfileName = sanitizeProfileName(name) ++ currentProfileName = sanitizedProfileName ++ if syncChange { ++ triggerProfileSync() ++ } ++ } ++ ++ private func clearCurrentProfile() { ++ currentProfileName = nil ++ triggerProfileSync() ++ } ++ ++ private func getCurrentProfileName() -> String? { ++ return currentProfileName ++ } ++ ++ private var profilesDirectory: URL { ++ let fileManager = FileManager.default ++ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] ++ return documentsDirectory.appendingPathComponent("LoopProfile") ++ } ++ ++ private func createProfileDirectoryIfNotExists() throws { ++ let fileManager = FileManager.default ++ var isDir : ObjCBool = false ++ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { ++ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) ++ } else if !isDir.boolValue { ++ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) ++ } ++ } ++ ++ private func getAllProfileFiles() throws -> [URL] { ++ let fileManager = FileManager.default ++ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) ++ return files.filter { $0.pathExtension == "json" } ++ } ++ ++ private func encodeProfile(_ profile: Profile) throws -> Data { ++ return try JSONEncoder().encode(profile) ++ } ++ ++ private func decodeProfile(from data: Data) throws -> Profile { ++ return try JSONDecoder().decode(Profile.self, from: data) ++ } ++ ++ public func sanitizeProfileName(_ name: String) -> String { ++ // Replace problematic characters ++ var sanitized = name.replacingOccurrences(of: ".", with: "_") ++ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") ++ ++ // Limit the name to 32 characters ++ if sanitized.count > 32 { ++ sanitized = String(sanitized.prefix(32)) ++ } ++ ++ return sanitized ++ } ++ ++ public func saveProfile(withName name: String) { ++ let sanitizedName = sanitizeProfileName(name) ++ ++ do { ++ try createProfileDirectoryIfNotExists() ++ ++ if let existingProfile = getProfileReference(withName: sanitizedName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let profile = Profile( ++ name: sanitizedName, ++ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ carbRatioSchedule: therapySettings.carbRatioSchedule!, ++ basalRateSchedule: therapySettings.basalRateSchedule!, ++ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ sortOrder: -1 ++ ) ++ ++ let jsonData = try encodeProfile(profile) ++ ++ let dateFormatter = DateFormatter() ++ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" ++ let fileName = dateFormatter.string(from: Date()) + ".json" ++ let fileURL = profilesDirectory.appendingPathComponent(fileName) ++ ++ try jsonData.write(to: fileURL) ++ setCurrentProfile(name: sanitizedName, syncChange: true) ++ ++ self.loadProfiles() ++ } catch { ++ print("An error occurred while saving profile: \(error)") ++ } ++ } ++ ++ public func renameProfile(oldName: String, newName: String) { ++ let sanitizedProfileName = sanitizeProfileName(newName) ++ ++ if currentProfileName == oldName { ++ setCurrentProfile(name: sanitizedProfileName, syncChange: true) ++ } ++ ++ do { ++ guard let profileReference = getProfileReference(withName: oldName) else { ++ print("Profile with old name not found.") ++ return ++ } ++ ++ let profile = try getProfile(from: profileReference) ++ ++ let newProfile = Profile( ++ name: sanitizedProfileName, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ ++ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ ++ try jsonData.write(to: fileURL) ++ ++ self.loadProfiles() ++ } catch { ++ print("An error occurred while renaming profile: \(error)") ++ } ++ } ++ ++ public func updateProfilesOrder() { ++ for (index, profileReference) in profiles.enumerated() { ++ do { ++ var profile = try getProfile(from: profileReference) ++ let newProfile = Profile( ++ name: profile.name, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: index ++ ) ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try jsonData.write(to: fileURL) ++ ++ } catch { ++ print("An error occurred while updating profile order: \(error)") ++ } ++ } ++ loadProfiles() ++ } ++ ++ public func loadProfiles() { ++ do { ++ let profileFiles = try getAllProfileFiles() ++ ++ var newProfiles = [ProfileReference]() ++ for fileURL in profileFiles { ++ let data = try Data(contentsOf: fileURL) ++ let profile = try decodeProfile(from: data) ++ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) ++ newProfiles.append(profileRef) ++ } ++ ++ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } ++ ++ self.profiles = newProfiles ++ } catch { ++ print("An error occurred while loading profiles: \(error)") ++ } ++ } ++ ++ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { ++ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && ++ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && ++ therapySettings.basalRateSchedule == profile.basalRateSchedule && ++ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { ++ self.setCurrentProfile(name: profile.name, syncChange: true) ++ completion(.success) ++ } ++ else ++ { ++ do { ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ switch result { ++ case .success(let syncedSchedule): ++ DispatchQueue.main.async { ++ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter ++ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) ++ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) ++ self?.saveBasalRates(basalRates: syncedSchedule) ++ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ print("New profile loaded") ++ self?.setCurrentProfile(name: profile.name, syncChange: false) ++ completion(.success) ++ } ++ case .failure(let error): ++ print("An error occurred while syncing basal rates: \(error)") ++ completion(.failure(error)) ++ } ++ }) ++ } ++ } ++ } ++ ++ func getProfileReference(withName name: String) -> ProfileReference? { ++ let sanitizedInputName = sanitizeProfileName(name) ++ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) ++ } ++ ++ public func removeProfile(profile: Profile) { ++ guard let profileReference = getProfileReference(withName: profile.name) else { ++ print("No ProfileReference found for given Profile") ++ return ++ } ++ ++ removeProfile(profileReference: profileReference) ++ if getCurrentProfileName() == profile.name { ++ clearCurrentProfile() ++ } ++ } ++ ++ public func removeProfile(profileReference: ProfileReference) { ++ do { ++ let fileManager = FileManager.default ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try fileManager.removeItem(at: fileURL) ++ loadProfiles() ++ } catch { ++ print("An error occurred while removing profile: \(error)") ++ } ++ } ++ ++ func doesProfileExist(withName name: String) -> Bool { ++ return profiles.contains(where: { $0.name == name }) ++ } ++ ++ public func getProfile(from profileReference: ProfileReference) throws -> Profile { ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ let data = try Data(contentsOf: fileURL) ++ let getProfile = try decodeProfile(from: data) ++ return getProfile ++ } ++ ++ public func validateProfile(_ profile: Profile) -> Result { ++ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { ++ return .failure(.fileLoadError) ++ } ++ ++ // Checking Correction Range Schedule ++ for item in profile.correctionRange.items { ++ let minValue = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) ++ let maxValue = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) ++ ++ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { ++ return .failure(.correctionRangeError) ++ } ++ } ++ ++ // Checking Insulin Sensitivity Schedule ++ for item in profile.insulinSensitivitySchedule.items { ++ let value = LoopQuantity(unit: .milligramsPerDeciliterPerInternationalUnit, doubleValue: item.value) ++ ++ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { ++ return .failure(.insulinSensitivityError) ++ } ++ } ++ ++ // Checking Carb Ratio Schedule ++ for item in profile.carbRatioSchedule.items { ++ let value = LoopQuantity(unit: .gramsPerUnit, doubleValue: item.value) ++ ++ if !Guardrail.carbRatio.absoluteBounds.contains(value) { ++ return .failure(.carbRatioError) ++ } ++ } ++ ++ // Checking Basal Rate Schedule ++ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { ++ for item in profile.basalRateSchedule.items { ++ let value = item.value ++ ++ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { ++ return .failure(.basalRateError) ++ } ++ } ++ } else { ++ return .failure(.maxBasalRateNotSet) ++ } ++ ++ return .success(()) ++ } ++ ++ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value.minValue - item2.value.minValue) > 0.01 || ++ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value - item2.value) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ public func isCurrentProfileOutOfSync() -> Bool { ++ guard let currentProfileName = self.currentProfileName, ++ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), ++ let profile = try? getProfile(from: currentProfileReference), ++ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: LoopUnit.milligramsPerDeciliter), ++ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: LoopUnit.milligramsPerDeciliter) else { ++ return false // No current profile set or profile not found or schedules not available ++ } ++ ++ var isOutOfSync = false ++ ++ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: LoopUnit.milligramsPerDeciliter)!) { ++ print("Glucose target range schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ print("Carb ratio schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ print("Basal rate schedule differs") ++ isOutOfSync = true ++ } ++ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: LoopUnit.milligramsPerDeciliter)!) { ++ print("Insulin sensitivity schedule differs") ++ isOutOfSync = true ++ } ++ ++ return isOutOfSync ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +new file mode 100644 +index 00000000..a48fa667 +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +@@ -0,0 +1,86 @@ ++// ++// ProfileViewModel.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import Combine ++import LoopKit ++import HealthKit ++import SwiftUI ++ ++public class ProfileViewModel: ObservableObject { ++ ++ @Published public var therapySettings: TherapySettings ++ @Published public var profiles = [ProfileReference]() ++ @Published public var currentProfileName: String? { ++ didSet { ++ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") ++ } ++ } ++ ++ internal weak var delegate: TherapySettingsViewModelDelegate? ++ ++ public init(therapySettings: TherapySettings, ++ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, ++ sensitivityOverridesEnabled: Bool = false, ++ adultChildInsulinModelSelectionEnabled: Bool = false, ++ prescription: Prescription? = nil, ++ delegate: TherapySettingsViewModelDelegate? = nil) { ++ self.therapySettings = therapySettings ++ self.delegate = delegate ++ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") ++ self.loadProfiles() ++ } ++ ++ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return delegate?.pumpSupportedIncrements() ++ } ++} ++ ++public struct Profile: Codable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ var sortOrder: Int? ++} ++ ++public struct ProfileReference: Codable, Equatable { ++ var name: String ++ var fileName: String ++ var sortOrder: Int? ++} ++ ++ ++// MARK: Saving ++extension ProfileViewModel { ++ ++ public func saveCorrectionRange(range: GlucoseRangeSchedule) { ++ therapySettings.glucoseTargetRangeSchedule = range ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveBasalRates(basalRates: BasalRateSchedule) { ++ therapySettings.basalRateSchedule = basalRates ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { ++ therapySettings.carbRatioSchedule = carbRatioSchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { ++ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func triggerProfileSync() { ++ delegate?.updateCurrentProfileName() ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +index 81be1865..913c82ac 100644 +--- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift ++++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { + func syncDeliveryLimits(deliveryLimits: DeliveryLimits) async throws -> DeliveryLimits + func saveCompletion(therapySettings: TherapySettings) + func pumpSupportedIncrements() -> PumpSupportedIncrements? ++ func updateCurrentProfileName() + } + + @Observable +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +new file mode 100644 +index 00000000..446910e4 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +@@ -0,0 +1,79 @@ ++// ++// NewProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct NewProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var newProfileName: String ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("New Profile") ++ .font(.headline) ++ }, ++ trailing: { addButton } ++ ) ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var addButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Add") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +new file mode 100644 +index 00000000..3566055d +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +@@ -0,0 +1,334 @@ ++// ++// ProfilePreviewView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-23. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++import HealthKit ++import LoopKit ++import LoopAlgorithm ++ ++enum ActiveAlert { ++ case load ++ case delete ++ case error ++} ++ ++struct ProfilePreviewView: View { ++ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference ++ @ObservedObject var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @Environment(\.presentationMode) var presentationMode ++ var profile: Profile ++ ++ @State private var showingAlert = false ++ @State private var activeAlert: ActiveAlert = .load ++ @State private var errorText: String? ++ @State private var newProfileName: String = "" ++ @State private var isRenamingProfile = false ++ @State private var shouldDismissSelf: Bool = false ++ ++ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } ++ ++ var body: some View { ++ ZStack { ++ ScrollView { ++ VStack(alignment: .leading) { ++ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ .font(.body) ++ .foregroundColor(.primary) ++ .padding([.leading, .trailing]) ++ .fixedSize(horizontal: false, vertical: true) ++ .textCase(nil) ++ ++ profileCardStack ++ ++ Button(action: { ++ activeAlert = .load ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Load") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.blue) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ isRenamingProfile = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Rename") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.orange) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ activeAlert = .delete ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Delete") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.red) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ } ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .navigationTitle(Text(profile.name)) ++ .alert(isPresented: $showingAlert) { ++ switch activeAlert { ++ case .load: ++ return Alert( ++ title: Text("Load Profile"), ++ message: Text("Do you want to load the profile \(profile.name)?"), ++ primaryButton: .default(Text("Yes"), action: { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .delete: ++ return Alert( ++ title: Text("Delete Profile"), ++ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), ++ primaryButton: .destructive(Text("Delete"), action: { ++ viewModel.removeProfile(profile: profile) ++ self.presentationMode.wrappedValue.dismiss() ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .error: ++ return Alert( ++ title: Text("Error loading profile"), ++ message: Text(errorText ?? "Unknown error"), ++ dismissButton: .default(Text("OK")) ++ ) ++ } ++ } ++ if isRenamingProfile { ++ // Some hacks here to mimic Alert behaviour. ++ // This function should be migrateed to Alert when iOS 16 is required. ++ Color.black.opacity(0.5) ++ .edgesIgnoringSafeArea(.all) ++ .onTapGesture { ++ isRenamingProfile = false ++ } ++ ++ RenameProfileEditor( ++ isPresented: $isRenamingProfile, ++ currentProfileName: profile.name, ++ viewModel: viewModel, ++ shouldDismissParent: $shouldDismissSelf ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ .offset(y: -22) ++ } ++ } ++ .onChange(of: shouldDismissSelf) { shouldDismiss in ++ if shouldDismiss { ++ self.presentationMode.wrappedValue.dismiss() ++ } ++ } ++ } ++ ++ private var profileCardStack: CardStack { ++ var cards: [Card] = [] ++ ++ cards.append(correctionRangeSection) ++ cards.append(carbRatioSection) ++ cards.append(basalRatesSection) ++ cards.append(insulinSensitivitiesSection) ++ ++ return CardStack(cards: cards) ++ } ++ ++ private var correctionRangeSection: Card { ++ card(for: .glucoseTargetRange) { ++ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleRangeItem(time: items[index].startTime, ++ range: items[index].value, ++ unit: glucoseUnit, ++ guardrail: .correctionRange) ++ } ++ } ++ } ++ } ++ ++ private var carbRatioSection: Card { ++ card(for: .carbRatio) { ++ SectionDivider() ++ let items = profile.carbRatioSchedule.items ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .gramsPerUnit, ++ guardrail: .carbRatio) ++ } ++ } ++ } ++ ++ private var basalRatesSection: Card { ++ card(for: .basalRate) { ++ let items = profile.basalRateSchedule.items ++ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates ++ { ++ SectionDivider() ++ let total = profile.basalRateSchedule.total() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .internationalUnitsPerHour, ++ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) ++ } ++ HStack { ++ Text("Total") ++ .bold() ++ .foregroundColor(.primary) ++ Spacer() ++ Text(String(format: "%.2f ",total)) ++ .foregroundColor(.primary) + ++ Text("U/day") ++ .foregroundColor(.secondary) ++ } ++ } ++ } ++ } ++ ++ private var insulinSensitivitiesSection: Card { ++ card(for: .insulinSensitivity) { ++ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items ++ { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: sensitivityUnit, ++ guardrail: .insulinSensitivity) ++ } ++ } ++ } ++ } ++} ++ ++ ++struct SectionWithContent: View where Content: View { ++ let title: String ++ let content: Content ++ ++ init(title: String, ++ content: () -> Content) ++ { ++ self.title = title ++ self.content = content() ++ } ++ ++ public var body: some View { ++ Section { ++ Text(title) ++ .bold() ++ .frame(maxWidth: .infinity, alignment: .leading) ++ content ++ } ++ } ++} ++ ++// MARK: Utilities ++extension ProfilePreviewView { ++ ++ private var glucoseUnit: LoopUnit { ++ displayGlucosePreference.unit ++ } ++ ++ private var sensitivityUnit: LoopUnit { ++ switch glucoseUnit { ++ case .milligramsPerDeciliter: ++ return .milligramsPerDeciliterPerInternationalUnit ++ case .millimolesPerLiter: ++ return .millimolesPerLiterPerInternationalUnit ++ default: ++ return .milligramsPerDeciliterPerInternationalUnit ++ } ++ } ++ ++ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { ++ Card { ++ SectionWithContent(title: therapySetting.title, ++ content: content) ++ } ++ } ++} ++ ++fileprivate struct SectionDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -16) ++ } ++} ++ ++fileprivate struct SettingsDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -8) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +new file mode 100644 +index 00000000..a1f35678 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +@@ -0,0 +1,172 @@ ++// ++// ProfileView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import AVFoundation ++import HealthKit ++import LoopKit ++import SwiftUI ++ ++public struct ProfileView: View { ++ @ObservedObject public var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @State private var newProfileName: String = "" ++ @State private var isAddingNewProfile = false ++ @State private var selectedProfileIndex: Int? = nil ++ @State private var refreshID = UUID() ++ ++ enum ActiveAlert: String, Identifiable { ++ case update, info ++ ++ var id: String { ++ return self.rawValue ++ } ++ } ++ @State private var activeAlert: ActiveAlert? ++ ++ public init(viewModel: ProfileViewModel) { ++ self.viewModel = viewModel ++ } ++ ++ private var dismissButton: some View { ++ Button(action: dismissAction) { ++ Text(LocalizedString("Done", comment: "Text for dismiss button")) ++ .bold() ++ } ++ } ++ ++ public var body: some View { ++ ZStack { ++ NavigationView { ++ ConfigurationPageScrollView( ++ content: { ++ VStack(alignment: .leading) { ++ ++ if !viewModel.profiles.isEmpty { ++ List { ++ ForEach(viewModel.profiles.indices, id: \.self) { index in ++ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { ++ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { ++ HStack { ++ if viewModel.currentProfileName == viewModel.profiles[index].name { ++ Image(systemName: "checkmark") ++ .foregroundColor(Color.blue) ++ } else { ++ // Add indentation to align with the checkmark for the active profile ++ Image(systemName: "checkmark") ++ .opacity(0) // Invisible ++ } ++ ++ Text(viewModel.profiles[index].name) ++ } ++ } ++ } ++ } ++ .onMove(perform: moveProfile) ++ }.id(refreshID) ++ ++ } else { ++ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") ++ .font(.subheadline) ++ .foregroundColor(.secondary) ++ .padding([.leading, .trailing]) ++ .multilineTextAlignment(.leading) ++ .lineLimit(nil) ++ .textCase(nil) ++ } ++ } ++ }, ++ actionArea: { EmptyView() } // no action area in this case ++ ) ++ .navigationBarItems( ++ leading: HStack { ++ Button(action: { withAnimation { isAddingNewProfile = true } }) { ++ Image(systemName: "plus") ++ } ++ ++ Button(action: { activeAlert = .info }) { ++ Image(systemName: "info.circle") ++ .resizable() ++ .frame(width: 24, height: 24) ++ } ++ }, ++ trailing: dismissButton ++ ) ++ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .onAppear { ++ if viewModel.isCurrentProfileOutOfSync() { ++ activeAlert = .update ++ } ++ } ++ ++ .alert(item: $activeAlert) { alertType in ++ switch alertType { ++ case .update: ++ return Alert( ++ title: Text("Profile Outdated"), ++ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), ++ primaryButton: .default(Text("Update")) { ++ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") ++ }, ++ secondaryButton: .cancel() ++ ) ++ case .info: ++ return Alert(title: Text("Information"), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ dismissButton: .default(Text("Got it!"))) ++ } ++ } ++ ++ if isAddingNewProfile { ++ DarkenedOverlay() ++ ++ NewProfileEditor( ++ isPresented: $isAddingNewProfile, ++ newProfileName: newProfileName, ++ viewModel: viewModel ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ } ++ } ++ } ++ ++ func moveProfile(from source: IndexSet, to destination: Int) { ++ viewModel.profiles.move(fromOffsets: source, toOffset: destination) ++ refreshID = UUID() ++ viewModel.updateProfilesOrder() ++ } ++} ++ ++struct ProfileView_Previews: PreviewProvider { ++ static let preview_glucoseScheduleItems = [ ++ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), ++ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), ++ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) ++ ] ++ ++ static let preview_therapySettings = TherapySettings( ++ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), ++ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), ++ unit: .milligramsPerDeciliter), ++ maximumBolus: 4, ++ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), ++ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: .milligramsPerDeciliterPerInternationalUnit, dailyItems: []), ++ carbRatioSchedule: nil, ++ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) ++ ++ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] ++ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] ++ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] ++ ++ static var previews: some View { ++ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +new file mode 100644 +index 00000000..8fd26665 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +@@ -0,0 +1,92 @@ ++// ++// RenameProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-09-07. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct RenameProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var currentProfileName: String ++ @State var newProfileName: String = "" ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ @Binding var shouldDismissParent: Bool ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("Rename Profile") ++ .font(.subheadline) ++ ++ }, ++ trailing: { renameButton } ++ ) ++ .onAppear { ++ self.newProfileName = currentProfileName ++ } ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var renameButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ guard newProfileName != currentProfileName else { ++ self.isPresented = false ++ return ++ } ++ ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Rename") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +Submodule LoopOnboarding contains modified content +diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +index 1ddc82b..54421a1 100644 +--- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift ++++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +@@ -381,6 +381,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { + maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount + ) + } ++ ++ func updateCurrentProfileName() { ++ } + } + + +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +index 3142223..790330a 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +@@ -93,12 +93,20 @@ extension StoredSettings { + return nil + } + ++ var store = ["Default": profile] ++ var defaultProfile = "Default" ++ ++ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { ++ defaultProfile = name ++ store[name] = profile ++ } ++ + return ProfileSet( + startDate: date, + units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), + enteredBy: "Loop", +- defaultProfile: "Default", +- store: ["Default": profile], ++ defaultProfile: defaultProfile, ++ store: store, + settings: loopSettings, + syncIdentifier: syncIdentifier.uuidString, + isAPNSProduction: Bundle.main.isAPNSProductionEnvironment, +diff --git a/profiles.loopcustomization b/profiles.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/profiles/dev_profiles.patch b/profiles/dev_profiles.patch index d6d69a0..e3b30fd 100644 --- a/profiles/dev_profiles.patch +++ b/profiles/dev_profiles.patch @@ -1,9 +1,17 @@ Submodule Loop contains modified content diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index cabca1e6..e7316b68 100644 +index cabca1e6..3b5f1de2 100644 --- a/Loop/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -1673,6 +1673,10 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { +@@ -433,6 +433,7 @@ final class DeviceDataManager { + ) + + statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) ++ servicesManager.deviceDataManager = self + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, +@@ -1673,6 +1674,10 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { } } @@ -29,6 +37,121 @@ index c9aef285..d0d2b312 100644 func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { var oldValue: LoopSettings! let newValue = lockedSettings.mutate { settings in +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 996d1470..17cff75d 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -263,6 +263,38 @@ extension NotificationManager { + } + } + ++ static func sendRemoteProfileNotification(name: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfile", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteProfileFailureNotification(name: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ private static func remoteProfileNotificationBody(name: String) -> String { ++ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ } ++ + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { + return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + } +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 3fb788f9..c9d3e04f 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -28,6 +28,7 @@ class ServicesManager { + + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? ++ weak var deviceDataManager: DeviceDataManager? + + private var services = [Service]() + +@@ -412,3 +413,59 @@ extension Service { + ] + } + } ++ ++// MARK: - RemoteProfileActionDelegate ++ ++extension ServicesManager: RemoteProfileActionDelegate { ++ @MainActor ++ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { ++ do { ++ guard let deviceDataManager else { ++ throw RemoteProfileActionError.unavailable ++ } ++ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) ++ guard deviceDataManager.pumpSupportedIncrements() != nil else { ++ throw RemoteProfileActionError.noPump ++ } ++ guard let profile = try viewModel.savedProfile(named: name) else { ++ throw RemoteProfileActionError.profileNotFound(name) ++ } ++ try profile.verify(with: verify) ++ if case .failure(let error) = viewModel.validateProfile(profile) { ++ throw error ++ } ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ viewModel.triggerProfileSync() ++ NotificationManager.sendRemoteProfileNotification(name: name) ++ } catch { ++ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteProfileActionError: LocalizedError { ++ case unavailable ++ case noPump ++ case profileNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .unavailable: ++ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") ++ case .noPump: ++ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") ++ case .profileNotFound(let name): ++ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) ++ } ++ } ++ } ++} diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift index 9f9e3aa0..c1c9ea87 100644 --- a/Loop/Loop/Views/SettingsView.swift @@ -173,12 +296,29 @@ index e4423ce6..89045896 100644 892A5DB32231E191008961AB /* LevelHUDView.swift in Sources */, B41A60B223D1DBC700636320 /* UIFont.swift in Sources */, B41A60AF23D1DB5B00636320 /* TableViewTitleLabel.swift in Sources */, +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 7fdc53ad..00fe25ab 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -8,6 +8,12 @@ + + import Foundation + ++/// Switches to a saved therapy profile on a remote command. ++public protocol RemoteProfileActionDelegate: AnyObject { ++ /// `verify` receives the schedules about to be activated and throws to reject them. ++ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws ++} ++ + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws + func cancelRemoteOverride() async throws diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..74724fb7 +index 00000000..f48848cd --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,408 @@ +@@ -0,0 +1,426 @@ +// +// ProfileViewModel+FileManagement.swift +// LoopKitUI @@ -355,6 +495,7 @@ index 00000000..74724fb7 + try jsonData.write(to: fileURL) + + self.loadProfiles() ++ triggerProfileSync() + } catch { + print("An error occurred while renaming profile: \(error)") + } @@ -451,6 +592,8 @@ index 00000000..74724fb7 + removeProfile(profileReference: profileReference) + if getCurrentProfileName() == profile.name { + clearCurrentProfile() ++ } else { ++ triggerProfileSync() + } + } + @@ -476,6 +619,14 @@ index 00000000..74724fb7 + return getProfile + } + ++ /// The saved profile named `name`, or nil when there is none. ++ public func savedProfile(named name: String) throws -> Profile? { ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ return nil ++ } ++ return try getProfile(from: profileReference) ++ } ++ + public func validateProfile(_ profile: Profile) -> Result { + guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { + return .failure(.fileLoadError) @@ -587,6 +738,13 @@ index 00000000..74724fb7 + return isOutOfSync + } +} ++ ++extension Profile { ++ /// Passes the schedules this profile activates to `check`. ++ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { ++ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) ++ } ++} diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift new file mode 100644 index 00000000..a48fa667 @@ -1401,15 +1559,216 @@ index cc61f2d..6462e2c 100644 Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index 69f7a26..14a7691 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -24,6 +24,10 @@ + A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; +@@ -158,6 +162,10 @@ + A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; + A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; +@@ -268,6 +276,7 @@ + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, + A934B6172358F49300949C8B /* StoredSettings.swift */, ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, + A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, + A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, +@@ -324,6 +333,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -364,6 +374,7 @@ + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, + ); + path = Notifications; + sourceTree = ""; +@@ -412,6 +423,7 @@ + A9AA6E5729EB06AA008FFA78 /* Actions */ = { + isa = PBXGroup; + children = ( ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, +@@ -744,6 +756,9 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, + A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, + C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, + ); +@@ -757,6 +772,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, + A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +new file mode 100644 +index 0000000..de73ca9 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,104 @@ ++// ++// SavedProfile.swift ++// NightscoutServiceKit ++// ++ ++import CryptoKit ++import Foundation ++import HealthKit ++import LoopKit ++import NightscoutKit ++ ++/// A profile saved with the Profiles settings screen, read from the file it was saved to. ++struct SavedProfile: Decodable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ ++ private static var directory: URL { ++ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") ++ } ++ ++ static func loadAll() -> [SavedProfile] { ++ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] ++ return files ++ .filter { $0.pathExtension == "json" } ++ .compactMap { try? Data(contentsOf: $0) } ++ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } ++ } ++ ++ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. ++ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { ++ var store: [String: ProfileSet.Profile] = [:] ++ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. ++ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { ++ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) ++ } ++ return store ++ } ++ ++ /// True when this profile uploads with this fingerprint, in either glucose unit. ++ func matches(fingerprint: String) -> Bool { ++ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { ++ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint ++ } ++ } ++ ++ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { ++ guard let correctionRange = correctionRange.schedule(for: unit), ++ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) ++ else { ++ return nil ++ } ++ return ProfileSet.Profile( ++ timezone: basalRateSchedule.timeZone, ++ dia: .hours(6), ++ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, ++ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, ++ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) ++ } ++} ++ ++extension ProfileSet.Profile { ++ /// Followers compute the same value from the uploaded profile and send it with a switch command, ++ /// so Loop only activates the values the follower previewed. ++ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` ++ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. ++ var remoteFingerprint: String { ++ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { ++ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") ++ } ++ let canonical = [ ++ "basal=" + items(basal), ++ "carbratio=" + items(carbratio), ++ "sens=" + items(sensitivity), ++ "target_low=" + items(targetLow), ++ "target_high=" + items(targetHigh), ++ ].joined(separator: ";") ++ let digest = SHA256.hash(data: Data(canonical.utf8)) ++ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index 4e1d49e..b66fb97 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -159,7 +159,8 @@ extension StoredDosingDecision { + automaticDoseRecommendation: loopStatusAutomaticDoseRecommendation, + recommendedBolus: loopStatusRecommendedBolus, + enacted: automaticDoseDecision?.loopStatusEnacted, +- failureReason: automaticDoseDecision?.loopStatusFailureReason), ++ failureReason: automaticDoseDecision?.loopStatusFailureReason, ++ testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]]), + overrideStatus: overrideStatus) + } + diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -index 2adadb9..a1769d8 100644 +index 2adadb9..dd7ac28 100644 --- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -@@ -94,12 +94,20 @@ extension StoredSettings { +@@ -94,12 +94,21 @@ extension StoredSettings { return nil } -+ var store = ["Default": profile] ++ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) ++ store["Default"] = profile + var defaultProfile = "Default" + + if let name = UserDefaults.standard.string(forKey: "currentProfileName") { @@ -1428,6 +1787,246 @@ index 2adadb9..a1769d8 100644 settings: loopSettings, syncIdentifier: syncIdentifier.uuidString, isAPNSProduction: Bundle.main.isAPNSProductionEnvironment) +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index 4626630..ef69236 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -436,6 +436,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + success = true + message = "Override '\(overrideCommand.name)' enacted successfully" + ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ success = true ++ message = "Profile '\(profileSwitchCommand.name)' activated" ++ + case .cancelTemporaryOverride: + commandType = .cancelOverride + try await self.serviceDelegate?.cancelRemoteOverride() +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index a92b509..29313b6 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -9,6 +9,7 @@ + import Foundation + + public enum Action: Codable { ++ case profileSwitch(ProfileSwitchAction) + case temporaryScheduleOverride(OverrideAction) + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) +@@ -28,6 +29,8 @@ public enum Action: Codable { + return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") + case .temporaryScheduleOverride: + return LocalizedString("Override", comment: "The remote action name for Override") ++ case .profileSwitch: ++ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") + } + } + +@@ -43,6 +46,8 @@ public enum Action: Codable { + return "" + case .temporaryScheduleOverride(let overrideAction): + return overrideAction.name ++ case .profileSwitch(let profileSwitchAction): ++ return profileSwitchAction.name + } + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +new file mode 100644 +index 0000000..ce03ded +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +@@ -0,0 +1,17 @@ ++// ++// ProfileSwitchAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct ProfileSwitchAction: Codable { ++ ++ public let name: String ++ public let fingerprint: String ++ ++ public init(name: String, fingerprint: String) { ++ self.name = name ++ self.fingerprint = fingerprint ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 95274e4..4457f80 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -45,6 +45,7 @@ class RemoteNotificationResponseManager { + case carbs = "carbs" + case override = "override" + case cancelOverride = "cancel_override" ++ case profileSwitch = "profile_switch" + } + + func sendResponseNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +new file mode 100644 +index 0000000..487c620 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -0,0 +1,50 @@ ++// ++// ProfileSwitchRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Switches to a saved profile. `expiration` is required: decoding fails without it. ++public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ public let profileName: String ++ public let fingerprint: String ++ public let remoteAddress: String ++ public let expirationDate: Date ++ public let sentAt: Date? ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ public var expiration: Date? { ++ return expirationDate ++ } ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expirationDate = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification[commandKey] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 0c5f8e3..3362bf7 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -108,6 +108,8 @@ extension Dictionary { + return try OverrideRemoteNotification(dictionary: self) + } else if OverrideCancelRemoteNotification.includedInNotification(self) { + return try OverrideCancelRemoteNotification(dictionary: self) ++ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { ++ return try ProfileSwitchRemoteNotification(dictionary: self) + } else { + throw RemoteNotificationError.unhandledNotification(self) + } +diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..2fcec94 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +@@ -0,0 +1,71 @@ ++// ++// ProfileSwitchRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "sent-at": "2023-02-25T20:46:35.778Z", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ] ++ ++ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) ++ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ ++ XCTAssertEqual(profileSwitch.profileName, "Sport") ++ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertTrue(profileSwitch.otpValidationRequired()) ++ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { ++ return XCTFail("Expected a profile switch action") ++ } ++ XCTAssertEqual(action.name, "Sport") ++ } ++ ++ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { ++ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ let profile = ProfileSet.Profile( ++ timezone: TimeZone(secondsFromGMT: 3600)!, ++ dia: .hours(6), ++ sensitivity: items([(0, 45)]), ++ carbratio: items([(0, 10)]), ++ basal: items([(0, 0.85), (21600, 1.1)]), ++ targetLow: items([(0, 100)]), ++ targetHigh: items([(0, 110)]), ++ units: "mg/dL") ++ ++ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") ++ } ++} ++ ++private extension ISO8601DateFormatter { ++ static let fractional: ISO8601DateFormatter = { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ }() ++} diff --git a/profiles.loopcustomization b/profiles.loopcustomization new file mode 100644 index 0000000..e69de29 diff --git a/profiles/dev_profiles_basal_lock.patch b/profiles/dev_profiles_basal_lock.patch index 5737fa2..e5d457a 100644 --- a/profiles/dev_profiles_basal_lock.patch +++ b/profiles/dev_profiles_basal_lock.patch @@ -1,8 +1,17 @@ +Submodule Loop contains modified content diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index cabca1e6..e7316b68 100644 +index cabca1e6..3b5f1de2 100644 --- a/Loop/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -1673,6 +1673,10 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { +@@ -433,6 +433,7 @@ final class DeviceDataManager { + ) + + statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) ++ servicesManager.deviceDataManager = self + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, +@@ -1673,6 +1674,10 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { } } @@ -28,6 +37,121 @@ index 1a6e00d7..dd378316 100644 func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { var oldValue: LoopSettings! let newValue = lockedSettings.mutate { settings in +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 996d1470..17cff75d 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -263,6 +263,38 @@ extension NotificationManager { + } + } + ++ static func sendRemoteProfileNotification(name: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfile", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteProfileFailureNotification(name: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ private static func remoteProfileNotificationBody(name: String) -> String { ++ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ } ++ + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { + return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + } +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 3fb788f9..c9d3e04f 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -28,6 +28,7 @@ class ServicesManager { + + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? ++ weak var deviceDataManager: DeviceDataManager? + + private var services = [Service]() + +@@ -412,3 +413,59 @@ extension Service { + ] + } + } ++ ++// MARK: - RemoteProfileActionDelegate ++ ++extension ServicesManager: RemoteProfileActionDelegate { ++ @MainActor ++ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { ++ do { ++ guard let deviceDataManager else { ++ throw RemoteProfileActionError.unavailable ++ } ++ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) ++ guard deviceDataManager.pumpSupportedIncrements() != nil else { ++ throw RemoteProfileActionError.noPump ++ } ++ guard let profile = try viewModel.savedProfile(named: name) else { ++ throw RemoteProfileActionError.profileNotFound(name) ++ } ++ try profile.verify(with: verify) ++ if case .failure(let error) = viewModel.validateProfile(profile) { ++ throw error ++ } ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ viewModel.triggerProfileSync() ++ NotificationManager.sendRemoteProfileNotification(name: name) ++ } catch { ++ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteProfileActionError: LocalizedError { ++ case unavailable ++ case noPump ++ case profileNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .unavailable: ++ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") ++ case .noPump: ++ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") ++ case .profileNotFound(let name): ++ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) ++ } ++ } ++ } ++} diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift index f31763de..4df43b2c 100644 --- a/Loop/Loop/Views/SettingsView.swift @@ -72,6 +196,7 @@ index f31763de..4df43b2c 100644 ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in item.view } +Submodule LoopKit contains modified content diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj index c36bfda3..5e9ec17d 100644 --- a/LoopKit/LoopKit.xcodeproj/project.pbxproj @@ -172,12 +297,29 @@ index c36bfda3..5e9ec17d 100644 892A5DB32231E191008961AB /* LevelHUDView.swift in Sources */, B41A60B223D1DBC700636320 /* UIFont.swift in Sources */, B41A60AF23D1DB5B00636320 /* TableViewTitleLabel.swift in Sources */, +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 7fdc53ad..00fe25ab 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -8,6 +8,12 @@ + + import Foundation + ++/// Switches to a saved therapy profile on a remote command. ++public protocol RemoteProfileActionDelegate: AnyObject { ++ /// `verify` receives the schedules about to be activated and throws to reject them. ++ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws ++} ++ + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws + func cancelRemoteOverride() async throws diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..74724fb7 +index 00000000..f48848cd --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,408 @@ +@@ -0,0 +1,426 @@ +// +// ProfileViewModel+FileManagement.swift +// LoopKitUI @@ -354,6 +496,7 @@ index 00000000..74724fb7 + try jsonData.write(to: fileURL) + + self.loadProfiles() ++ triggerProfileSync() + } catch { + print("An error occurred while renaming profile: \(error)") + } @@ -450,6 +593,8 @@ index 00000000..74724fb7 + removeProfile(profileReference: profileReference) + if getCurrentProfileName() == profile.name { + clearCurrentProfile() ++ } else { ++ triggerProfileSync() + } + } + @@ -475,6 +620,14 @@ index 00000000..74724fb7 + return getProfile + } + ++ /// The saved profile named `name`, or nil when there is none. ++ public func savedProfile(named name: String) throws -> Profile? { ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ return nil ++ } ++ return try getProfile(from: profileReference) ++ } ++ + public func validateProfile(_ profile: Profile) -> Result { + guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { + return .failure(.fileLoadError) @@ -586,6 +739,13 @@ index 00000000..74724fb7 + return isOutOfSync + } +} ++ ++extension Profile { ++ /// Passes the schedules this profile activates to `check`. ++ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { ++ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) ++ } ++} diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift new file mode 100644 index 00000000..a48fa667 @@ -1384,6 +1544,7 @@ index 00000000..8fd26665 + ) + } +} +Submodule LoopOnboarding contains modified content diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift index cc61f2d..6462e2c 100644 --- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift @@ -1398,15 +1559,217 @@ index cc61f2d..6462e2c 100644 } +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index 69f7a26..14a7691 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -24,6 +24,10 @@ + A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; +@@ -158,6 +162,10 @@ + A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; + A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; +@@ -268,6 +276,7 @@ + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, + A934B6172358F49300949C8B /* StoredSettings.swift */, ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, + A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, + A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, +@@ -324,6 +333,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -364,6 +374,7 @@ + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, + ); + path = Notifications; + sourceTree = ""; +@@ -412,6 +423,7 @@ + A9AA6E5729EB06AA008FFA78 /* Actions */ = { + isa = PBXGroup; + children = ( ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, +@@ -744,6 +756,9 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, + A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, + C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, + ); +@@ -757,6 +772,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, + A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +new file mode 100644 +index 0000000..de73ca9 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,104 @@ ++// ++// SavedProfile.swift ++// NightscoutServiceKit ++// ++ ++import CryptoKit ++import Foundation ++import HealthKit ++import LoopKit ++import NightscoutKit ++ ++/// A profile saved with the Profiles settings screen, read from the file it was saved to. ++struct SavedProfile: Decodable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ ++ private static var directory: URL { ++ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") ++ } ++ ++ static func loadAll() -> [SavedProfile] { ++ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] ++ return files ++ .filter { $0.pathExtension == "json" } ++ .compactMap { try? Data(contentsOf: $0) } ++ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } ++ } ++ ++ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. ++ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { ++ var store: [String: ProfileSet.Profile] = [:] ++ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. ++ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { ++ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) ++ } ++ return store ++ } ++ ++ /// True when this profile uploads with this fingerprint, in either glucose unit. ++ func matches(fingerprint: String) -> Bool { ++ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { ++ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint ++ } ++ } ++ ++ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { ++ guard let correctionRange = correctionRange.schedule(for: unit), ++ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) ++ else { ++ return nil ++ } ++ return ProfileSet.Profile( ++ timezone: basalRateSchedule.timeZone, ++ dia: .hours(6), ++ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, ++ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, ++ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) ++ } ++} ++ ++extension ProfileSet.Profile { ++ /// Followers compute the same value from the uploaded profile and send it with a switch command, ++ /// so Loop only activates the values the follower previewed. ++ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` ++ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. ++ var remoteFingerprint: String { ++ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { ++ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") ++ } ++ let canonical = [ ++ "basal=" + items(basal), ++ "carbratio=" + items(carbratio), ++ "sens=" + items(sensitivity), ++ "target_low=" + items(targetLow), ++ "target_high=" + items(targetHigh), ++ ].joined(separator: ";") ++ let digest = SHA256.hash(data: Data(canonical.utf8)) ++ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index 4e1d49e..b66fb97 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -159,7 +159,8 @@ extension StoredDosingDecision { + automaticDoseRecommendation: loopStatusAutomaticDoseRecommendation, + recommendedBolus: loopStatusRecommendedBolus, + enacted: automaticDoseDecision?.loopStatusEnacted, +- failureReason: automaticDoseDecision?.loopStatusFailureReason), ++ failureReason: automaticDoseDecision?.loopStatusFailureReason, ++ testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]]), + overrideStatus: overrideStatus) + } + diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -index 2adadb9..a1769d8 100644 +index 2adadb9..dd7ac28 100644 --- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -@@ -94,12 +94,20 @@ extension StoredSettings { +@@ -94,12 +94,21 @@ extension StoredSettings { return nil } -+ var store = ["Default": profile] ++ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) ++ store["Default"] = profile + var defaultProfile = "Default" + + if let name = UserDefaults.standard.string(forKey: "currentProfileName") { @@ -1425,6 +1788,246 @@ index 2adadb9..a1769d8 100644 settings: loopSettings, syncIdentifier: syncIdentifier.uuidString, isAPNSProduction: Bundle.main.isAPNSProductionEnvironment) +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index 4626630..ef69236 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -436,6 +436,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + success = true + message = "Override '\(overrideCommand.name)' enacted successfully" + ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ success = true ++ message = "Profile '\(profileSwitchCommand.name)' activated" ++ + case .cancelTemporaryOverride: + commandType = .cancelOverride + try await self.serviceDelegate?.cancelRemoteOverride() +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index a92b509..29313b6 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -9,6 +9,7 @@ + import Foundation + + public enum Action: Codable { ++ case profileSwitch(ProfileSwitchAction) + case temporaryScheduleOverride(OverrideAction) + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) +@@ -28,6 +29,8 @@ public enum Action: Codable { + return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") + case .temporaryScheduleOverride: + return LocalizedString("Override", comment: "The remote action name for Override") ++ case .profileSwitch: ++ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") + } + } + +@@ -43,6 +46,8 @@ public enum Action: Codable { + return "" + case .temporaryScheduleOverride(let overrideAction): + return overrideAction.name ++ case .profileSwitch(let profileSwitchAction): ++ return profileSwitchAction.name + } + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +new file mode 100644 +index 0000000..ce03ded +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +@@ -0,0 +1,17 @@ ++// ++// ProfileSwitchAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct ProfileSwitchAction: Codable { ++ ++ public let name: String ++ public let fingerprint: String ++ ++ public init(name: String, fingerprint: String) { ++ self.name = name ++ self.fingerprint = fingerprint ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 95274e4..4457f80 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -45,6 +45,7 @@ class RemoteNotificationResponseManager { + case carbs = "carbs" + case override = "override" + case cancelOverride = "cancel_override" ++ case profileSwitch = "profile_switch" + } + + func sendResponseNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +new file mode 100644 +index 0000000..487c620 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -0,0 +1,50 @@ ++// ++// ProfileSwitchRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Switches to a saved profile. `expiration` is required: decoding fails without it. ++public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ public let profileName: String ++ public let fingerprint: String ++ public let remoteAddress: String ++ public let expirationDate: Date ++ public let sentAt: Date? ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ public var expiration: Date? { ++ return expirationDate ++ } ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expirationDate = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification[commandKey] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 0c5f8e3..3362bf7 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -108,6 +108,8 @@ extension Dictionary { + return try OverrideRemoteNotification(dictionary: self) + } else if OverrideCancelRemoteNotification.includedInNotification(self) { + return try OverrideCancelRemoteNotification(dictionary: self) ++ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { ++ return try ProfileSwitchRemoteNotification(dictionary: self) + } else { + throw RemoteNotificationError.unhandledNotification(self) + } +diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..2fcec94 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +@@ -0,0 +1,71 @@ ++// ++// ProfileSwitchRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "sent-at": "2023-02-25T20:46:35.778Z", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ] ++ ++ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) ++ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ ++ XCTAssertEqual(profileSwitch.profileName, "Sport") ++ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertTrue(profileSwitch.otpValidationRequired()) ++ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { ++ return XCTFail("Expected a profile switch action") ++ } ++ XCTAssertEqual(action.name, "Sport") ++ } ++ ++ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { ++ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ let profile = ProfileSet.Profile( ++ timezone: TimeZone(secondsFromGMT: 3600)!, ++ dia: .hours(6), ++ sensitivity: items([(0, 45)]), ++ carbratio: items([(0, 10)]), ++ basal: items([(0, 0.85), (21600, 1.1)]), ++ targetLow: items([(0, 100)]), ++ targetHigh: items([(0, 110)]), ++ units: "mg/dL") ++ ++ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") ++ } ++} ++ ++private extension ISO8601DateFormatter { ++ static let fractional: ISO8601DateFormatter = { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ }() ++} diff --git a/profiles.loopcustomization b/profiles.loopcustomization new file mode 100644 index 0000000..e69de29 diff --git a/profiles/dev_profiles_basal_lock_remote_carb_edit.patch b/profiles/dev_profiles_basal_lock_remote_carb_edit.patch new file mode 100644 index 0000000..18fafd5 --- /dev/null +++ b/profiles/dev_profiles_basal_lock_remote_carb_edit.patch @@ -0,0 +1,2032 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index cabca1e6..3b5f1de2 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -433,6 +433,7 @@ final class DeviceDataManager { + ) + + statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) ++ servicesManager.deviceDataManager = self + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, +@@ -1673,6 +1674,10 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { + } + } + ++ func updateCurrentProfileName() { ++ loopManager.updateCurrentProfileName() ++ } ++ + func saveCompletion(therapySettings: TherapySettings) { + + loopManager.mutateSettings { settings in +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index 16959e56..42c17bb1 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -244,6 +244,10 @@ final class LoopDataManager { + lockedSettings.value + } + ++ func updateCurrentProfileName () { ++ notify(forChange: .preferences) ++ } ++ + func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { + var oldValue: LoopSettings! + let newValue = lockedSettings.mutate { settings in +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 3eb3154b..6cefc092 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -293,6 +293,38 @@ extension NotificationManager { + } + } + ++ static func sendRemoteProfileNotification(name: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfile", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteProfileFailureNotification(name: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ private static func remoteProfileNotificationBody(name: String) -> String { ++ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ } ++ + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { + return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + } +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 3b74fd9d..5bf1ec36 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -28,6 +28,7 @@ class ServicesManager { + + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? ++ weak var deviceDataManager: DeviceDataManager? + + private var services = [Service]() + +@@ -456,3 +457,59 @@ extension Service { + ] + } + } ++ ++// MARK: - RemoteProfileActionDelegate ++ ++extension ServicesManager: RemoteProfileActionDelegate { ++ @MainActor ++ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { ++ do { ++ guard let deviceDataManager else { ++ throw RemoteProfileActionError.unavailable ++ } ++ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) ++ guard deviceDataManager.pumpSupportedIncrements() != nil else { ++ throw RemoteProfileActionError.noPump ++ } ++ guard let profile = try viewModel.savedProfile(named: name) else { ++ throw RemoteProfileActionError.profileNotFound(name) ++ } ++ try profile.verify(with: verify) ++ if case .failure(let error) = viewModel.validateProfile(profile) { ++ throw error ++ } ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ viewModel.triggerProfileSync() ++ NotificationManager.sendRemoteProfileNotification(name: name) ++ } catch { ++ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteProfileActionError: LocalizedError { ++ case unavailable ++ case noPump ++ case profileNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .unavailable: ++ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") ++ case .noPump: ++ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") ++ case .profileNotFound(let name): ++ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) ++ } ++ } ++ } ++} +diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift +index f31763de..4df43b2c 100644 +--- a/Loop/Loop/Views/SettingsView.swift ++++ b/Loop/Loop/Views/SettingsView.swift +@@ -51,6 +51,7 @@ public struct SettingsView: View { + + case favoriteFoods + case preferences ++ case profiles + } + } + +@@ -125,6 +126,19 @@ public struct SettingsView: View { + FavoriteFoodsView() + case .preferences: + PreferencesView(viewModel: PreferencesViewModel(preferencesProvider: Preferences.shared)).environmentObject(displayGlucosePreference) ++ case .profiles: ++ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), ++ sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, ++ adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, ++ delegate: self.viewModel.therapySettingsViewModelDelegate)) ++ .environmentObject(displayGlucosePreference) ++ .environment(\.dismissAction, self.dismiss) ++ .environment(\.appName, self.appName) ++ .environment(\.chartColorPalette, .primary) ++ .environment(\.carbTintColor, self.carbTintColor) ++ .environment(\.glucoseTintColor, self.glucoseTintColor) ++ .environment(\.guidanceColors, self.guidanceColors) ++ .environment(\.insulinTintColor, self.insulinTintColor) + } + } + } +@@ -285,6 +299,11 @@ extension SettingsView { + descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) + } + ++ LargeButton(action: { sheet = .profiles }, ++ includeArrow: true, ++ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), ++ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), ++ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) + ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in + item.view + } +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj +index c36bfda3..5e9ec17d 100644 +--- a/LoopKit/LoopKit.xcodeproj/project.pbxproj ++++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj +@@ -873,6 +873,12 @@ + D1A0B000000000000000000C /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; + D1A0B000000000000000000D /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; + D1A0B0000000000000000081 /* PersistenceControllerMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */; }; ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; + DD13BC702C3C71A70062313B /* BasalLockEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC6F2C3C71A70062313B /* BasalLockEditor.swift */; }; + DD13BC722C3C74CD0062313B /* PreferencesGuardrailWarning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC712C3C74CD0062313B /* PreferencesGuardrailWarning.swift */; }; + DD28B3712B80C074001044D4 /* PreferencesSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD28B3702B80C074001044D4 /* PreferencesSetting.swift */; }; +@@ -1820,6 +1826,12 @@ + D1A0B0000000000000000006 /* Modelv5ToModelv6.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = Modelv5ToModelv6.xcmappingmodel; sourceTree = ""; }; + D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelMappingv4v5Tov6Policy.swift; sourceTree = ""; }; + D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; ++ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; + DD13BC6F2C3C71A70062313B /* BasalLockEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalLockEditor.swift; sourceTree = ""; }; + DD13BC712C3C74CD0062313B /* PreferencesGuardrailWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesGuardrailWarning.swift; sourceTree = ""; }; + DD28B3702B80C074001044D4 /* PreferencesSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesSetting.swift; sourceTree = ""; }; +@@ -3027,12 +3039,14 @@ + isa = PBXGroup; + children = ( + B455F3A325FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift */, ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, + B4B7C1BB2604E3FC007379F6 /* CorrectionRangeScheduleEditorViewModel.swift */, + B4D4C20C25F95A8700DA809D /* DisplayGlucosePreference.swift */, + B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, + B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, + 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, + DD545A5E2B80EFA900915F95 /* PreferencesViewModel.swift */, ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, + ); + path = ViewModels; + sourceTree = ""; +@@ -3230,6 +3244,10 @@ + E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, + 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, + 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, ++ DDAF746629F422C600719F0A /* ProfileView.swift */, ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, + ); + path = "Settings Editors"; + sourceTree = ""; +@@ -3940,6 +3958,7 @@ + E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, + B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, + 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, + 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, + E9E5E56A24D5CCE800B5DFFE /* OverrideViewCell.swift in Sources */, + 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, +@@ -3949,6 +3968,7 @@ + B43D69D62A26642300DF0925 /* DemoPlaceHolderView.swift in Sources */, + E949E38F24B3711E00024DA0 /* InsulinModelInformationView.swift in Sources */, + 895FE08B22011F0C00FCF18A /* EmojiInputHeaderView.swift in Sources */, ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, + 895FE08322011F0C00FCF18A /* OverrideSelectionFooterView.swift in Sources */, + 89AF78C22447E353002B4FCC /* Splat.swift in Sources */, + 893C9F8C2447DBD900CD4185 /* CardBuilder.swift in Sources */, +@@ -4000,9 +4020,11 @@ + E9C58A6E24DA65E400487A17 /* HistoricalOverrideDetailView.swift in Sources */, + 895FE08222011F0C00FCF18A /* LabeledTextFieldTableViewCell.swift in Sources */, + B43DA44224D9CD8500CAFF4E /* Environment+Colors.swift in Sources */, ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, + 892A5DB22231E191008961AB /* LoadingTableViewCell.swift in Sources */, + B4D3D4AD25B8A94E0085BA0F /* DisplayGlucoseUnitObserver.swift in Sources */, + A9D3FF102A6C19CA000C891D /* ChartAxisValueDoubleLog.swift in Sources */, ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, + E96DCB5A24AF74AC007117BC /* SuspendThresholdEditor.swift in Sources */, + E96DCB5824AEF50F007117BC /* SuspendThresholdInformationView.swift in Sources */, + A9D3FF0C2A6C198C000C891D /* CollectionType.swift in Sources */, +@@ -4079,6 +4101,7 @@ + E93C86B624D08CAD0073089B /* InsulinSensitivityInformationView.swift in Sources */, + C18733AF29B9492300519CDF /* Collection.swift in Sources */, + 892A5DA22231E137008961AB /* HUDProvider.swift in Sources */, ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, + 898E6E6C224194060019E459 /* UIColor.swift in Sources */, + 43BA717D201EE7090058961E /* GlucoseRangeTableViewCell.swift in Sources */, + B455F3A425FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift in Sources */, +@@ -4106,6 +4129,7 @@ + 1D1065E9282DC54700026A70 /* VideoPlayView.swift in Sources */, + E99A132E2557548300D3F5B3 /* SegmentedGaugeBar.swift in Sources */, + B455F31825FBEC5F000ED456 /* SuspendThresholdEditorViewModel.swift in Sources */, ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, + 892A5DB32231E191008961AB /* LevelHUDView.swift in Sources */, + B41A60B223D1DBC700636320 /* UIFont.swift in Sources */, + B41A60AF23D1DB5B00636320 /* TableViewTitleLabel.swift in Sources */, +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 4b2cecf3..56742c96 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -8,6 +8,12 @@ + + import Foundation + ++/// Switches to a saved therapy profile on a remote command. ++public protocol RemoteProfileActionDelegate: AnyObject { ++ /// `verify` receives the schedules about to be activated and throws to reject them. ++ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws ++} ++ + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws + func cancelRemoteOverride() async throws +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +new file mode 100644 +index 00000000..f48848cd +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +@@ -0,0 +1,426 @@ ++// ++// ProfileViewModel+FileManagement.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import LoopKit ++import HealthKit ++ ++public enum ProfileValidationError: Error, LocalizedError { ++ case fileLoadError ++ case unsupportedIncrement ++ case correctionRangeError ++ case insulinSensitivityError ++ case carbRatioError ++ case basalRateError ++ case maxBasalRateNotSet ++ ++ public var errorDescription: String? { ++ switch self { ++ case .fileLoadError: ++ return "Unable to load the profile file." ++ case .unsupportedIncrement: ++ return "The profile contains unsupported increments." ++ case .correctionRangeError: ++ return "Correction Range values are out of bounds." ++ case .insulinSensitivityError: ++ return "Insulin Sensitivity values are out of bounds." ++ case .carbRatioError: ++ return "Carb Ratio values are out of bounds." ++ case .basalRateError: ++ return "Basal Rate values are out of bounds." ++ case .maxBasalRateNotSet: ++ return "Maximum Basal Rate is not set." ++ } ++ } ++} ++ ++public enum LoadProfileResult { ++ case success ++ case failure(Error) ++} ++ ++// MARK: File management ++extension ProfileViewModel { ++ private func setCurrentProfile(name: String, syncChange: Bool = false) { ++ let sanitizedProfileName = sanitizeProfileName(name) ++ currentProfileName = sanitizedProfileName ++ if syncChange { ++ triggerProfileSync() ++ } ++ } ++ ++ private func clearCurrentProfile() { ++ currentProfileName = nil ++ triggerProfileSync() ++ } ++ ++ private func getCurrentProfileName() -> String? { ++ return currentProfileName ++ } ++ ++ private var profilesDirectory: URL { ++ let fileManager = FileManager.default ++ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] ++ return documentsDirectory.appendingPathComponent("LoopProfile") ++ } ++ ++ private func createProfileDirectoryIfNotExists() throws { ++ let fileManager = FileManager.default ++ var isDir : ObjCBool = false ++ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { ++ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) ++ } else if !isDir.boolValue { ++ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) ++ } ++ } ++ ++ private func getAllProfileFiles() throws -> [URL] { ++ let fileManager = FileManager.default ++ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) ++ return files.filter { $0.pathExtension == "json" } ++ } ++ ++ private func encodeProfile(_ profile: Profile) throws -> Data { ++ return try JSONEncoder().encode(profile) ++ } ++ ++ private func decodeProfile(from data: Data) throws -> Profile { ++ return try JSONDecoder().decode(Profile.self, from: data) ++ } ++ ++ public func sanitizeProfileName(_ name: String) -> String { ++ // Replace problematic characters ++ var sanitized = name.replacingOccurrences(of: ".", with: "_") ++ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") ++ ++ // Limit the name to 32 characters ++ if sanitized.count > 32 { ++ sanitized = String(sanitized.prefix(32)) ++ } ++ ++ return sanitized ++ } ++ ++ public func saveProfile(withName name: String) { ++ let sanitizedName = sanitizeProfileName(name) ++ ++ do { ++ try createProfileDirectoryIfNotExists() ++ ++ if let existingProfile = getProfileReference(withName: sanitizedName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let profile = Profile( ++ name: sanitizedName, ++ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ carbRatioSchedule: therapySettings.carbRatioSchedule!, ++ basalRateSchedule: therapySettings.basalRateSchedule!, ++ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ sortOrder: -1 ++ ) ++ ++ let jsonData = try encodeProfile(profile) ++ ++ let dateFormatter = DateFormatter() ++ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" ++ let fileName = dateFormatter.string(from: Date()) + ".json" ++ let fileURL = profilesDirectory.appendingPathComponent(fileName) ++ ++ try jsonData.write(to: fileURL) ++ setCurrentProfile(name: sanitizedName, syncChange: true) ++ ++ self.loadProfiles() ++ } catch { ++ print("An error occurred while saving profile: \(error)") ++ } ++ } ++ ++ public func renameProfile(oldName: String, newName: String) { ++ let sanitizedProfileName = sanitizeProfileName(newName) ++ ++ if currentProfileName == oldName { ++ setCurrentProfile(name: sanitizedProfileName, syncChange: true) ++ } ++ ++ do { ++ guard let profileReference = getProfileReference(withName: oldName) else { ++ print("Profile with old name not found.") ++ return ++ } ++ ++ let profile = try getProfile(from: profileReference) ++ ++ let newProfile = Profile( ++ name: sanitizedProfileName, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ ++ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ ++ try jsonData.write(to: fileURL) ++ ++ self.loadProfiles() ++ triggerProfileSync() ++ } catch { ++ print("An error occurred while renaming profile: \(error)") ++ } ++ } ++ ++ public func updateProfilesOrder() { ++ for (index, profileReference) in profiles.enumerated() { ++ do { ++ var profile = try getProfile(from: profileReference) ++ let newProfile = Profile( ++ name: profile.name, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: index ++ ) ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try jsonData.write(to: fileURL) ++ ++ } catch { ++ print("An error occurred while updating profile order: \(error)") ++ } ++ } ++ loadProfiles() ++ } ++ ++ public func loadProfiles() { ++ do { ++ let profileFiles = try getAllProfileFiles() ++ ++ var newProfiles = [ProfileReference]() ++ for fileURL in profileFiles { ++ let data = try Data(contentsOf: fileURL) ++ let profile = try decodeProfile(from: data) ++ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) ++ newProfiles.append(profileRef) ++ } ++ ++ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } ++ ++ self.profiles = newProfiles ++ } catch { ++ print("An error occurred while loading profiles: \(error)") ++ } ++ } ++ ++ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { ++ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && ++ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && ++ therapySettings.basalRateSchedule == profile.basalRateSchedule && ++ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { ++ self.setCurrentProfile(name: profile.name, syncChange: true) ++ completion(.success) ++ } ++ else ++ { ++ do { ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ switch result { ++ case .success(let syncedSchedule): ++ DispatchQueue.main.async { ++ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) ++ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) ++ self?.saveBasalRates(basalRates: syncedSchedule) ++ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ print("New profile loaded") ++ self?.setCurrentProfile(name: profile.name, syncChange: false) ++ completion(.success) ++ } ++ case .failure(let error): ++ print("An error occurred while syncing basal rates: \(error)") ++ completion(.failure(error)) ++ } ++ }) ++ } ++ } ++ } ++ ++ func getProfileReference(withName name: String) -> ProfileReference? { ++ let sanitizedInputName = sanitizeProfileName(name) ++ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) ++ } ++ ++ public func removeProfile(profile: Profile) { ++ guard let profileReference = getProfileReference(withName: profile.name) else { ++ print("No ProfileReference found for given Profile") ++ return ++ } ++ ++ removeProfile(profileReference: profileReference) ++ if getCurrentProfileName() == profile.name { ++ clearCurrentProfile() ++ } else { ++ triggerProfileSync() ++ } ++ } ++ ++ public func removeProfile(profileReference: ProfileReference) { ++ do { ++ let fileManager = FileManager.default ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try fileManager.removeItem(at: fileURL) ++ loadProfiles() ++ } catch { ++ print("An error occurred while removing profile: \(error)") ++ } ++ } ++ ++ func doesProfileExist(withName name: String) -> Bool { ++ return profiles.contains(where: { $0.name == name }) ++ } ++ ++ public func getProfile(from profileReference: ProfileReference) throws -> Profile { ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ let data = try Data(contentsOf: fileURL) ++ let getProfile = try decodeProfile(from: data) ++ return getProfile ++ } ++ ++ /// The saved profile named `name`, or nil when there is none. ++ public func savedProfile(named name: String) throws -> Profile? { ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ return nil ++ } ++ return try getProfile(from: profileReference) ++ } ++ ++ public func validateProfile(_ profile: Profile) -> Result { ++ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { ++ return .failure(.fileLoadError) ++ } ++ ++ // Checking Correction Range Schedule ++ for item in profile.correctionRange.items { ++ let minValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) ++ let maxValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) ++ ++ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { ++ return .failure(.correctionRangeError) ++ } ++ } ++ ++ // Checking Insulin Sensitivity Schedule ++ for item in profile.insulinSensitivitySchedule.items { ++ let value = HKQuantity(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: .internationalUnit()), doubleValue: item.value) ++ ++ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { ++ return .failure(.insulinSensitivityError) ++ } ++ } ++ ++ // Checking Carb Ratio Schedule ++ for item in profile.carbRatioSchedule.items { ++ let value = HKQuantity(unit: .gramsPerUnit, doubleValue: item.value) ++ ++ if !Guardrail.carbRatio.absoluteBounds.contains(value) { ++ return .failure(.carbRatioError) ++ } ++ } ++ ++ // Checking Basal Rate Schedule ++ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { ++ for item in profile.basalRateSchedule.items { ++ let value = item.value ++ ++ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { ++ return .failure(.basalRateError) ++ } ++ } ++ } else { ++ return .failure(.maxBasalRateNotSet) ++ } ++ ++ return .success(()) ++ } ++ ++ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value.minValue - item2.value.minValue) > 0.01 || ++ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value - item2.value) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ public func isCurrentProfileOutOfSync() -> Bool { ++ guard let currentProfileName = self.currentProfileName, ++ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), ++ let profile = try? getProfile(from: currentProfileReference), ++ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: HKUnit.milligramsPerDeciliter), ++ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: HKUnit.milligramsPerDeciliter) else { ++ return false // No current profile set or profile not found or schedules not available ++ } ++ ++ var isOutOfSync = false ++ ++ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: HKUnit.milligramsPerDeciliter)!) { ++ print("Glucose target range schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ print("Carb ratio schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ print("Basal rate schedule differs") ++ isOutOfSync = true ++ } ++ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: HKUnit.milligramsPerDeciliter)!) { ++ print("Insulin sensitivity schedule differs") ++ isOutOfSync = true ++ } ++ ++ return isOutOfSync ++ } ++} ++ ++extension Profile { ++ /// Passes the schedules this profile activates to `check`. ++ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { ++ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +new file mode 100644 +index 00000000..a48fa667 +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +@@ -0,0 +1,86 @@ ++// ++// ProfileViewModel.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import Combine ++import LoopKit ++import HealthKit ++import SwiftUI ++ ++public class ProfileViewModel: ObservableObject { ++ ++ @Published public var therapySettings: TherapySettings ++ @Published public var profiles = [ProfileReference]() ++ @Published public var currentProfileName: String? { ++ didSet { ++ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") ++ } ++ } ++ ++ internal weak var delegate: TherapySettingsViewModelDelegate? ++ ++ public init(therapySettings: TherapySettings, ++ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, ++ sensitivityOverridesEnabled: Bool = false, ++ adultChildInsulinModelSelectionEnabled: Bool = false, ++ prescription: Prescription? = nil, ++ delegate: TherapySettingsViewModelDelegate? = nil) { ++ self.therapySettings = therapySettings ++ self.delegate = delegate ++ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") ++ self.loadProfiles() ++ } ++ ++ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return delegate?.pumpSupportedIncrements() ++ } ++} ++ ++public struct Profile: Codable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ var sortOrder: Int? ++} ++ ++public struct ProfileReference: Codable, Equatable { ++ var name: String ++ var fileName: String ++ var sortOrder: Int? ++} ++ ++ ++// MARK: Saving ++extension ProfileViewModel { ++ ++ public func saveCorrectionRange(range: GlucoseRangeSchedule) { ++ therapySettings.glucoseTargetRangeSchedule = range ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveBasalRates(basalRates: BasalRateSchedule) { ++ therapySettings.basalRateSchedule = basalRates ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { ++ therapySettings.carbRatioSchedule = carbRatioSchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { ++ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func triggerProfileSync() { ++ delegate?.updateCurrentProfileName() ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +index 3650ef60..c5c1dc01 100644 +--- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift ++++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { + func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) + func saveCompletion(therapySettings: TherapySettings) + func pumpSupportedIncrements() -> PumpSupportedIncrements? ++ func updateCurrentProfileName() + } + + public class TherapySettingsViewModel: ObservableObject { +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +new file mode 100644 +index 00000000..446910e4 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +@@ -0,0 +1,79 @@ ++// ++// NewProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct NewProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var newProfileName: String ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("New Profile") ++ .font(.headline) ++ }, ++ trailing: { addButton } ++ ) ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var addButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Add") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +new file mode 100644 +index 00000000..6de3598f +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +@@ -0,0 +1,326 @@ ++// ++// ProfilePreviewView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-23. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++import HealthKit ++import LoopKit ++ ++enum ActiveAlert { ++ case load ++ case delete ++ case error ++} ++ ++struct ProfilePreviewView: View { ++ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference ++ @ObservedObject var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @Environment(\.presentationMode) var presentationMode ++ var profile: Profile ++ ++ @State private var showingAlert = false ++ @State private var activeAlert: ActiveAlert = .load ++ @State private var errorText: String? ++ @State private var newProfileName: String = "" ++ @State private var isRenamingProfile = false ++ @State private var shouldDismissSelf: Bool = false ++ ++ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } ++ ++ var body: some View { ++ ZStack { ++ ScrollView { ++ VStack(alignment: .leading) { ++ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ .font(.body) ++ .foregroundColor(.primary) ++ .padding([.leading, .trailing]) ++ .fixedSize(horizontal: false, vertical: true) ++ .textCase(nil) ++ ++ profileCardStack ++ ++ Button(action: { ++ activeAlert = .load ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Load") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.blue) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ isRenamingProfile = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Rename") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.orange) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ activeAlert = .delete ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Delete") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.red) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ } ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .navigationTitle(Text(profile.name)) ++ .alert(isPresented: $showingAlert) { ++ switch activeAlert { ++ case .load: ++ return Alert( ++ title: Text("Load Profile"), ++ message: Text("Do you want to load the profile \(profile.name)?"), ++ primaryButton: .default(Text("Yes"), action: { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .delete: ++ return Alert( ++ title: Text("Delete Profile"), ++ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), ++ primaryButton: .destructive(Text("Delete"), action: { ++ viewModel.removeProfile(profile: profile) ++ self.presentationMode.wrappedValue.dismiss() ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .error: ++ return Alert( ++ title: Text("Error loading profile"), ++ message: Text(errorText ?? "Unknown error"), ++ dismissButton: .default(Text("OK")) ++ ) ++ } ++ } ++ if isRenamingProfile { ++ // Some hacks here to mimic Alert behaviour. ++ // This function should be migrateed to Alert when iOS 16 is required. ++ Color.black.opacity(0.5) ++ .edgesIgnoringSafeArea(.all) ++ .onTapGesture { ++ isRenamingProfile = false ++ } ++ ++ RenameProfileEditor( ++ isPresented: $isRenamingProfile, ++ currentProfileName: profile.name, ++ viewModel: viewModel, ++ shouldDismissParent: $shouldDismissSelf ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ .offset(y: -22) ++ } ++ } ++ .onChange(of: shouldDismissSelf) { shouldDismiss in ++ if shouldDismiss { ++ self.presentationMode.wrappedValue.dismiss() ++ } ++ } ++ } ++ ++ private var profileCardStack: CardStack { ++ var cards: [Card] = [] ++ ++ cards.append(correctionRangeSection) ++ cards.append(carbRatioSection) ++ cards.append(basalRatesSection) ++ cards.append(insulinSensitivitiesSection) ++ ++ return CardStack(cards: cards) ++ } ++ ++ private var correctionRangeSection: Card { ++ card(for: .glucoseTargetRange) { ++ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleRangeItem(time: items[index].startTime, ++ range: items[index].value, ++ unit: glucoseUnit, ++ guardrail: .correctionRange) ++ } ++ } ++ } ++ } ++ ++ private var carbRatioSection: Card { ++ card(for: .carbRatio) { ++ SectionDivider() ++ let items = profile.carbRatioSchedule.items ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .gramsPerUnit, ++ guardrail: .carbRatio) ++ } ++ } ++ } ++ ++ private var basalRatesSection: Card { ++ card(for: .basalRate) { ++ let items = profile.basalRateSchedule.items ++ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates ++ { ++ SectionDivider() ++ let total = profile.basalRateSchedule.total() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .internationalUnitsPerHour, ++ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) ++ } ++ HStack { ++ Text("Total") ++ .bold() ++ .foregroundColor(.primary) ++ Spacer() ++ Text(String(format: "%.2f ",total)) ++ .foregroundColor(.primary) + ++ Text("U/day") ++ .foregroundColor(.secondary) ++ } ++ } ++ } ++ } ++ ++ private var insulinSensitivitiesSection: Card { ++ card(for: .insulinSensitivity) { ++ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items ++ { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: sensitivityUnit, ++ guardrail: .insulinSensitivity) ++ } ++ } ++ } ++ } ++} ++ ++ ++struct SectionWithContent: View where Content: View { ++ let title: String ++ let content: Content ++ ++ init(title: String, ++ content: () -> Content) ++ { ++ self.title = title ++ self.content = content() ++ } ++ ++ public var body: some View { ++ Section { ++ Text(title) ++ .bold() ++ .frame(maxWidth: .infinity, alignment: .leading) ++ content ++ } ++ } ++} ++ ++// MARK: Utilities ++extension ProfilePreviewView { ++ ++ private var glucoseUnit: HKUnit { ++ displayGlucosePreference.unit ++ } ++ ++ private var sensitivityUnit: HKUnit { ++ glucoseUnit.unitDivided(by: .internationalUnit()) ++ } ++ ++ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { ++ Card { ++ SectionWithContent(title: therapySetting.title, ++ content: content) ++ } ++ } ++} ++ ++fileprivate struct SectionDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -16) ++ } ++} ++ ++fileprivate struct SettingsDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -8) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +new file mode 100644 +index 00000000..1c6244a4 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +@@ -0,0 +1,173 @@ ++// ++// ProfileView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import AVFoundation ++import HealthKit ++import LoopKit ++import SwiftUI ++ ++public struct ProfileView: View { ++ @ObservedObject public var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @State private var newProfileName: String = "" ++ @State private var isAddingNewProfile = false ++ @State private var selectedProfileIndex: Int? = nil ++ @State private var refreshID = UUID() ++ ++ enum ActiveAlert: String, Identifiable { ++ case update, info ++ ++ var id: String { ++ return self.rawValue ++ } ++ } ++ @State private var activeAlert: ActiveAlert? ++ ++ public init(viewModel: ProfileViewModel) { ++ self.viewModel = viewModel ++ } ++ ++ private var dismissButton: some View { ++ Button(action: dismissAction) { ++ Text(LocalizedString("Done", comment: "Text for dismiss button")) ++ .bold() ++ } ++ } ++ ++ public var body: some View { ++ ZStack { ++ NavigationView { ++ ConfigurationPageScrollView( ++ content: { ++ VStack(alignment: .leading) { ++ ++ if !viewModel.profiles.isEmpty { ++ List { ++ ForEach(viewModel.profiles.indices, id: \.self) { index in ++ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { ++ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { ++ HStack { ++ if viewModel.currentProfileName == viewModel.profiles[index].name { ++ Image(systemName: "checkmark") ++ .foregroundColor(Color.blue) ++ } else { ++ // Add indentation to align with the checkmark for the active profile ++ Image(systemName: "checkmark") ++ .opacity(0) // Invisible ++ } ++ ++ Text(viewModel.profiles[index].name) ++ } ++ } ++ } ++ } ++ .onMove(perform: moveProfile) ++ }.id(refreshID) ++ ++ } else { ++ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") ++ .font(.subheadline) ++ .foregroundColor(.secondary) ++ .padding([.leading, .trailing]) ++ .multilineTextAlignment(.leading) ++ .lineLimit(nil) ++ .textCase(nil) ++ } ++ } ++ }, ++ actionArea: { EmptyView() } // no action area in this case ++ ) ++ .navigationBarItems( ++ leading: HStack { ++ Button(action: { withAnimation { isAddingNewProfile = true } }) { ++ Image(systemName: "plus") ++ } ++ ++ Button(action: { activeAlert = .info }) { ++ Image(systemName: "info.circle") ++ .resizable() ++ .frame(width: 24, height: 24) ++ } ++ }, ++ trailing: dismissButton ++ ) ++ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .onAppear { ++ if viewModel.isCurrentProfileOutOfSync() { ++ activeAlert = .update ++ } ++ } ++ ++ .alert(item: $activeAlert) { alertType in ++ switch alertType { ++ case .update: ++ return Alert( ++ title: Text("Profile Outdated"), ++ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), ++ primaryButton: .default(Text("Update")) { ++ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") ++ }, ++ secondaryButton: .cancel() ++ ) ++ case .info: ++ return Alert(title: Text("Information"), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ dismissButton: .default(Text("Got it!"))) ++ } ++ } ++ ++ if isAddingNewProfile { ++ DarkenedOverlay() ++ ++ NewProfileEditor( ++ isPresented: $isAddingNewProfile, ++ newProfileName: newProfileName, ++ viewModel: viewModel ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ } ++ } ++ } ++ ++ func moveProfile(from source: IndexSet, to destination: Int) { ++ viewModel.profiles.move(fromOffsets: source, toOffset: destination) ++ refreshID = UUID() ++ viewModel.updateProfilesOrder() ++ } ++} ++ ++struct ProfileView_Previews: PreviewProvider { ++ static let preview_glucoseScheduleItems = [ ++ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), ++ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), ++ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) ++ ] ++ ++ static let preview_therapySettings = TherapySettings( ++ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), ++ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), ++ workout: DoubleRange(99...111), ++ unit: .milligramsPerDeciliter), ++ maximumBolus: 4, ++ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), ++ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: HKUnit.internationalUnit()), dailyItems: []), ++ carbRatioSchedule: nil, ++ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) ++ ++ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] ++ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] ++ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] ++ ++ static var previews: some View { ++ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +new file mode 100644 +index 00000000..8fd26665 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +@@ -0,0 +1,92 @@ ++// ++// RenameProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-09-07. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct RenameProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var currentProfileName: String ++ @State var newProfileName: String = "" ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ @Binding var shouldDismissParent: Bool ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("Rename Profile") ++ .font(.subheadline) ++ ++ }, ++ trailing: { renameButton } ++ ) ++ .onAppear { ++ self.newProfileName = currentProfileName ++ } ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var renameButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ guard newProfileName != currentProfileName else { ++ self.isPresented = false ++ return ++ } ++ ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Rename") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +Submodule LoopOnboarding contains modified content +diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +index cc61f2d..6462e2c 100644 +--- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift ++++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +@@ -379,6 +379,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { + maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount + ) + } ++ ++ func updateCurrentProfileName() { ++ } + } + + +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index 9f75373..18143ab 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -24,6 +24,10 @@ + A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; +@@ -165,6 +169,10 @@ + A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; + A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; +@@ -282,6 +290,7 @@ + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, + A934B6172358F49300949C8B /* StoredSettings.swift */, ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, + A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, + A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, +@@ -341,6 +350,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -383,6 +393,7 @@ + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, + ); + path = Notifications; + sourceTree = ""; +@@ -431,6 +442,7 @@ + A9AA6E5729EB06AA008FFA78 /* Actions */ = { + isa = PBXGroup; + children = ( ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, +@@ -769,6 +781,9 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, + A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, + C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, + ); +@@ -785,6 +800,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, + A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +new file mode 100644 +index 0000000..de73ca9 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,104 @@ ++// ++// SavedProfile.swift ++// NightscoutServiceKit ++// ++ ++import CryptoKit ++import Foundation ++import HealthKit ++import LoopKit ++import NightscoutKit ++ ++/// A profile saved with the Profiles settings screen, read from the file it was saved to. ++struct SavedProfile: Decodable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ ++ private static var directory: URL { ++ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") ++ } ++ ++ static func loadAll() -> [SavedProfile] { ++ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] ++ return files ++ .filter { $0.pathExtension == "json" } ++ .compactMap { try? Data(contentsOf: $0) } ++ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } ++ } ++ ++ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. ++ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { ++ var store: [String: ProfileSet.Profile] = [:] ++ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. ++ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { ++ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) ++ } ++ return store ++ } ++ ++ /// True when this profile uploads with this fingerprint, in either glucose unit. ++ func matches(fingerprint: String) -> Bool { ++ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { ++ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint ++ } ++ } ++ ++ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { ++ guard let correctionRange = correctionRange.schedule(for: unit), ++ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) ++ else { ++ return nil ++ } ++ return ProfileSet.Profile( ++ timezone: basalRateSchedule.timeZone, ++ dia: .hours(6), ++ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, ++ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, ++ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) ++ } ++} ++ ++extension ProfileSet.Profile { ++ /// Followers compute the same value from the uploaded profile and send it with a switch command, ++ /// so Loop only activates the values the follower previewed. ++ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` ++ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. ++ var remoteFingerprint: String { ++ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { ++ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") ++ } ++ let canonical = [ ++ "basal=" + items(basal), ++ "carbratio=" + items(carbratio), ++ "sens=" + items(sensitivity), ++ "target_low=" + items(targetLow), ++ "target_high=" + items(targetHigh), ++ ].joined(separator: ";") ++ let digest = SHA256.hash(data: Data(canonical.utf8)) ++ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index 637988b..7c780ea 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -160,7 +160,7 @@ extension StoredDosingDecision { + recommendedBolus: loopStatusRecommendedBolus, + enacted: automaticDoseDecision?.loopStatusEnacted, + failureReason: automaticDoseDecision?.loopStatusFailureReason, +- testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported]), ++ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]]), + overrideStatus: overrideStatus) + } + +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +index 2adadb9..dd7ac28 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +@@ -94,12 +94,21 @@ extension StoredSettings { + return nil + } + ++ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) ++ store["Default"] = profile ++ var defaultProfile = "Default" ++ ++ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { ++ defaultProfile = name ++ store[name] = profile ++ } ++ + return ProfileSet( + startDate: date, + units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), + enteredBy: "Loop", +- defaultProfile: "Default", +- store: ["Default": profile], ++ defaultProfile: defaultProfile, ++ store: store, + settings: loopSettings, + syncIdentifier: syncIdentifier.uuidString, + isAPNSProduction: Bundle.main.isAPNSProductionEnvironment) +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index 169fb86..18ac515 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -437,6 +437,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + success = true + message = "Override '\(overrideCommand.name)' enacted successfully" + ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ success = true ++ message = "Profile '\(profileSwitchCommand.name)' activated" ++ + case .cancelTemporaryOverride: + commandType = .cancelOverride + try await self.serviceDelegate?.cancelRemoteOverride() +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index 91ad56c..29d3563 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -9,6 +9,7 @@ + import Foundation + + public enum Action: Codable { ++ case profileSwitch(ProfileSwitchAction) + case temporaryScheduleOverride(OverrideAction) + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) +@@ -34,6 +35,8 @@ public enum Action: Codable { + return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") + case .temporaryScheduleOverride: + return LocalizedString("Override", comment: "The remote action name for Override") ++ case .profileSwitch: ++ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") + } + } + +@@ -54,6 +57,8 @@ public enum Action: Codable { + return "" + case .temporaryScheduleOverride(let overrideAction): + return overrideAction.name ++ case .profileSwitch(let profileSwitchAction): ++ return profileSwitchAction.name + } + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +new file mode 100644 +index 0000000..ce03ded +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +@@ -0,0 +1,17 @@ ++// ++// ProfileSwitchAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct ProfileSwitchAction: Codable { ++ ++ public let name: String ++ public let fingerprint: String ++ ++ public init(name: String, fingerprint: String) { ++ self.name = name ++ self.fingerprint = fingerprint ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 6092bc9..aabb47f 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -49,6 +49,7 @@ class RemoteNotificationResponseManager { + case carbsEdit = "carbs_edit" + case override = "override" + case cancelOverride = "cancel_override" ++ case profileSwitch = "profile_switch" + } + + func sendResponseNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +new file mode 100644 +index 0000000..487c620 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -0,0 +1,50 @@ ++// ++// ProfileSwitchRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Switches to a saved profile. `expiration` is required: decoding fails without it. ++public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ public let profileName: String ++ public let fingerprint: String ++ public let remoteAddress: String ++ public let expirationDate: Date ++ public let sentAt: Date? ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ public var expiration: Date? { ++ return expirationDate ++ } ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expirationDate = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification[commandKey] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 6f0524d..87f1e92 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -122,6 +122,8 @@ extension Dictionary { + return try OverrideRemoteNotification(dictionary: self) + } else if OverrideCancelRemoteNotification.includedInNotification(self) { + return try OverrideCancelRemoteNotification(dictionary: self) ++ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { ++ return try ProfileSwitchRemoteNotification(dictionary: self) + } else { + throw RemoteNotificationError.unhandledNotification(self) + } +diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..2fcec94 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +@@ -0,0 +1,71 @@ ++// ++// ProfileSwitchRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "sent-at": "2023-02-25T20:46:35.778Z", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ] ++ ++ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) ++ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ ++ XCTAssertEqual(profileSwitch.profileName, "Sport") ++ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertTrue(profileSwitch.otpValidationRequired()) ++ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { ++ return XCTFail("Expected a profile switch action") ++ } ++ XCTAssertEqual(action.name, "Sport") ++ } ++ ++ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { ++ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ let profile = ProfileSet.Profile( ++ timezone: TimeZone(secondsFromGMT: 3600)!, ++ dia: .hours(6), ++ sensitivity: items([(0, 45)]), ++ carbratio: items([(0, 10)]), ++ basal: items([(0, 0.85), (21600, 1.1)]), ++ targetLow: items([(0, 100)]), ++ targetHigh: items([(0, 110)]), ++ units: "mg/dL") ++ ++ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") ++ } ++} ++ ++private extension ISO8601DateFormatter { ++ static let fractional: ISO8601DateFormatter = { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ }() ++} +diff --git a/profiles.loopcustomization b/profiles.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/profiles/dev_profiles_basal_lock_remote_carb_edit_remote_window.patch b/profiles/dev_profiles_basal_lock_remote_carb_edit_remote_window.patch new file mode 100644 index 0000000..243fdb6 --- /dev/null +++ b/profiles/dev_profiles_basal_lock_remote_carb_edit_remote_window.patch @@ -0,0 +1,2095 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index 8cc5714a..c253e88e 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -433,6 +433,7 @@ final class DeviceDataManager { + ) + + statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) ++ servicesManager.deviceDataManager = self + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, +@@ -1725,6 +1726,10 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { + } + } + ++ func updateCurrentProfileName() { ++ loopManager.updateCurrentProfileName() ++ } ++ + func saveCompletion(therapySettings: TherapySettings) { + + loopManager.mutateSettings { settings in +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index 0e1ee6c0..01fdf61f 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -244,6 +244,10 @@ final class LoopDataManager { + lockedSettings.value + } + ++ func updateCurrentProfileName () { ++ notify(forChange: .preferences) ++ } ++ + func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { + var oldValue: LoopSettings! + let newValue = lockedSettings.mutate { settings in +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 3eb3154b..6cefc092 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -293,6 +293,38 @@ extension NotificationManager { + } + } + ++ static func sendRemoteProfileNotification(name: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfile", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteProfileFailureNotification(name: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ private static func remoteProfileNotificationBody(name: String) -> String { ++ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ } ++ + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { + return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + } +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 1e272bb2..161a42e3 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -28,6 +28,7 @@ class ServicesManager { + + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? ++ weak var deviceDataManager: DeviceDataManager? + + private var services = [Service]() + +@@ -482,3 +483,59 @@ extension Service { + ] + } + } ++ ++// MARK: - RemoteProfileActionDelegate ++ ++extension ServicesManager: RemoteProfileActionDelegate { ++ @MainActor ++ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { ++ do { ++ guard let deviceDataManager else { ++ throw RemoteProfileActionError.unavailable ++ } ++ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) ++ guard deviceDataManager.pumpSupportedIncrements() != nil else { ++ throw RemoteProfileActionError.noPump ++ } ++ guard let profile = try viewModel.savedProfile(named: name) else { ++ throw RemoteProfileActionError.profileNotFound(name) ++ } ++ try profile.verify(with: verify) ++ if case .failure(let error) = viewModel.validateProfile(profile) { ++ throw error ++ } ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ viewModel.triggerProfileSync() ++ NotificationManager.sendRemoteProfileNotification(name: name) ++ } catch { ++ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteProfileActionError: LocalizedError { ++ case unavailable ++ case noPump ++ case profileNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .unavailable: ++ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") ++ case .noPump: ++ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") ++ case .profileNotFound(let name): ++ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) ++ } ++ } ++ } ++} +diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift +index f31763de..4df43b2c 100644 +--- a/Loop/Loop/Views/SettingsView.swift ++++ b/Loop/Loop/Views/SettingsView.swift +@@ -51,6 +51,7 @@ public struct SettingsView: View { + + case favoriteFoods + case preferences ++ case profiles + } + } + +@@ -125,6 +126,19 @@ public struct SettingsView: View { + FavoriteFoodsView() + case .preferences: + PreferencesView(viewModel: PreferencesViewModel(preferencesProvider: Preferences.shared)).environmentObject(displayGlucosePreference) ++ case .profiles: ++ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), ++ sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, ++ adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, ++ delegate: self.viewModel.therapySettingsViewModelDelegate)) ++ .environmentObject(displayGlucosePreference) ++ .environment(\.dismissAction, self.dismiss) ++ .environment(\.appName, self.appName) ++ .environment(\.chartColorPalette, .primary) ++ .environment(\.carbTintColor, self.carbTintColor) ++ .environment(\.glucoseTintColor, self.glucoseTintColor) ++ .environment(\.guidanceColors, self.guidanceColors) ++ .environment(\.insulinTintColor, self.insulinTintColor) + } + } + } +@@ -285,6 +299,11 @@ extension SettingsView { + descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) + } + ++ LargeButton(action: { sheet = .profiles }, ++ includeArrow: true, ++ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), ++ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), ++ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) + ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in + item.view + } +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj +index c36bfda3..5e9ec17d 100644 +--- a/LoopKit/LoopKit.xcodeproj/project.pbxproj ++++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj +@@ -873,6 +873,12 @@ + D1A0B000000000000000000C /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; + D1A0B000000000000000000D /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; + D1A0B0000000000000000081 /* PersistenceControllerMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */; }; ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; + DD13BC702C3C71A70062313B /* BasalLockEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC6F2C3C71A70062313B /* BasalLockEditor.swift */; }; + DD13BC722C3C74CD0062313B /* PreferencesGuardrailWarning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC712C3C74CD0062313B /* PreferencesGuardrailWarning.swift */; }; + DD28B3712B80C074001044D4 /* PreferencesSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD28B3702B80C074001044D4 /* PreferencesSetting.swift */; }; +@@ -1820,6 +1826,12 @@ + D1A0B0000000000000000006 /* Modelv5ToModelv6.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = Modelv5ToModelv6.xcmappingmodel; sourceTree = ""; }; + D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelMappingv4v5Tov6Policy.swift; sourceTree = ""; }; + D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; ++ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; + DD13BC6F2C3C71A70062313B /* BasalLockEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalLockEditor.swift; sourceTree = ""; }; + DD13BC712C3C74CD0062313B /* PreferencesGuardrailWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesGuardrailWarning.swift; sourceTree = ""; }; + DD28B3702B80C074001044D4 /* PreferencesSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesSetting.swift; sourceTree = ""; }; +@@ -3027,12 +3039,14 @@ + isa = PBXGroup; + children = ( + B455F3A325FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift */, ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, + B4B7C1BB2604E3FC007379F6 /* CorrectionRangeScheduleEditorViewModel.swift */, + B4D4C20C25F95A8700DA809D /* DisplayGlucosePreference.swift */, + B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, + B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, + 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, + DD545A5E2B80EFA900915F95 /* PreferencesViewModel.swift */, ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, + ); + path = ViewModels; + sourceTree = ""; +@@ -3230,6 +3244,10 @@ + E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, + 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, + 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, ++ DDAF746629F422C600719F0A /* ProfileView.swift */, ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, + ); + path = "Settings Editors"; + sourceTree = ""; +@@ -3940,6 +3958,7 @@ + E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, + B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, + 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, + 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, + E9E5E56A24D5CCE800B5DFFE /* OverrideViewCell.swift in Sources */, + 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, +@@ -3949,6 +3968,7 @@ + B43D69D62A26642300DF0925 /* DemoPlaceHolderView.swift in Sources */, + E949E38F24B3711E00024DA0 /* InsulinModelInformationView.swift in Sources */, + 895FE08B22011F0C00FCF18A /* EmojiInputHeaderView.swift in Sources */, ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, + 895FE08322011F0C00FCF18A /* OverrideSelectionFooterView.swift in Sources */, + 89AF78C22447E353002B4FCC /* Splat.swift in Sources */, + 893C9F8C2447DBD900CD4185 /* CardBuilder.swift in Sources */, +@@ -4000,9 +4020,11 @@ + E9C58A6E24DA65E400487A17 /* HistoricalOverrideDetailView.swift in Sources */, + 895FE08222011F0C00FCF18A /* LabeledTextFieldTableViewCell.swift in Sources */, + B43DA44224D9CD8500CAFF4E /* Environment+Colors.swift in Sources */, ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, + 892A5DB22231E191008961AB /* LoadingTableViewCell.swift in Sources */, + B4D3D4AD25B8A94E0085BA0F /* DisplayGlucoseUnitObserver.swift in Sources */, + A9D3FF102A6C19CA000C891D /* ChartAxisValueDoubleLog.swift in Sources */, ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, + E96DCB5A24AF74AC007117BC /* SuspendThresholdEditor.swift in Sources */, + E96DCB5824AEF50F007117BC /* SuspendThresholdInformationView.swift in Sources */, + A9D3FF0C2A6C198C000C891D /* CollectionType.swift in Sources */, +@@ -4079,6 +4101,7 @@ + E93C86B624D08CAD0073089B /* InsulinSensitivityInformationView.swift in Sources */, + C18733AF29B9492300519CDF /* Collection.swift in Sources */, + 892A5DA22231E137008961AB /* HUDProvider.swift in Sources */, ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, + 898E6E6C224194060019E459 /* UIColor.swift in Sources */, + 43BA717D201EE7090058961E /* GlucoseRangeTableViewCell.swift in Sources */, + B455F3A425FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift in Sources */, +@@ -4106,6 +4129,7 @@ + 1D1065E9282DC54700026A70 /* VideoPlayView.swift in Sources */, + E99A132E2557548300D3F5B3 /* SegmentedGaugeBar.swift in Sources */, + B455F31825FBEC5F000ED456 /* SuspendThresholdEditorViewModel.swift in Sources */, ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, + 892A5DB32231E191008961AB /* LevelHUDView.swift in Sources */, + B41A60B223D1DBC700636320 /* UIFont.swift in Sources */, + B41A60AF23D1DB5B00636320 /* TableViewTitleLabel.swift in Sources */, +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 8fb254b6..858a5621 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -8,6 +8,12 @@ + + import Foundation + ++/// Switches to a saved therapy profile on a remote command. ++public protocol RemoteProfileActionDelegate: AnyObject { ++ /// `verify` receives the schedules about to be activated and throws to reject them. ++ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws ++} ++ + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride + func cancelRemoteOverride() async throws -> TemporaryScheduleOverride +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +new file mode 100644 +index 00000000..f48848cd +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +@@ -0,0 +1,426 @@ ++// ++// ProfileViewModel+FileManagement.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import LoopKit ++import HealthKit ++ ++public enum ProfileValidationError: Error, LocalizedError { ++ case fileLoadError ++ case unsupportedIncrement ++ case correctionRangeError ++ case insulinSensitivityError ++ case carbRatioError ++ case basalRateError ++ case maxBasalRateNotSet ++ ++ public var errorDescription: String? { ++ switch self { ++ case .fileLoadError: ++ return "Unable to load the profile file." ++ case .unsupportedIncrement: ++ return "The profile contains unsupported increments." ++ case .correctionRangeError: ++ return "Correction Range values are out of bounds." ++ case .insulinSensitivityError: ++ return "Insulin Sensitivity values are out of bounds." ++ case .carbRatioError: ++ return "Carb Ratio values are out of bounds." ++ case .basalRateError: ++ return "Basal Rate values are out of bounds." ++ case .maxBasalRateNotSet: ++ return "Maximum Basal Rate is not set." ++ } ++ } ++} ++ ++public enum LoadProfileResult { ++ case success ++ case failure(Error) ++} ++ ++// MARK: File management ++extension ProfileViewModel { ++ private func setCurrentProfile(name: String, syncChange: Bool = false) { ++ let sanitizedProfileName = sanitizeProfileName(name) ++ currentProfileName = sanitizedProfileName ++ if syncChange { ++ triggerProfileSync() ++ } ++ } ++ ++ private func clearCurrentProfile() { ++ currentProfileName = nil ++ triggerProfileSync() ++ } ++ ++ private func getCurrentProfileName() -> String? { ++ return currentProfileName ++ } ++ ++ private var profilesDirectory: URL { ++ let fileManager = FileManager.default ++ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] ++ return documentsDirectory.appendingPathComponent("LoopProfile") ++ } ++ ++ private func createProfileDirectoryIfNotExists() throws { ++ let fileManager = FileManager.default ++ var isDir : ObjCBool = false ++ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { ++ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) ++ } else if !isDir.boolValue { ++ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) ++ } ++ } ++ ++ private func getAllProfileFiles() throws -> [URL] { ++ let fileManager = FileManager.default ++ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) ++ return files.filter { $0.pathExtension == "json" } ++ } ++ ++ private func encodeProfile(_ profile: Profile) throws -> Data { ++ return try JSONEncoder().encode(profile) ++ } ++ ++ private func decodeProfile(from data: Data) throws -> Profile { ++ return try JSONDecoder().decode(Profile.self, from: data) ++ } ++ ++ public func sanitizeProfileName(_ name: String) -> String { ++ // Replace problematic characters ++ var sanitized = name.replacingOccurrences(of: ".", with: "_") ++ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") ++ ++ // Limit the name to 32 characters ++ if sanitized.count > 32 { ++ sanitized = String(sanitized.prefix(32)) ++ } ++ ++ return sanitized ++ } ++ ++ public func saveProfile(withName name: String) { ++ let sanitizedName = sanitizeProfileName(name) ++ ++ do { ++ try createProfileDirectoryIfNotExists() ++ ++ if let existingProfile = getProfileReference(withName: sanitizedName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let profile = Profile( ++ name: sanitizedName, ++ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ carbRatioSchedule: therapySettings.carbRatioSchedule!, ++ basalRateSchedule: therapySettings.basalRateSchedule!, ++ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ sortOrder: -1 ++ ) ++ ++ let jsonData = try encodeProfile(profile) ++ ++ let dateFormatter = DateFormatter() ++ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" ++ let fileName = dateFormatter.string(from: Date()) + ".json" ++ let fileURL = profilesDirectory.appendingPathComponent(fileName) ++ ++ try jsonData.write(to: fileURL) ++ setCurrentProfile(name: sanitizedName, syncChange: true) ++ ++ self.loadProfiles() ++ } catch { ++ print("An error occurred while saving profile: \(error)") ++ } ++ } ++ ++ public func renameProfile(oldName: String, newName: String) { ++ let sanitizedProfileName = sanitizeProfileName(newName) ++ ++ if currentProfileName == oldName { ++ setCurrentProfile(name: sanitizedProfileName, syncChange: true) ++ } ++ ++ do { ++ guard let profileReference = getProfileReference(withName: oldName) else { ++ print("Profile with old name not found.") ++ return ++ } ++ ++ let profile = try getProfile(from: profileReference) ++ ++ let newProfile = Profile( ++ name: sanitizedProfileName, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ ++ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ ++ try jsonData.write(to: fileURL) ++ ++ self.loadProfiles() ++ triggerProfileSync() ++ } catch { ++ print("An error occurred while renaming profile: \(error)") ++ } ++ } ++ ++ public func updateProfilesOrder() { ++ for (index, profileReference) in profiles.enumerated() { ++ do { ++ var profile = try getProfile(from: profileReference) ++ let newProfile = Profile( ++ name: profile.name, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: index ++ ) ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try jsonData.write(to: fileURL) ++ ++ } catch { ++ print("An error occurred while updating profile order: \(error)") ++ } ++ } ++ loadProfiles() ++ } ++ ++ public func loadProfiles() { ++ do { ++ let profileFiles = try getAllProfileFiles() ++ ++ var newProfiles = [ProfileReference]() ++ for fileURL in profileFiles { ++ let data = try Data(contentsOf: fileURL) ++ let profile = try decodeProfile(from: data) ++ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) ++ newProfiles.append(profileRef) ++ } ++ ++ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } ++ ++ self.profiles = newProfiles ++ } catch { ++ print("An error occurred while loading profiles: \(error)") ++ } ++ } ++ ++ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { ++ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && ++ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && ++ therapySettings.basalRateSchedule == profile.basalRateSchedule && ++ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { ++ self.setCurrentProfile(name: profile.name, syncChange: true) ++ completion(.success) ++ } ++ else ++ { ++ do { ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ switch result { ++ case .success(let syncedSchedule): ++ DispatchQueue.main.async { ++ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) ++ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) ++ self?.saveBasalRates(basalRates: syncedSchedule) ++ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ print("New profile loaded") ++ self?.setCurrentProfile(name: profile.name, syncChange: false) ++ completion(.success) ++ } ++ case .failure(let error): ++ print("An error occurred while syncing basal rates: \(error)") ++ completion(.failure(error)) ++ } ++ }) ++ } ++ } ++ } ++ ++ func getProfileReference(withName name: String) -> ProfileReference? { ++ let sanitizedInputName = sanitizeProfileName(name) ++ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) ++ } ++ ++ public func removeProfile(profile: Profile) { ++ guard let profileReference = getProfileReference(withName: profile.name) else { ++ print("No ProfileReference found for given Profile") ++ return ++ } ++ ++ removeProfile(profileReference: profileReference) ++ if getCurrentProfileName() == profile.name { ++ clearCurrentProfile() ++ } else { ++ triggerProfileSync() ++ } ++ } ++ ++ public func removeProfile(profileReference: ProfileReference) { ++ do { ++ let fileManager = FileManager.default ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try fileManager.removeItem(at: fileURL) ++ loadProfiles() ++ } catch { ++ print("An error occurred while removing profile: \(error)") ++ } ++ } ++ ++ func doesProfileExist(withName name: String) -> Bool { ++ return profiles.contains(where: { $0.name == name }) ++ } ++ ++ public func getProfile(from profileReference: ProfileReference) throws -> Profile { ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ let data = try Data(contentsOf: fileURL) ++ let getProfile = try decodeProfile(from: data) ++ return getProfile ++ } ++ ++ /// The saved profile named `name`, or nil when there is none. ++ public func savedProfile(named name: String) throws -> Profile? { ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ return nil ++ } ++ return try getProfile(from: profileReference) ++ } ++ ++ public func validateProfile(_ profile: Profile) -> Result { ++ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { ++ return .failure(.fileLoadError) ++ } ++ ++ // Checking Correction Range Schedule ++ for item in profile.correctionRange.items { ++ let minValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) ++ let maxValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) ++ ++ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { ++ return .failure(.correctionRangeError) ++ } ++ } ++ ++ // Checking Insulin Sensitivity Schedule ++ for item in profile.insulinSensitivitySchedule.items { ++ let value = HKQuantity(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: .internationalUnit()), doubleValue: item.value) ++ ++ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { ++ return .failure(.insulinSensitivityError) ++ } ++ } ++ ++ // Checking Carb Ratio Schedule ++ for item in profile.carbRatioSchedule.items { ++ let value = HKQuantity(unit: .gramsPerUnit, doubleValue: item.value) ++ ++ if !Guardrail.carbRatio.absoluteBounds.contains(value) { ++ return .failure(.carbRatioError) ++ } ++ } ++ ++ // Checking Basal Rate Schedule ++ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { ++ for item in profile.basalRateSchedule.items { ++ let value = item.value ++ ++ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { ++ return .failure(.basalRateError) ++ } ++ } ++ } else { ++ return .failure(.maxBasalRateNotSet) ++ } ++ ++ return .success(()) ++ } ++ ++ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value.minValue - item2.value.minValue) > 0.01 || ++ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value - item2.value) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ public func isCurrentProfileOutOfSync() -> Bool { ++ guard let currentProfileName = self.currentProfileName, ++ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), ++ let profile = try? getProfile(from: currentProfileReference), ++ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: HKUnit.milligramsPerDeciliter), ++ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: HKUnit.milligramsPerDeciliter) else { ++ return false // No current profile set or profile not found or schedules not available ++ } ++ ++ var isOutOfSync = false ++ ++ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: HKUnit.milligramsPerDeciliter)!) { ++ print("Glucose target range schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ print("Carb ratio schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ print("Basal rate schedule differs") ++ isOutOfSync = true ++ } ++ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: HKUnit.milligramsPerDeciliter)!) { ++ print("Insulin sensitivity schedule differs") ++ isOutOfSync = true ++ } ++ ++ return isOutOfSync ++ } ++} ++ ++extension Profile { ++ /// Passes the schedules this profile activates to `check`. ++ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { ++ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +new file mode 100644 +index 00000000..a48fa667 +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +@@ -0,0 +1,86 @@ ++// ++// ProfileViewModel.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import Combine ++import LoopKit ++import HealthKit ++import SwiftUI ++ ++public class ProfileViewModel: ObservableObject { ++ ++ @Published public var therapySettings: TherapySettings ++ @Published public var profiles = [ProfileReference]() ++ @Published public var currentProfileName: String? { ++ didSet { ++ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") ++ } ++ } ++ ++ internal weak var delegate: TherapySettingsViewModelDelegate? ++ ++ public init(therapySettings: TherapySettings, ++ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, ++ sensitivityOverridesEnabled: Bool = false, ++ adultChildInsulinModelSelectionEnabled: Bool = false, ++ prescription: Prescription? = nil, ++ delegate: TherapySettingsViewModelDelegate? = nil) { ++ self.therapySettings = therapySettings ++ self.delegate = delegate ++ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") ++ self.loadProfiles() ++ } ++ ++ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return delegate?.pumpSupportedIncrements() ++ } ++} ++ ++public struct Profile: Codable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ var sortOrder: Int? ++} ++ ++public struct ProfileReference: Codable, Equatable { ++ var name: String ++ var fileName: String ++ var sortOrder: Int? ++} ++ ++ ++// MARK: Saving ++extension ProfileViewModel { ++ ++ public func saveCorrectionRange(range: GlucoseRangeSchedule) { ++ therapySettings.glucoseTargetRangeSchedule = range ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveBasalRates(basalRates: BasalRateSchedule) { ++ therapySettings.basalRateSchedule = basalRates ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { ++ therapySettings.carbRatioSchedule = carbRatioSchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { ++ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func triggerProfileSync() { ++ delegate?.updateCurrentProfileName() ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +index 3650ef60..c5c1dc01 100644 +--- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift ++++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { + func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) + func saveCompletion(therapySettings: TherapySettings) + func pumpSupportedIncrements() -> PumpSupportedIncrements? ++ func updateCurrentProfileName() + } + + public class TherapySettingsViewModel: ObservableObject { +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +new file mode 100644 +index 00000000..446910e4 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +@@ -0,0 +1,79 @@ ++// ++// NewProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct NewProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var newProfileName: String ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("New Profile") ++ .font(.headline) ++ }, ++ trailing: { addButton } ++ ) ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var addButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Add") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +new file mode 100644 +index 00000000..6de3598f +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +@@ -0,0 +1,326 @@ ++// ++// ProfilePreviewView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-23. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++import HealthKit ++import LoopKit ++ ++enum ActiveAlert { ++ case load ++ case delete ++ case error ++} ++ ++struct ProfilePreviewView: View { ++ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference ++ @ObservedObject var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @Environment(\.presentationMode) var presentationMode ++ var profile: Profile ++ ++ @State private var showingAlert = false ++ @State private var activeAlert: ActiveAlert = .load ++ @State private var errorText: String? ++ @State private var newProfileName: String = "" ++ @State private var isRenamingProfile = false ++ @State private var shouldDismissSelf: Bool = false ++ ++ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } ++ ++ var body: some View { ++ ZStack { ++ ScrollView { ++ VStack(alignment: .leading) { ++ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ .font(.body) ++ .foregroundColor(.primary) ++ .padding([.leading, .trailing]) ++ .fixedSize(horizontal: false, vertical: true) ++ .textCase(nil) ++ ++ profileCardStack ++ ++ Button(action: { ++ activeAlert = .load ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Load") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.blue) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ isRenamingProfile = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Rename") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.orange) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ activeAlert = .delete ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Delete") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.red) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ } ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .navigationTitle(Text(profile.name)) ++ .alert(isPresented: $showingAlert) { ++ switch activeAlert { ++ case .load: ++ return Alert( ++ title: Text("Load Profile"), ++ message: Text("Do you want to load the profile \(profile.name)?"), ++ primaryButton: .default(Text("Yes"), action: { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .delete: ++ return Alert( ++ title: Text("Delete Profile"), ++ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), ++ primaryButton: .destructive(Text("Delete"), action: { ++ viewModel.removeProfile(profile: profile) ++ self.presentationMode.wrappedValue.dismiss() ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .error: ++ return Alert( ++ title: Text("Error loading profile"), ++ message: Text(errorText ?? "Unknown error"), ++ dismissButton: .default(Text("OK")) ++ ) ++ } ++ } ++ if isRenamingProfile { ++ // Some hacks here to mimic Alert behaviour. ++ // This function should be migrateed to Alert when iOS 16 is required. ++ Color.black.opacity(0.5) ++ .edgesIgnoringSafeArea(.all) ++ .onTapGesture { ++ isRenamingProfile = false ++ } ++ ++ RenameProfileEditor( ++ isPresented: $isRenamingProfile, ++ currentProfileName: profile.name, ++ viewModel: viewModel, ++ shouldDismissParent: $shouldDismissSelf ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ .offset(y: -22) ++ } ++ } ++ .onChange(of: shouldDismissSelf) { shouldDismiss in ++ if shouldDismiss { ++ self.presentationMode.wrappedValue.dismiss() ++ } ++ } ++ } ++ ++ private var profileCardStack: CardStack { ++ var cards: [Card] = [] ++ ++ cards.append(correctionRangeSection) ++ cards.append(carbRatioSection) ++ cards.append(basalRatesSection) ++ cards.append(insulinSensitivitiesSection) ++ ++ return CardStack(cards: cards) ++ } ++ ++ private var correctionRangeSection: Card { ++ card(for: .glucoseTargetRange) { ++ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleRangeItem(time: items[index].startTime, ++ range: items[index].value, ++ unit: glucoseUnit, ++ guardrail: .correctionRange) ++ } ++ } ++ } ++ } ++ ++ private var carbRatioSection: Card { ++ card(for: .carbRatio) { ++ SectionDivider() ++ let items = profile.carbRatioSchedule.items ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .gramsPerUnit, ++ guardrail: .carbRatio) ++ } ++ } ++ } ++ ++ private var basalRatesSection: Card { ++ card(for: .basalRate) { ++ let items = profile.basalRateSchedule.items ++ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates ++ { ++ SectionDivider() ++ let total = profile.basalRateSchedule.total() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .internationalUnitsPerHour, ++ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) ++ } ++ HStack { ++ Text("Total") ++ .bold() ++ .foregroundColor(.primary) ++ Spacer() ++ Text(String(format: "%.2f ",total)) ++ .foregroundColor(.primary) + ++ Text("U/day") ++ .foregroundColor(.secondary) ++ } ++ } ++ } ++ } ++ ++ private var insulinSensitivitiesSection: Card { ++ card(for: .insulinSensitivity) { ++ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items ++ { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: sensitivityUnit, ++ guardrail: .insulinSensitivity) ++ } ++ } ++ } ++ } ++} ++ ++ ++struct SectionWithContent: View where Content: View { ++ let title: String ++ let content: Content ++ ++ init(title: String, ++ content: () -> Content) ++ { ++ self.title = title ++ self.content = content() ++ } ++ ++ public var body: some View { ++ Section { ++ Text(title) ++ .bold() ++ .frame(maxWidth: .infinity, alignment: .leading) ++ content ++ } ++ } ++} ++ ++// MARK: Utilities ++extension ProfilePreviewView { ++ ++ private var glucoseUnit: HKUnit { ++ displayGlucosePreference.unit ++ } ++ ++ private var sensitivityUnit: HKUnit { ++ glucoseUnit.unitDivided(by: .internationalUnit()) ++ } ++ ++ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { ++ Card { ++ SectionWithContent(title: therapySetting.title, ++ content: content) ++ } ++ } ++} ++ ++fileprivate struct SectionDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -16) ++ } ++} ++ ++fileprivate struct SettingsDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -8) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +new file mode 100644 +index 00000000..1c6244a4 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +@@ -0,0 +1,173 @@ ++// ++// ProfileView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import AVFoundation ++import HealthKit ++import LoopKit ++import SwiftUI ++ ++public struct ProfileView: View { ++ @ObservedObject public var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @State private var newProfileName: String = "" ++ @State private var isAddingNewProfile = false ++ @State private var selectedProfileIndex: Int? = nil ++ @State private var refreshID = UUID() ++ ++ enum ActiveAlert: String, Identifiable { ++ case update, info ++ ++ var id: String { ++ return self.rawValue ++ } ++ } ++ @State private var activeAlert: ActiveAlert? ++ ++ public init(viewModel: ProfileViewModel) { ++ self.viewModel = viewModel ++ } ++ ++ private var dismissButton: some View { ++ Button(action: dismissAction) { ++ Text(LocalizedString("Done", comment: "Text for dismiss button")) ++ .bold() ++ } ++ } ++ ++ public var body: some View { ++ ZStack { ++ NavigationView { ++ ConfigurationPageScrollView( ++ content: { ++ VStack(alignment: .leading) { ++ ++ if !viewModel.profiles.isEmpty { ++ List { ++ ForEach(viewModel.profiles.indices, id: \.self) { index in ++ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { ++ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { ++ HStack { ++ if viewModel.currentProfileName == viewModel.profiles[index].name { ++ Image(systemName: "checkmark") ++ .foregroundColor(Color.blue) ++ } else { ++ // Add indentation to align with the checkmark for the active profile ++ Image(systemName: "checkmark") ++ .opacity(0) // Invisible ++ } ++ ++ Text(viewModel.profiles[index].name) ++ } ++ } ++ } ++ } ++ .onMove(perform: moveProfile) ++ }.id(refreshID) ++ ++ } else { ++ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") ++ .font(.subheadline) ++ .foregroundColor(.secondary) ++ .padding([.leading, .trailing]) ++ .multilineTextAlignment(.leading) ++ .lineLimit(nil) ++ .textCase(nil) ++ } ++ } ++ }, ++ actionArea: { EmptyView() } // no action area in this case ++ ) ++ .navigationBarItems( ++ leading: HStack { ++ Button(action: { withAnimation { isAddingNewProfile = true } }) { ++ Image(systemName: "plus") ++ } ++ ++ Button(action: { activeAlert = .info }) { ++ Image(systemName: "info.circle") ++ .resizable() ++ .frame(width: 24, height: 24) ++ } ++ }, ++ trailing: dismissButton ++ ) ++ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .onAppear { ++ if viewModel.isCurrentProfileOutOfSync() { ++ activeAlert = .update ++ } ++ } ++ ++ .alert(item: $activeAlert) { alertType in ++ switch alertType { ++ case .update: ++ return Alert( ++ title: Text("Profile Outdated"), ++ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), ++ primaryButton: .default(Text("Update")) { ++ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") ++ }, ++ secondaryButton: .cancel() ++ ) ++ case .info: ++ return Alert(title: Text("Information"), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ dismissButton: .default(Text("Got it!"))) ++ } ++ } ++ ++ if isAddingNewProfile { ++ DarkenedOverlay() ++ ++ NewProfileEditor( ++ isPresented: $isAddingNewProfile, ++ newProfileName: newProfileName, ++ viewModel: viewModel ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ } ++ } ++ } ++ ++ func moveProfile(from source: IndexSet, to destination: Int) { ++ viewModel.profiles.move(fromOffsets: source, toOffset: destination) ++ refreshID = UUID() ++ viewModel.updateProfilesOrder() ++ } ++} ++ ++struct ProfileView_Previews: PreviewProvider { ++ static let preview_glucoseScheduleItems = [ ++ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), ++ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), ++ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) ++ ] ++ ++ static let preview_therapySettings = TherapySettings( ++ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), ++ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), ++ workout: DoubleRange(99...111), ++ unit: .milligramsPerDeciliter), ++ maximumBolus: 4, ++ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), ++ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: HKUnit.internationalUnit()), dailyItems: []), ++ carbRatioSchedule: nil, ++ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) ++ ++ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] ++ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] ++ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] ++ ++ static var previews: some View { ++ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +new file mode 100644 +index 00000000..8fd26665 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +@@ -0,0 +1,92 @@ ++// ++// RenameProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-09-07. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct RenameProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var currentProfileName: String ++ @State var newProfileName: String = "" ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ @Binding var shouldDismissParent: Bool ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("Rename Profile") ++ .font(.subheadline) ++ ++ }, ++ trailing: { renameButton } ++ ) ++ .onAppear { ++ self.newProfileName = currentProfileName ++ } ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var renameButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ guard newProfileName != currentProfileName else { ++ self.isPresented = false ++ return ++ } ++ ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Rename") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +Submodule LoopOnboarding contains modified content +diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +index cc61f2d..6462e2c 100644 +--- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift ++++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +@@ -379,6 +379,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { + maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount + ) + } ++ ++ func updateCurrentProfileName() { ++ } + } + + +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index 9f75373..18143ab 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -24,6 +24,10 @@ + A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; +@@ -165,6 +169,10 @@ + A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; + A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; +@@ -282,6 +290,7 @@ + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, + A934B6172358F49300949C8B /* StoredSettings.swift */, ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, + A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, + A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, +@@ -341,6 +350,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -383,6 +393,7 @@ + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, + ); + path = Notifications; + sourceTree = ""; +@@ -431,6 +442,7 @@ + A9AA6E5729EB06AA008FFA78 /* Actions */ = { + isa = PBXGroup; + children = ( ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, +@@ -769,6 +781,9 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, + A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, + C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, + ); +@@ -785,6 +800,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, + A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +new file mode 100644 +index 0000000..de73ca9 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,104 @@ ++// ++// SavedProfile.swift ++// NightscoutServiceKit ++// ++ ++import CryptoKit ++import Foundation ++import HealthKit ++import LoopKit ++import NightscoutKit ++ ++/// A profile saved with the Profiles settings screen, read from the file it was saved to. ++struct SavedProfile: Decodable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ ++ private static var directory: URL { ++ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") ++ } ++ ++ static func loadAll() -> [SavedProfile] { ++ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] ++ return files ++ .filter { $0.pathExtension == "json" } ++ .compactMap { try? Data(contentsOf: $0) } ++ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } ++ } ++ ++ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. ++ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { ++ var store: [String: ProfileSet.Profile] = [:] ++ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. ++ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { ++ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) ++ } ++ return store ++ } ++ ++ /// True when this profile uploads with this fingerprint, in either glucose unit. ++ func matches(fingerprint: String) -> Bool { ++ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { ++ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint ++ } ++ } ++ ++ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { ++ guard let correctionRange = correctionRange.schedule(for: unit), ++ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) ++ else { ++ return nil ++ } ++ return ProfileSet.Profile( ++ timezone: basalRateSchedule.timeZone, ++ dia: .hours(6), ++ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, ++ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, ++ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) ++ } ++} ++ ++extension ProfileSet.Profile { ++ /// Followers compute the same value from the uploaded profile and send it with a switch command, ++ /// so Loop only activates the values the follower previewed. ++ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` ++ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. ++ var remoteFingerprint: String { ++ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { ++ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") ++ } ++ let canonical = [ ++ "basal=" + items(basal), ++ "carbratio=" + items(carbratio), ++ "sens=" + items(sensitivity), ++ "target_low=" + items(targetLow), ++ "target_high=" + items(targetHigh), ++ ].joined(separator: ";") ++ let digest = SHA256.hash(data: Data(canonical.utf8)) ++ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index 637988b..7c780ea 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -160,7 +160,7 @@ extension StoredDosingDecision { + recommendedBolus: loopStatusRecommendedBolus, + enacted: automaticDoseDecision?.loopStatusEnacted, + failureReason: automaticDoseDecision?.loopStatusFailureReason, +- testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported]), ++ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]]), + overrideStatus: overrideStatus) + } + +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +index 2adadb9..dd7ac28 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +@@ -94,12 +94,21 @@ extension StoredSettings { + return nil + } + ++ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) ++ store["Default"] = profile ++ var defaultProfile = "Default" ++ ++ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { ++ defaultProfile = name ++ store[name] = profile ++ } ++ + return ProfileSet( + startDate: date, + units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), + enteredBy: "Loop", +- defaultProfile: "Default", +- store: ["Default": profile], ++ defaultProfile: defaultProfile, ++ store: store, + settings: loopSettings, + syncIdentifier: syncIdentifier.uuidString, + isAPNSProduction: Bundle.main.isAPNSProductionEnvironment) +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index 91ad56c..29d3563 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -9,6 +9,7 @@ + import Foundation + + public enum Action: Codable { ++ case profileSwitch(ProfileSwitchAction) + case temporaryScheduleOverride(OverrideAction) + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) +@@ -34,6 +35,8 @@ public enum Action: Codable { + return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") + case .temporaryScheduleOverride: + return LocalizedString("Override", comment: "The remote action name for Override") ++ case .profileSwitch: ++ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") + } + } + +@@ -54,6 +57,8 @@ public enum Action: Codable { + return "" + case .temporaryScheduleOverride(let overrideAction): + return overrideAction.name ++ case .profileSwitch(let profileSwitchAction): ++ return profileSwitchAction.name + } + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +new file mode 100644 +index 0000000..ce03ded +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +@@ -0,0 +1,17 @@ ++// ++// ProfileSwitchAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct ProfileSwitchAction: Codable { ++ ++ public let name: String ++ public let fingerprint: String ++ ++ public init(name: String, fingerprint: String) { ++ self.name = name ++ self.fingerprint = fingerprint ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 6092bc9..aabb47f 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -49,6 +49,7 @@ class RemoteNotificationResponseManager { + case carbsEdit = "carbs_edit" + case override = "override" + case cancelOverride = "cancel_override" ++ case profileSwitch = "profile_switch" + } + + func sendResponseNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +new file mode 100644 +index 0000000..ebcd6ef +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -0,0 +1,50 @@ ++// ++// ProfileSwitchRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Switches to a saved profile. `expiration` is required: decoding fails without it. ++public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ public let profileName: String ++ public let fingerprint: String ++ public let remoteAddress: String ++ public let expirationDate: Date ++ public let sentAt: Date ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ public var expiration: Date? { ++ return expirationDate ++ } ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expirationDate = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification[commandKey] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index be43210..1ea8b96 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -118,6 +118,8 @@ extension Dictionary { + return try OverrideRemoteNotification(dictionary: self) + } else if OverrideCancelRemoteNotification.includedInNotification(self) { + return try OverrideCancelRemoteNotification(dictionary: self) ++ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { ++ return try ProfileSwitchRemoteNotification(dictionary: self) + } else { + throw RemoteNotificationError.unhandledNotification(self) + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +index 331ed5c..bff69c8 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +@@ -123,6 +123,27 @@ class RemoteCommandSourceV1: RemoteCommandSource { + status: .success(date: Date(), syncIdentifier: replacementEntry.syncIdentifier ?? "", completionMessage: nil), + notificationJSON: notification + ) ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ successMessage = "Profile '\(profileSwitchCommand.name)' activated" ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: "", completionMessage: nil), ++ notificationJSON: notification ++ ) + case .temporaryScheduleOverride(let overrideCommand): + commandType = .override + let override = try await serviceDelegate.enactRemoteOverride( +@@ -260,6 +281,10 @@ public class StoredRemoteNotification: NSObject, Codable { + self.init(notificationType: .carbsEdit(carbEditNotification), notificationJSONData: notificationJSONData) + } + ++ convenience init(profileSwitchNotification: ProfileSwitchRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .profileSwitch(profileSwitchNotification), notificationJSONData: notificationJSONData) ++ } ++ + convenience init(overrideNotification: OverrideRemoteNotification, notificationJSONData: Data) { + self.init(notificationType: .override(overrideNotification), notificationJSONData: notificationJSONData) + } +@@ -347,6 +372,8 @@ public class StoredRemoteNotification: NSObject, Codable { + return carbDeleteNotification + case let .carbsEdit(carbEditNotification): + return carbEditNotification ++ case let .profileSwitch(profileSwitchNotification): ++ return profileSwitchNotification + case let .override(overrideNotification): + return overrideNotification + case let .overrideCancel(overrideCancelNotification): +@@ -377,6 +404,7 @@ public class StoredRemoteNotification: NSObject, Codable { + case carbsEdit(CarbEditRemoteNotification) + case override(OverrideRemoteNotification) + case overrideCancel(OverrideCancelRemoteNotification) ++ case profileSwitch(ProfileSwitchRemoteNotification) + } + } + +@@ -433,6 +461,9 @@ actor RecentNotifications { + } else if let carbEditNotification = remoteNotification as? CarbEditRemoteNotification { + let storedNotification = StoredRemoteNotification(carbEditNotification: carbEditNotification, notificationJSONData: data) + try storeNotification(storedNotification) ++ } else if let profileSwitchNotification = remoteNotification as? ProfileSwitchRemoteNotification { ++ let storedNotification = StoredRemoteNotification(profileSwitchNotification: profileSwitchNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) + } else if let overrideNotification = remoteNotification as? OverrideRemoteNotification { + let storedNotification = StoredRemoteNotification(overrideNotification: overrideNotification, notificationJSONData: data) + try storeNotification(storedNotification) +diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..2fcec94 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +@@ -0,0 +1,71 @@ ++// ++// ProfileSwitchRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "sent-at": "2023-02-25T20:46:35.778Z", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ] ++ ++ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) ++ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ ++ XCTAssertEqual(profileSwitch.profileName, "Sport") ++ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertTrue(profileSwitch.otpValidationRequired()) ++ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { ++ return XCTFail("Expected a profile switch action") ++ } ++ XCTAssertEqual(action.name, "Sport") ++ } ++ ++ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { ++ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ let profile = ProfileSet.Profile( ++ timezone: TimeZone(secondsFromGMT: 3600)!, ++ dia: .hours(6), ++ sensitivity: items([(0, 45)]), ++ carbratio: items([(0, 10)]), ++ basal: items([(0, 0.85), (21600, 1.1)]), ++ targetLow: items([(0, 100)]), ++ targetHigh: items([(0, 110)]), ++ units: "mg/dL") ++ ++ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") ++ } ++} ++ ++private extension ISO8601DateFormatter { ++ static let fractional: ISO8601DateFormatter = { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ }() ++} +diff --git a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +index 0bfd600..bb9423b 100644 +--- a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift ++++ b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +@@ -60,6 +60,8 @@ extension StoredRemoteNotification { + return "Edit Carbs" + case .override: + return "Override" ++ case .profileSwitch: ++ return "Profile Switch" + case .overrideCancel: + return "Cancel Override" + } +@@ -84,6 +86,8 @@ extension StoredRemoteNotification { + return String(format: "%.0f g", notification.amount) + case .override(let notification): + return notification.name ++ case .profileSwitch(let notification): ++ return notification.profileName + case .overrideCancel: + return "" + } +diff --git a/profiles.loopcustomization b/profiles.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/profiles/dev_profiles_basal_lock_remote_window.patch b/profiles/dev_profiles_basal_lock_remote_window.patch new file mode 100644 index 0000000..876fe6c --- /dev/null +++ b/profiles/dev_profiles_basal_lock_remote_window.patch @@ -0,0 +1,2096 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index 8cc5714a..c253e88e 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -433,6 +433,7 @@ final class DeviceDataManager { + ) + + statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) ++ servicesManager.deviceDataManager = self + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, +@@ -1725,6 +1726,10 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { + } + } + ++ func updateCurrentProfileName() { ++ loopManager.updateCurrentProfileName() ++ } ++ + func saveCompletion(therapySettings: TherapySettings) { + + loopManager.mutateSettings { settings in +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index 1d295ff0..5be078cf 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -244,6 +244,10 @@ final class LoopDataManager { + lockedSettings.value + } + ++ func updateCurrentProfileName () { ++ notify(forChange: .preferences) ++ } ++ + func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { + var oldValue: LoopSettings! + let newValue = lockedSettings.mutate { settings in +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 996d1470..17cff75d 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -263,6 +263,38 @@ extension NotificationManager { + } + } + ++ static func sendRemoteProfileNotification(name: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfile", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteProfileFailureNotification(name: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ private static func remoteProfileNotificationBody(name: String) -> String { ++ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ } ++ + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { + return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + } +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 0b498420..f5f0d3c5 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -28,6 +28,7 @@ class ServicesManager { + + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? ++ weak var deviceDataManager: DeviceDataManager? + + private var services = [Service]() + +@@ -447,3 +448,59 @@ extension Service { + ] + } + } ++ ++// MARK: - RemoteProfileActionDelegate ++ ++extension ServicesManager: RemoteProfileActionDelegate { ++ @MainActor ++ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { ++ do { ++ guard let deviceDataManager else { ++ throw RemoteProfileActionError.unavailable ++ } ++ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) ++ guard deviceDataManager.pumpSupportedIncrements() != nil else { ++ throw RemoteProfileActionError.noPump ++ } ++ guard let profile = try viewModel.savedProfile(named: name) else { ++ throw RemoteProfileActionError.profileNotFound(name) ++ } ++ try profile.verify(with: verify) ++ if case .failure(let error) = viewModel.validateProfile(profile) { ++ throw error ++ } ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ viewModel.triggerProfileSync() ++ NotificationManager.sendRemoteProfileNotification(name: name) ++ } catch { ++ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteProfileActionError: LocalizedError { ++ case unavailable ++ case noPump ++ case profileNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .unavailable: ++ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") ++ case .noPump: ++ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") ++ case .profileNotFound(let name): ++ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) ++ } ++ } ++ } ++} +diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift +index f31763de..4df43b2c 100644 +--- a/Loop/Loop/Views/SettingsView.swift ++++ b/Loop/Loop/Views/SettingsView.swift +@@ -51,6 +51,7 @@ public struct SettingsView: View { + + case favoriteFoods + case preferences ++ case profiles + } + } + +@@ -125,6 +126,19 @@ public struct SettingsView: View { + FavoriteFoodsView() + case .preferences: + PreferencesView(viewModel: PreferencesViewModel(preferencesProvider: Preferences.shared)).environmentObject(displayGlucosePreference) ++ case .profiles: ++ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), ++ sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, ++ adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, ++ delegate: self.viewModel.therapySettingsViewModelDelegate)) ++ .environmentObject(displayGlucosePreference) ++ .environment(\.dismissAction, self.dismiss) ++ .environment(\.appName, self.appName) ++ .environment(\.chartColorPalette, .primary) ++ .environment(\.carbTintColor, self.carbTintColor) ++ .environment(\.glucoseTintColor, self.glucoseTintColor) ++ .environment(\.guidanceColors, self.guidanceColors) ++ .environment(\.insulinTintColor, self.insulinTintColor) + } + } + } +@@ -285,6 +299,11 @@ extension SettingsView { + descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) + } + ++ LargeButton(action: { sheet = .profiles }, ++ includeArrow: true, ++ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), ++ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), ++ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) + ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in + item.view + } +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj +index c36bfda3..5e9ec17d 100644 +--- a/LoopKit/LoopKit.xcodeproj/project.pbxproj ++++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj +@@ -873,6 +873,12 @@ + D1A0B000000000000000000C /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; + D1A0B000000000000000000D /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; + D1A0B0000000000000000081 /* PersistenceControllerMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */; }; ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; + DD13BC702C3C71A70062313B /* BasalLockEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC6F2C3C71A70062313B /* BasalLockEditor.swift */; }; + DD13BC722C3C74CD0062313B /* PreferencesGuardrailWarning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC712C3C74CD0062313B /* PreferencesGuardrailWarning.swift */; }; + DD28B3712B80C074001044D4 /* PreferencesSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD28B3702B80C074001044D4 /* PreferencesSetting.swift */; }; +@@ -1820,6 +1826,12 @@ + D1A0B0000000000000000006 /* Modelv5ToModelv6.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = Modelv5ToModelv6.xcmappingmodel; sourceTree = ""; }; + D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelMappingv4v5Tov6Policy.swift; sourceTree = ""; }; + D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; ++ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; + DD13BC6F2C3C71A70062313B /* BasalLockEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalLockEditor.swift; sourceTree = ""; }; + DD13BC712C3C74CD0062313B /* PreferencesGuardrailWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesGuardrailWarning.swift; sourceTree = ""; }; + DD28B3702B80C074001044D4 /* PreferencesSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesSetting.swift; sourceTree = ""; }; +@@ -3027,12 +3039,14 @@ + isa = PBXGroup; + children = ( + B455F3A325FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift */, ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, + B4B7C1BB2604E3FC007379F6 /* CorrectionRangeScheduleEditorViewModel.swift */, + B4D4C20C25F95A8700DA809D /* DisplayGlucosePreference.swift */, + B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, + B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, + 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, + DD545A5E2B80EFA900915F95 /* PreferencesViewModel.swift */, ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, + ); + path = ViewModels; + sourceTree = ""; +@@ -3230,6 +3244,10 @@ + E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, + 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, + 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, ++ DDAF746629F422C600719F0A /* ProfileView.swift */, ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, + ); + path = "Settings Editors"; + sourceTree = ""; +@@ -3940,6 +3958,7 @@ + E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, + B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, + 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, + 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, + E9E5E56A24D5CCE800B5DFFE /* OverrideViewCell.swift in Sources */, + 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, +@@ -3949,6 +3968,7 @@ + B43D69D62A26642300DF0925 /* DemoPlaceHolderView.swift in Sources */, + E949E38F24B3711E00024DA0 /* InsulinModelInformationView.swift in Sources */, + 895FE08B22011F0C00FCF18A /* EmojiInputHeaderView.swift in Sources */, ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, + 895FE08322011F0C00FCF18A /* OverrideSelectionFooterView.swift in Sources */, + 89AF78C22447E353002B4FCC /* Splat.swift in Sources */, + 893C9F8C2447DBD900CD4185 /* CardBuilder.swift in Sources */, +@@ -4000,9 +4020,11 @@ + E9C58A6E24DA65E400487A17 /* HistoricalOverrideDetailView.swift in Sources */, + 895FE08222011F0C00FCF18A /* LabeledTextFieldTableViewCell.swift in Sources */, + B43DA44224D9CD8500CAFF4E /* Environment+Colors.swift in Sources */, ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, + 892A5DB22231E191008961AB /* LoadingTableViewCell.swift in Sources */, + B4D3D4AD25B8A94E0085BA0F /* DisplayGlucoseUnitObserver.swift in Sources */, + A9D3FF102A6C19CA000C891D /* ChartAxisValueDoubleLog.swift in Sources */, ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, + E96DCB5A24AF74AC007117BC /* SuspendThresholdEditor.swift in Sources */, + E96DCB5824AEF50F007117BC /* SuspendThresholdInformationView.swift in Sources */, + A9D3FF0C2A6C198C000C891D /* CollectionType.swift in Sources */, +@@ -4079,6 +4101,7 @@ + E93C86B624D08CAD0073089B /* InsulinSensitivityInformationView.swift in Sources */, + C18733AF29B9492300519CDF /* Collection.swift in Sources */, + 892A5DA22231E137008961AB /* HUDProvider.swift in Sources */, ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, + 898E6E6C224194060019E459 /* UIColor.swift in Sources */, + 43BA717D201EE7090058961E /* GlucoseRangeTableViewCell.swift in Sources */, + B455F3A425FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift in Sources */, +@@ -4106,6 +4129,7 @@ + 1D1065E9282DC54700026A70 /* VideoPlayView.swift in Sources */, + E99A132E2557548300D3F5B3 /* SegmentedGaugeBar.swift in Sources */, + B455F31825FBEC5F000ED456 /* SuspendThresholdEditorViewModel.swift in Sources */, ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, + 892A5DB32231E191008961AB /* LevelHUDView.swift in Sources */, + B41A60B223D1DBC700636320 /* UIFont.swift in Sources */, + B41A60AF23D1DB5B00636320 /* TableViewTitleLabel.swift in Sources */, +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index e4279746..6f691598 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -8,6 +8,12 @@ + + import Foundation + ++/// Switches to a saved therapy profile on a remote command. ++public protocol RemoteProfileActionDelegate: AnyObject { ++ /// `verify` receives the schedules about to be activated and throws to reject them. ++ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws ++} ++ + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride + func cancelRemoteOverride() async throws -> TemporaryScheduleOverride +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +new file mode 100644 +index 00000000..f48848cd +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +@@ -0,0 +1,426 @@ ++// ++// ProfileViewModel+FileManagement.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import LoopKit ++import HealthKit ++ ++public enum ProfileValidationError: Error, LocalizedError { ++ case fileLoadError ++ case unsupportedIncrement ++ case correctionRangeError ++ case insulinSensitivityError ++ case carbRatioError ++ case basalRateError ++ case maxBasalRateNotSet ++ ++ public var errorDescription: String? { ++ switch self { ++ case .fileLoadError: ++ return "Unable to load the profile file." ++ case .unsupportedIncrement: ++ return "The profile contains unsupported increments." ++ case .correctionRangeError: ++ return "Correction Range values are out of bounds." ++ case .insulinSensitivityError: ++ return "Insulin Sensitivity values are out of bounds." ++ case .carbRatioError: ++ return "Carb Ratio values are out of bounds." ++ case .basalRateError: ++ return "Basal Rate values are out of bounds." ++ case .maxBasalRateNotSet: ++ return "Maximum Basal Rate is not set." ++ } ++ } ++} ++ ++public enum LoadProfileResult { ++ case success ++ case failure(Error) ++} ++ ++// MARK: File management ++extension ProfileViewModel { ++ private func setCurrentProfile(name: String, syncChange: Bool = false) { ++ let sanitizedProfileName = sanitizeProfileName(name) ++ currentProfileName = sanitizedProfileName ++ if syncChange { ++ triggerProfileSync() ++ } ++ } ++ ++ private func clearCurrentProfile() { ++ currentProfileName = nil ++ triggerProfileSync() ++ } ++ ++ private func getCurrentProfileName() -> String? { ++ return currentProfileName ++ } ++ ++ private var profilesDirectory: URL { ++ let fileManager = FileManager.default ++ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] ++ return documentsDirectory.appendingPathComponent("LoopProfile") ++ } ++ ++ private func createProfileDirectoryIfNotExists() throws { ++ let fileManager = FileManager.default ++ var isDir : ObjCBool = false ++ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { ++ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) ++ } else if !isDir.boolValue { ++ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) ++ } ++ } ++ ++ private func getAllProfileFiles() throws -> [URL] { ++ let fileManager = FileManager.default ++ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) ++ return files.filter { $0.pathExtension == "json" } ++ } ++ ++ private func encodeProfile(_ profile: Profile) throws -> Data { ++ return try JSONEncoder().encode(profile) ++ } ++ ++ private func decodeProfile(from data: Data) throws -> Profile { ++ return try JSONDecoder().decode(Profile.self, from: data) ++ } ++ ++ public func sanitizeProfileName(_ name: String) -> String { ++ // Replace problematic characters ++ var sanitized = name.replacingOccurrences(of: ".", with: "_") ++ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") ++ ++ // Limit the name to 32 characters ++ if sanitized.count > 32 { ++ sanitized = String(sanitized.prefix(32)) ++ } ++ ++ return sanitized ++ } ++ ++ public func saveProfile(withName name: String) { ++ let sanitizedName = sanitizeProfileName(name) ++ ++ do { ++ try createProfileDirectoryIfNotExists() ++ ++ if let existingProfile = getProfileReference(withName: sanitizedName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let profile = Profile( ++ name: sanitizedName, ++ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ carbRatioSchedule: therapySettings.carbRatioSchedule!, ++ basalRateSchedule: therapySettings.basalRateSchedule!, ++ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ sortOrder: -1 ++ ) ++ ++ let jsonData = try encodeProfile(profile) ++ ++ let dateFormatter = DateFormatter() ++ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" ++ let fileName = dateFormatter.string(from: Date()) + ".json" ++ let fileURL = profilesDirectory.appendingPathComponent(fileName) ++ ++ try jsonData.write(to: fileURL) ++ setCurrentProfile(name: sanitizedName, syncChange: true) ++ ++ self.loadProfiles() ++ } catch { ++ print("An error occurred while saving profile: \(error)") ++ } ++ } ++ ++ public func renameProfile(oldName: String, newName: String) { ++ let sanitizedProfileName = sanitizeProfileName(newName) ++ ++ if currentProfileName == oldName { ++ setCurrentProfile(name: sanitizedProfileName, syncChange: true) ++ } ++ ++ do { ++ guard let profileReference = getProfileReference(withName: oldName) else { ++ print("Profile with old name not found.") ++ return ++ } ++ ++ let profile = try getProfile(from: profileReference) ++ ++ let newProfile = Profile( ++ name: sanitizedProfileName, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ ++ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ ++ try jsonData.write(to: fileURL) ++ ++ self.loadProfiles() ++ triggerProfileSync() ++ } catch { ++ print("An error occurred while renaming profile: \(error)") ++ } ++ } ++ ++ public func updateProfilesOrder() { ++ for (index, profileReference) in profiles.enumerated() { ++ do { ++ var profile = try getProfile(from: profileReference) ++ let newProfile = Profile( ++ name: profile.name, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: index ++ ) ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try jsonData.write(to: fileURL) ++ ++ } catch { ++ print("An error occurred while updating profile order: \(error)") ++ } ++ } ++ loadProfiles() ++ } ++ ++ public func loadProfiles() { ++ do { ++ let profileFiles = try getAllProfileFiles() ++ ++ var newProfiles = [ProfileReference]() ++ for fileURL in profileFiles { ++ let data = try Data(contentsOf: fileURL) ++ let profile = try decodeProfile(from: data) ++ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) ++ newProfiles.append(profileRef) ++ } ++ ++ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } ++ ++ self.profiles = newProfiles ++ } catch { ++ print("An error occurred while loading profiles: \(error)") ++ } ++ } ++ ++ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { ++ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && ++ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && ++ therapySettings.basalRateSchedule == profile.basalRateSchedule && ++ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { ++ self.setCurrentProfile(name: profile.name, syncChange: true) ++ completion(.success) ++ } ++ else ++ { ++ do { ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ switch result { ++ case .success(let syncedSchedule): ++ DispatchQueue.main.async { ++ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) ++ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) ++ self?.saveBasalRates(basalRates: syncedSchedule) ++ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ print("New profile loaded") ++ self?.setCurrentProfile(name: profile.name, syncChange: false) ++ completion(.success) ++ } ++ case .failure(let error): ++ print("An error occurred while syncing basal rates: \(error)") ++ completion(.failure(error)) ++ } ++ }) ++ } ++ } ++ } ++ ++ func getProfileReference(withName name: String) -> ProfileReference? { ++ let sanitizedInputName = sanitizeProfileName(name) ++ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) ++ } ++ ++ public func removeProfile(profile: Profile) { ++ guard let profileReference = getProfileReference(withName: profile.name) else { ++ print("No ProfileReference found for given Profile") ++ return ++ } ++ ++ removeProfile(profileReference: profileReference) ++ if getCurrentProfileName() == profile.name { ++ clearCurrentProfile() ++ } else { ++ triggerProfileSync() ++ } ++ } ++ ++ public func removeProfile(profileReference: ProfileReference) { ++ do { ++ let fileManager = FileManager.default ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try fileManager.removeItem(at: fileURL) ++ loadProfiles() ++ } catch { ++ print("An error occurred while removing profile: \(error)") ++ } ++ } ++ ++ func doesProfileExist(withName name: String) -> Bool { ++ return profiles.contains(where: { $0.name == name }) ++ } ++ ++ public func getProfile(from profileReference: ProfileReference) throws -> Profile { ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ let data = try Data(contentsOf: fileURL) ++ let getProfile = try decodeProfile(from: data) ++ return getProfile ++ } ++ ++ /// The saved profile named `name`, or nil when there is none. ++ public func savedProfile(named name: String) throws -> Profile? { ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ return nil ++ } ++ return try getProfile(from: profileReference) ++ } ++ ++ public func validateProfile(_ profile: Profile) -> Result { ++ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { ++ return .failure(.fileLoadError) ++ } ++ ++ // Checking Correction Range Schedule ++ for item in profile.correctionRange.items { ++ let minValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) ++ let maxValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) ++ ++ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { ++ return .failure(.correctionRangeError) ++ } ++ } ++ ++ // Checking Insulin Sensitivity Schedule ++ for item in profile.insulinSensitivitySchedule.items { ++ let value = HKQuantity(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: .internationalUnit()), doubleValue: item.value) ++ ++ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { ++ return .failure(.insulinSensitivityError) ++ } ++ } ++ ++ // Checking Carb Ratio Schedule ++ for item in profile.carbRatioSchedule.items { ++ let value = HKQuantity(unit: .gramsPerUnit, doubleValue: item.value) ++ ++ if !Guardrail.carbRatio.absoluteBounds.contains(value) { ++ return .failure(.carbRatioError) ++ } ++ } ++ ++ // Checking Basal Rate Schedule ++ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { ++ for item in profile.basalRateSchedule.items { ++ let value = item.value ++ ++ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { ++ return .failure(.basalRateError) ++ } ++ } ++ } else { ++ return .failure(.maxBasalRateNotSet) ++ } ++ ++ return .success(()) ++ } ++ ++ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value.minValue - item2.value.minValue) > 0.01 || ++ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value - item2.value) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ public func isCurrentProfileOutOfSync() -> Bool { ++ guard let currentProfileName = self.currentProfileName, ++ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), ++ let profile = try? getProfile(from: currentProfileReference), ++ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: HKUnit.milligramsPerDeciliter), ++ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: HKUnit.milligramsPerDeciliter) else { ++ return false // No current profile set or profile not found or schedules not available ++ } ++ ++ var isOutOfSync = false ++ ++ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: HKUnit.milligramsPerDeciliter)!) { ++ print("Glucose target range schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ print("Carb ratio schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ print("Basal rate schedule differs") ++ isOutOfSync = true ++ } ++ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: HKUnit.milligramsPerDeciliter)!) { ++ print("Insulin sensitivity schedule differs") ++ isOutOfSync = true ++ } ++ ++ return isOutOfSync ++ } ++} ++ ++extension Profile { ++ /// Passes the schedules this profile activates to `check`. ++ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { ++ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +new file mode 100644 +index 00000000..a48fa667 +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +@@ -0,0 +1,86 @@ ++// ++// ProfileViewModel.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import Combine ++import LoopKit ++import HealthKit ++import SwiftUI ++ ++public class ProfileViewModel: ObservableObject { ++ ++ @Published public var therapySettings: TherapySettings ++ @Published public var profiles = [ProfileReference]() ++ @Published public var currentProfileName: String? { ++ didSet { ++ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") ++ } ++ } ++ ++ internal weak var delegate: TherapySettingsViewModelDelegate? ++ ++ public init(therapySettings: TherapySettings, ++ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, ++ sensitivityOverridesEnabled: Bool = false, ++ adultChildInsulinModelSelectionEnabled: Bool = false, ++ prescription: Prescription? = nil, ++ delegate: TherapySettingsViewModelDelegate? = nil) { ++ self.therapySettings = therapySettings ++ self.delegate = delegate ++ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") ++ self.loadProfiles() ++ } ++ ++ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return delegate?.pumpSupportedIncrements() ++ } ++} ++ ++public struct Profile: Codable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ var sortOrder: Int? ++} ++ ++public struct ProfileReference: Codable, Equatable { ++ var name: String ++ var fileName: String ++ var sortOrder: Int? ++} ++ ++ ++// MARK: Saving ++extension ProfileViewModel { ++ ++ public func saveCorrectionRange(range: GlucoseRangeSchedule) { ++ therapySettings.glucoseTargetRangeSchedule = range ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveBasalRates(basalRates: BasalRateSchedule) { ++ therapySettings.basalRateSchedule = basalRates ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { ++ therapySettings.carbRatioSchedule = carbRatioSchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { ++ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func triggerProfileSync() { ++ delegate?.updateCurrentProfileName() ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +index 3650ef60..c5c1dc01 100644 +--- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift ++++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { + func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) + func saveCompletion(therapySettings: TherapySettings) + func pumpSupportedIncrements() -> PumpSupportedIncrements? ++ func updateCurrentProfileName() + } + + public class TherapySettingsViewModel: ObservableObject { +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +new file mode 100644 +index 00000000..446910e4 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +@@ -0,0 +1,79 @@ ++// ++// NewProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct NewProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var newProfileName: String ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("New Profile") ++ .font(.headline) ++ }, ++ trailing: { addButton } ++ ) ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var addButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Add") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +new file mode 100644 +index 00000000..6de3598f +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +@@ -0,0 +1,326 @@ ++// ++// ProfilePreviewView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-23. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++import HealthKit ++import LoopKit ++ ++enum ActiveAlert { ++ case load ++ case delete ++ case error ++} ++ ++struct ProfilePreviewView: View { ++ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference ++ @ObservedObject var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @Environment(\.presentationMode) var presentationMode ++ var profile: Profile ++ ++ @State private var showingAlert = false ++ @State private var activeAlert: ActiveAlert = .load ++ @State private var errorText: String? ++ @State private var newProfileName: String = "" ++ @State private var isRenamingProfile = false ++ @State private var shouldDismissSelf: Bool = false ++ ++ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } ++ ++ var body: some View { ++ ZStack { ++ ScrollView { ++ VStack(alignment: .leading) { ++ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ .font(.body) ++ .foregroundColor(.primary) ++ .padding([.leading, .trailing]) ++ .fixedSize(horizontal: false, vertical: true) ++ .textCase(nil) ++ ++ profileCardStack ++ ++ Button(action: { ++ activeAlert = .load ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Load") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.blue) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ isRenamingProfile = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Rename") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.orange) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ activeAlert = .delete ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Delete") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.red) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ } ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .navigationTitle(Text(profile.name)) ++ .alert(isPresented: $showingAlert) { ++ switch activeAlert { ++ case .load: ++ return Alert( ++ title: Text("Load Profile"), ++ message: Text("Do you want to load the profile \(profile.name)?"), ++ primaryButton: .default(Text("Yes"), action: { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .delete: ++ return Alert( ++ title: Text("Delete Profile"), ++ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), ++ primaryButton: .destructive(Text("Delete"), action: { ++ viewModel.removeProfile(profile: profile) ++ self.presentationMode.wrappedValue.dismiss() ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .error: ++ return Alert( ++ title: Text("Error loading profile"), ++ message: Text(errorText ?? "Unknown error"), ++ dismissButton: .default(Text("OK")) ++ ) ++ } ++ } ++ if isRenamingProfile { ++ // Some hacks here to mimic Alert behaviour. ++ // This function should be migrateed to Alert when iOS 16 is required. ++ Color.black.opacity(0.5) ++ .edgesIgnoringSafeArea(.all) ++ .onTapGesture { ++ isRenamingProfile = false ++ } ++ ++ RenameProfileEditor( ++ isPresented: $isRenamingProfile, ++ currentProfileName: profile.name, ++ viewModel: viewModel, ++ shouldDismissParent: $shouldDismissSelf ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ .offset(y: -22) ++ } ++ } ++ .onChange(of: shouldDismissSelf) { shouldDismiss in ++ if shouldDismiss { ++ self.presentationMode.wrappedValue.dismiss() ++ } ++ } ++ } ++ ++ private var profileCardStack: CardStack { ++ var cards: [Card] = [] ++ ++ cards.append(correctionRangeSection) ++ cards.append(carbRatioSection) ++ cards.append(basalRatesSection) ++ cards.append(insulinSensitivitiesSection) ++ ++ return CardStack(cards: cards) ++ } ++ ++ private var correctionRangeSection: Card { ++ card(for: .glucoseTargetRange) { ++ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleRangeItem(time: items[index].startTime, ++ range: items[index].value, ++ unit: glucoseUnit, ++ guardrail: .correctionRange) ++ } ++ } ++ } ++ } ++ ++ private var carbRatioSection: Card { ++ card(for: .carbRatio) { ++ SectionDivider() ++ let items = profile.carbRatioSchedule.items ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .gramsPerUnit, ++ guardrail: .carbRatio) ++ } ++ } ++ } ++ ++ private var basalRatesSection: Card { ++ card(for: .basalRate) { ++ let items = profile.basalRateSchedule.items ++ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates ++ { ++ SectionDivider() ++ let total = profile.basalRateSchedule.total() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .internationalUnitsPerHour, ++ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) ++ } ++ HStack { ++ Text("Total") ++ .bold() ++ .foregroundColor(.primary) ++ Spacer() ++ Text(String(format: "%.2f ",total)) ++ .foregroundColor(.primary) + ++ Text("U/day") ++ .foregroundColor(.secondary) ++ } ++ } ++ } ++ } ++ ++ private var insulinSensitivitiesSection: Card { ++ card(for: .insulinSensitivity) { ++ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items ++ { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: sensitivityUnit, ++ guardrail: .insulinSensitivity) ++ } ++ } ++ } ++ } ++} ++ ++ ++struct SectionWithContent: View where Content: View { ++ let title: String ++ let content: Content ++ ++ init(title: String, ++ content: () -> Content) ++ { ++ self.title = title ++ self.content = content() ++ } ++ ++ public var body: some View { ++ Section { ++ Text(title) ++ .bold() ++ .frame(maxWidth: .infinity, alignment: .leading) ++ content ++ } ++ } ++} ++ ++// MARK: Utilities ++extension ProfilePreviewView { ++ ++ private var glucoseUnit: HKUnit { ++ displayGlucosePreference.unit ++ } ++ ++ private var sensitivityUnit: HKUnit { ++ glucoseUnit.unitDivided(by: .internationalUnit()) ++ } ++ ++ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { ++ Card { ++ SectionWithContent(title: therapySetting.title, ++ content: content) ++ } ++ } ++} ++ ++fileprivate struct SectionDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -16) ++ } ++} ++ ++fileprivate struct SettingsDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -8) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +new file mode 100644 +index 00000000..1c6244a4 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +@@ -0,0 +1,173 @@ ++// ++// ProfileView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import AVFoundation ++import HealthKit ++import LoopKit ++import SwiftUI ++ ++public struct ProfileView: View { ++ @ObservedObject public var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @State private var newProfileName: String = "" ++ @State private var isAddingNewProfile = false ++ @State private var selectedProfileIndex: Int? = nil ++ @State private var refreshID = UUID() ++ ++ enum ActiveAlert: String, Identifiable { ++ case update, info ++ ++ var id: String { ++ return self.rawValue ++ } ++ } ++ @State private var activeAlert: ActiveAlert? ++ ++ public init(viewModel: ProfileViewModel) { ++ self.viewModel = viewModel ++ } ++ ++ private var dismissButton: some View { ++ Button(action: dismissAction) { ++ Text(LocalizedString("Done", comment: "Text for dismiss button")) ++ .bold() ++ } ++ } ++ ++ public var body: some View { ++ ZStack { ++ NavigationView { ++ ConfigurationPageScrollView( ++ content: { ++ VStack(alignment: .leading) { ++ ++ if !viewModel.profiles.isEmpty { ++ List { ++ ForEach(viewModel.profiles.indices, id: \.self) { index in ++ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { ++ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { ++ HStack { ++ if viewModel.currentProfileName == viewModel.profiles[index].name { ++ Image(systemName: "checkmark") ++ .foregroundColor(Color.blue) ++ } else { ++ // Add indentation to align with the checkmark for the active profile ++ Image(systemName: "checkmark") ++ .opacity(0) // Invisible ++ } ++ ++ Text(viewModel.profiles[index].name) ++ } ++ } ++ } ++ } ++ .onMove(perform: moveProfile) ++ }.id(refreshID) ++ ++ } else { ++ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") ++ .font(.subheadline) ++ .foregroundColor(.secondary) ++ .padding([.leading, .trailing]) ++ .multilineTextAlignment(.leading) ++ .lineLimit(nil) ++ .textCase(nil) ++ } ++ } ++ }, ++ actionArea: { EmptyView() } // no action area in this case ++ ) ++ .navigationBarItems( ++ leading: HStack { ++ Button(action: { withAnimation { isAddingNewProfile = true } }) { ++ Image(systemName: "plus") ++ } ++ ++ Button(action: { activeAlert = .info }) { ++ Image(systemName: "info.circle") ++ .resizable() ++ .frame(width: 24, height: 24) ++ } ++ }, ++ trailing: dismissButton ++ ) ++ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .onAppear { ++ if viewModel.isCurrentProfileOutOfSync() { ++ activeAlert = .update ++ } ++ } ++ ++ .alert(item: $activeAlert) { alertType in ++ switch alertType { ++ case .update: ++ return Alert( ++ title: Text("Profile Outdated"), ++ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), ++ primaryButton: .default(Text("Update")) { ++ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") ++ }, ++ secondaryButton: .cancel() ++ ) ++ case .info: ++ return Alert(title: Text("Information"), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ dismissButton: .default(Text("Got it!"))) ++ } ++ } ++ ++ if isAddingNewProfile { ++ DarkenedOverlay() ++ ++ NewProfileEditor( ++ isPresented: $isAddingNewProfile, ++ newProfileName: newProfileName, ++ viewModel: viewModel ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ } ++ } ++ } ++ ++ func moveProfile(from source: IndexSet, to destination: Int) { ++ viewModel.profiles.move(fromOffsets: source, toOffset: destination) ++ refreshID = UUID() ++ viewModel.updateProfilesOrder() ++ } ++} ++ ++struct ProfileView_Previews: PreviewProvider { ++ static let preview_glucoseScheduleItems = [ ++ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), ++ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), ++ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) ++ ] ++ ++ static let preview_therapySettings = TherapySettings( ++ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), ++ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), ++ workout: DoubleRange(99...111), ++ unit: .milligramsPerDeciliter), ++ maximumBolus: 4, ++ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), ++ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: HKUnit.internationalUnit()), dailyItems: []), ++ carbRatioSchedule: nil, ++ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) ++ ++ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] ++ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] ++ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] ++ ++ static var previews: some View { ++ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +new file mode 100644 +index 00000000..8fd26665 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +@@ -0,0 +1,92 @@ ++// ++// RenameProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-09-07. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct RenameProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var currentProfileName: String ++ @State var newProfileName: String = "" ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ @Binding var shouldDismissParent: Bool ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("Rename Profile") ++ .font(.subheadline) ++ ++ }, ++ trailing: { renameButton } ++ ) ++ .onAppear { ++ self.newProfileName = currentProfileName ++ } ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var renameButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ guard newProfileName != currentProfileName else { ++ self.isPresented = false ++ return ++ } ++ ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Rename") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +Submodule LoopOnboarding contains modified content +diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +index cc61f2d..6462e2c 100644 +--- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift ++++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +@@ -379,6 +379,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { + maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount + ) + } ++ ++ func updateCurrentProfileName() { ++ } + } + + +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index 69f7a26..14a7691 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -24,6 +24,10 @@ + A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; +@@ -158,6 +162,10 @@ + A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; + A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; +@@ -268,6 +276,7 @@ + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, + A934B6172358F49300949C8B /* StoredSettings.swift */, ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, + A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, + A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, +@@ -324,6 +333,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -364,6 +374,7 @@ + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, + ); + path = Notifications; + sourceTree = ""; +@@ -412,6 +423,7 @@ + A9AA6E5729EB06AA008FFA78 /* Actions */ = { + isa = PBXGroup; + children = ( ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, +@@ -744,6 +756,9 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, + A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, + C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, + ); +@@ -757,6 +772,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, + A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +new file mode 100644 +index 0000000..de73ca9 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,104 @@ ++// ++// SavedProfile.swift ++// NightscoutServiceKit ++// ++ ++import CryptoKit ++import Foundation ++import HealthKit ++import LoopKit ++import NightscoutKit ++ ++/// A profile saved with the Profiles settings screen, read from the file it was saved to. ++struct SavedProfile: Decodable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ ++ private static var directory: URL { ++ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") ++ } ++ ++ static func loadAll() -> [SavedProfile] { ++ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] ++ return files ++ .filter { $0.pathExtension == "json" } ++ .compactMap { try? Data(contentsOf: $0) } ++ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } ++ } ++ ++ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. ++ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { ++ var store: [String: ProfileSet.Profile] = [:] ++ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. ++ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { ++ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) ++ } ++ return store ++ } ++ ++ /// True when this profile uploads with this fingerprint, in either glucose unit. ++ func matches(fingerprint: String) -> Bool { ++ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { ++ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint ++ } ++ } ++ ++ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { ++ guard let correctionRange = correctionRange.schedule(for: unit), ++ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) ++ else { ++ return nil ++ } ++ return ProfileSet.Profile( ++ timezone: basalRateSchedule.timeZone, ++ dia: .hours(6), ++ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, ++ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, ++ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) ++ } ++} ++ ++extension ProfileSet.Profile { ++ /// Followers compute the same value from the uploaded profile and send it with a switch command, ++ /// so Loop only activates the values the follower previewed. ++ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` ++ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. ++ var remoteFingerprint: String { ++ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { ++ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") ++ } ++ let canonical = [ ++ "basal=" + items(basal), ++ "carbratio=" + items(carbratio), ++ "sens=" + items(sensitivity), ++ "target_low=" + items(targetLow), ++ "target_high=" + items(targetHigh), ++ ].joined(separator: ";") ++ let digest = SHA256.hash(data: Data(canonical.utf8)) ++ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index 4e1d49e..b66fb97 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -159,7 +159,8 @@ extension StoredDosingDecision { + automaticDoseRecommendation: loopStatusAutomaticDoseRecommendation, + recommendedBolus: loopStatusRecommendedBolus, + enacted: automaticDoseDecision?.loopStatusEnacted, +- failureReason: automaticDoseDecision?.loopStatusFailureReason), ++ failureReason: automaticDoseDecision?.loopStatusFailureReason, ++ testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]]), + overrideStatus: overrideStatus) + } + +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +index 2adadb9..dd7ac28 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +@@ -94,12 +94,21 @@ extension StoredSettings { + return nil + } + ++ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) ++ store["Default"] = profile ++ var defaultProfile = "Default" ++ ++ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { ++ defaultProfile = name ++ store[name] = profile ++ } ++ + return ProfileSet( + startDate: date, + units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), + enteredBy: "Loop", +- defaultProfile: "Default", +- store: ["Default": profile], ++ defaultProfile: defaultProfile, ++ store: store, + settings: loopSettings, + syncIdentifier: syncIdentifier.uuidString, + isAPNSProduction: Bundle.main.isAPNSProductionEnvironment) +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index a92b509..29313b6 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -9,6 +9,7 @@ + import Foundation + + public enum Action: Codable { ++ case profileSwitch(ProfileSwitchAction) + case temporaryScheduleOverride(OverrideAction) + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) +@@ -28,6 +29,8 @@ public enum Action: Codable { + return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") + case .temporaryScheduleOverride: + return LocalizedString("Override", comment: "The remote action name for Override") ++ case .profileSwitch: ++ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") + } + } + +@@ -43,6 +46,8 @@ public enum Action: Codable { + return "" + case .temporaryScheduleOverride(let overrideAction): + return overrideAction.name ++ case .profileSwitch(let profileSwitchAction): ++ return profileSwitchAction.name + } + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +new file mode 100644 +index 0000000..ce03ded +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +@@ -0,0 +1,17 @@ ++// ++// ProfileSwitchAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct ProfileSwitchAction: Codable { ++ ++ public let name: String ++ public let fingerprint: String ++ ++ public init(name: String, fingerprint: String) { ++ self.name = name ++ self.fingerprint = fingerprint ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 95274e4..4457f80 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -45,6 +45,7 @@ class RemoteNotificationResponseManager { + case carbs = "carbs" + case override = "override" + case cancelOverride = "cancel_override" ++ case profileSwitch = "profile_switch" + } + + func sendResponseNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +new file mode 100644 +index 0000000..ebcd6ef +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -0,0 +1,50 @@ ++// ++// ProfileSwitchRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Switches to a saved profile. `expiration` is required: decoding fails without it. ++public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ public let profileName: String ++ public let fingerprint: String ++ public let remoteAddress: String ++ public let expirationDate: Date ++ public let sentAt: Date ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ public var expiration: Date? { ++ return expirationDate ++ } ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expirationDate = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification[commandKey] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 24e63d7..a0f58a0 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -104,6 +104,8 @@ extension Dictionary { + return try OverrideRemoteNotification(dictionary: self) + } else if OverrideCancelRemoteNotification.includedInNotification(self) { + return try OverrideCancelRemoteNotification(dictionary: self) ++ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { ++ return try ProfileSwitchRemoteNotification(dictionary: self) + } else { + throw RemoteNotificationError.unhandledNotification(self) + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +index 84ff118..0912feb 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +@@ -96,6 +96,27 @@ class RemoteCommandSourceV1: RemoteCommandSource { + status: .success(date: Date(), syncIdentifier: carbEntry.syncIdentifier ?? "", completionMessage: nil), + notificationJSON: notification + ) ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ successMessage = "Profile '\(profileSwitchCommand.name)' activated" ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: "", completionMessage: nil), ++ notificationJSON: notification ++ ) + case .temporaryScheduleOverride(let overrideCommand): + commandType = .override + let override = try await serviceDelegate.enactRemoteOverride( +@@ -223,6 +244,10 @@ public class StoredRemoteNotification: NSObject, Codable { + self.init(notificationType: .carbs(carbNotification), notificationJSONData: notificationJSONData) + } + ++ convenience init(profileSwitchNotification: ProfileSwitchRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .profileSwitch(profileSwitchNotification), notificationJSONData: notificationJSONData) ++ } ++ + convenience init(overrideNotification: OverrideRemoteNotification, notificationJSONData: Data) { + self.init(notificationType: .override(overrideNotification), notificationJSONData: notificationJSONData) + } +@@ -306,6 +331,8 @@ public class StoredRemoteNotification: NSObject, Codable { + return bolusNotification + case let .carbs(carbNotification): + return carbNotification ++ case let .profileSwitch(profileSwitchNotification): ++ return profileSwitchNotification + case let .override(overrideNotification): + return overrideNotification + case let .overrideCancel(overrideCancelNotification): +@@ -334,6 +361,7 @@ public class StoredRemoteNotification: NSObject, Codable { + case carbs(CarbRemoteNotification) + case override(OverrideRemoteNotification) + case overrideCancel(OverrideCancelRemoteNotification) ++ case profileSwitch(ProfileSwitchRemoteNotification) + } + } + +@@ -384,6 +412,9 @@ actor RecentNotifications { + } else if let carbNotification = remoteNotification as? CarbRemoteNotification { + let storedNotification = StoredRemoteNotification(carbNotification: carbNotification, notificationJSONData: data) + try storeNotification(storedNotification) ++ } else if let profileSwitchNotification = remoteNotification as? ProfileSwitchRemoteNotification { ++ let storedNotification = StoredRemoteNotification(profileSwitchNotification: profileSwitchNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) + } else if let overrideNotification = remoteNotification as? OverrideRemoteNotification { + let storedNotification = StoredRemoteNotification(overrideNotification: overrideNotification, notificationJSONData: data) + try storeNotification(storedNotification) +diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..2fcec94 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +@@ -0,0 +1,71 @@ ++// ++// ProfileSwitchRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "sent-at": "2023-02-25T20:46:35.778Z", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ] ++ ++ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) ++ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ ++ XCTAssertEqual(profileSwitch.profileName, "Sport") ++ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertTrue(profileSwitch.otpValidationRequired()) ++ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { ++ return XCTFail("Expected a profile switch action") ++ } ++ XCTAssertEqual(action.name, "Sport") ++ } ++ ++ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { ++ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ let profile = ProfileSet.Profile( ++ timezone: TimeZone(secondsFromGMT: 3600)!, ++ dia: .hours(6), ++ sensitivity: items([(0, 45)]), ++ carbratio: items([(0, 10)]), ++ basal: items([(0, 0.85), (21600, 1.1)]), ++ targetLow: items([(0, 100)]), ++ targetHigh: items([(0, 110)]), ++ units: "mg/dL") ++ ++ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") ++ } ++} ++ ++private extension ISO8601DateFormatter { ++ static let fractional: ISO8601DateFormatter = { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ }() ++} +diff --git a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +index 2a3fff3..4ce6ab7 100644 +--- a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift ++++ b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +@@ -56,6 +56,8 @@ extension StoredRemoteNotification { + return "Carbs" + case .override: + return "Override" ++ case .profileSwitch: ++ return "Profile Switch" + case .overrideCancel: + return "Cancel Override" + } +@@ -76,6 +78,8 @@ extension StoredRemoteNotification { + return String(format: "%.0f g", notification.amount) + case .override(let notification): + return notification.name ++ case .profileSwitch(let notification): ++ return notification.profileName + case .overrideCancel: + return "" + } +diff --git a/profiles.loopcustomization b/profiles.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/profiles/dev_profiles_remote_carb_edit.patch b/profiles/dev_profiles_remote_carb_edit.patch new file mode 100644 index 0000000..67ec6ba --- /dev/null +++ b/profiles/dev_profiles_remote_carb_edit.patch @@ -0,0 +1,2031 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index cabca1e6..3b5f1de2 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -433,6 +433,7 @@ final class DeviceDataManager { + ) + + statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) ++ servicesManager.deviceDataManager = self + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, +@@ -1673,6 +1674,10 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { + } + } + ++ func updateCurrentProfileName() { ++ loopManager.updateCurrentProfileName() ++ } ++ + func saveCompletion(therapySettings: TherapySettings) { + + loopManager.mutateSettings { settings in +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index 8f497568..02359815 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -244,6 +244,10 @@ final class LoopDataManager { + lockedSettings.value + } + ++ func updateCurrentProfileName () { ++ notify(forChange: .preferences) ++ } ++ + func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { + var oldValue: LoopSettings! + let newValue = lockedSettings.mutate { settings in +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 3eb3154b..6cefc092 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -293,6 +293,38 @@ extension NotificationManager { + } + } + ++ static func sendRemoteProfileNotification(name: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfile", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteProfileFailureNotification(name: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ private static func remoteProfileNotificationBody(name: String) -> String { ++ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ } ++ + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { + return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + } +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 3b74fd9d..5bf1ec36 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -28,6 +28,7 @@ class ServicesManager { + + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? ++ weak var deviceDataManager: DeviceDataManager? + + private var services = [Service]() + +@@ -456,3 +457,59 @@ extension Service { + ] + } + } ++ ++// MARK: - RemoteProfileActionDelegate ++ ++extension ServicesManager: RemoteProfileActionDelegate { ++ @MainActor ++ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { ++ do { ++ guard let deviceDataManager else { ++ throw RemoteProfileActionError.unavailable ++ } ++ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) ++ guard deviceDataManager.pumpSupportedIncrements() != nil else { ++ throw RemoteProfileActionError.noPump ++ } ++ guard let profile = try viewModel.savedProfile(named: name) else { ++ throw RemoteProfileActionError.profileNotFound(name) ++ } ++ try profile.verify(with: verify) ++ if case .failure(let error) = viewModel.validateProfile(profile) { ++ throw error ++ } ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ viewModel.triggerProfileSync() ++ NotificationManager.sendRemoteProfileNotification(name: name) ++ } catch { ++ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteProfileActionError: LocalizedError { ++ case unavailable ++ case noPump ++ case profileNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .unavailable: ++ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") ++ case .noPump: ++ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") ++ case .profileNotFound(let name): ++ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) ++ } ++ } ++ } ++} +diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift +index 9f9e3aa0..c1c9ea87 100644 +--- a/Loop/Loop/Views/SettingsView.swift ++++ b/Loop/Loop/Views/SettingsView.swift +@@ -50,6 +50,7 @@ public struct SettingsView: View { + } + + case favoriteFoods ++ case profiles + } + } + +@@ -119,6 +120,19 @@ public struct SettingsView: View { + switch sheet { + case .favoriteFoods: + FavoriteFoodsView() ++ case .profiles: ++ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), ++ sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, ++ adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, ++ delegate: self.viewModel.therapySettingsViewModelDelegate)) ++ .environmentObject(displayGlucosePreference) ++ .environment(\.dismissAction, self.dismiss) ++ .environment(\.appName, self.appName) ++ .environment(\.chartColorPalette, .primary) ++ .environment(\.carbTintColor, self.carbTintColor) ++ .environment(\.glucoseTintColor, self.glucoseTintColor) ++ .environment(\.guidanceColors, self.guidanceColors) ++ .environment(\.insulinTintColor, self.insulinTintColor) + } + } + } +@@ -279,6 +293,11 @@ extension SettingsView { + descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) + } + ++ LargeButton(action: { sheet = .profiles }, ++ includeArrow: true, ++ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), ++ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), ++ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) + ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in + item.view + } +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj +index e4423ce6..89045896 100644 +--- a/LoopKit/LoopKit.xcodeproj/project.pbxproj ++++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj +@@ -873,6 +873,12 @@ + D1A0B000000000000000000C /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; + D1A0B000000000000000000D /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; + D1A0B0000000000000000081 /* PersistenceControllerMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */; }; ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; + E9077D2724ACD59F0066A88D /* InformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9077D2624ACD59F0066A88D /* InformationView.swift */; }; + E9077D2A24ACDE2C0066A88D /* CorrectionRangeInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9077D2924ACDE2C0066A88D /* CorrectionRangeInformationView.swift */; }; + E9086B2924B39EDC0062F5C8 /* ChartsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9086B2824B39EDC0062F5C8 /* ChartsTableViewController.swift */; }; +@@ -1813,6 +1819,12 @@ + D1A0B0000000000000000006 /* Modelv5ToModelv6.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = Modelv5ToModelv6.xcmappingmodel; sourceTree = ""; }; + D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelMappingv4v5Tov6Policy.swift; sourceTree = ""; }; + D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; ++ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; + E9077D2624ACD59F0066A88D /* InformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InformationView.swift; sourceTree = ""; }; + E9077D2924ACDE2C0066A88D /* CorrectionRangeInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionRangeInformationView.swift; sourceTree = ""; }; + E9086B2824B39EDC0062F5C8 /* ChartsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartsTableViewController.swift; sourceTree = ""; }; +@@ -3009,11 +3021,13 @@ + isa = PBXGroup; + children = ( + B455F3A325FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift */, ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, + B4B7C1BB2604E3FC007379F6 /* CorrectionRangeScheduleEditorViewModel.swift */, + B4D4C20C25F95A8700DA809D /* DisplayGlucosePreference.swift */, + B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, + B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, + 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, + ); + path = ViewModels; + sourceTree = ""; +@@ -3201,6 +3215,10 @@ + E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, + 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, + 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, ++ DDAF746629F422C600719F0A /* ProfileView.swift */, ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, + ); + path = "Settings Editors"; + sourceTree = ""; +@@ -3910,6 +3928,7 @@ + E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, + B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, + 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, + 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, + E9E5E56A24D5CCE800B5DFFE /* OverrideViewCell.swift in Sources */, + 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, +@@ -3919,6 +3938,7 @@ + B43D69D62A26642300DF0925 /* DemoPlaceHolderView.swift in Sources */, + E949E38F24B3711E00024DA0 /* InsulinModelInformationView.swift in Sources */, + 895FE08B22011F0C00FCF18A /* EmojiInputHeaderView.swift in Sources */, ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, + 895FE08322011F0C00FCF18A /* OverrideSelectionFooterView.swift in Sources */, + 89AF78C22447E353002B4FCC /* Splat.swift in Sources */, + 893C9F8C2447DBD900CD4185 /* CardBuilder.swift in Sources */, +@@ -3969,9 +3989,11 @@ + E9C58A6E24DA65E400487A17 /* HistoricalOverrideDetailView.swift in Sources */, + 895FE08222011F0C00FCF18A /* LabeledTextFieldTableViewCell.swift in Sources */, + B43DA44224D9CD8500CAFF4E /* Environment+Colors.swift in Sources */, ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, + 892A5DB22231E191008961AB /* LoadingTableViewCell.swift in Sources */, + B4D3D4AD25B8A94E0085BA0F /* DisplayGlucoseUnitObserver.swift in Sources */, + A9D3FF102A6C19CA000C891D /* ChartAxisValueDoubleLog.swift in Sources */, ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, + E96DCB5A24AF74AC007117BC /* SuspendThresholdEditor.swift in Sources */, + E96DCB5824AEF50F007117BC /* SuspendThresholdInformationView.swift in Sources */, + A9D3FF0C2A6C198C000C891D /* CollectionType.swift in Sources */, +@@ -4047,6 +4069,7 @@ + E93C86B624D08CAD0073089B /* InsulinSensitivityInformationView.swift in Sources */, + C18733AF29B9492300519CDF /* Collection.swift in Sources */, + 892A5DA22231E137008961AB /* HUDProvider.swift in Sources */, ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, + 898E6E6C224194060019E459 /* UIColor.swift in Sources */, + 43BA717D201EE7090058961E /* GlucoseRangeTableViewCell.swift in Sources */, + B455F3A425FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift in Sources */, +@@ -4073,6 +4096,7 @@ + 1D1065E9282DC54700026A70 /* VideoPlayView.swift in Sources */, + E99A132E2557548300D3F5B3 /* SegmentedGaugeBar.swift in Sources */, + B455F31825FBEC5F000ED456 /* SuspendThresholdEditorViewModel.swift in Sources */, ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, + 892A5DB32231E191008961AB /* LevelHUDView.swift in Sources */, + B41A60B223D1DBC700636320 /* UIFont.swift in Sources */, + B41A60AF23D1DB5B00636320 /* TableViewTitleLabel.swift in Sources */, +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 4b2cecf3..56742c96 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -8,6 +8,12 @@ + + import Foundation + ++/// Switches to a saved therapy profile on a remote command. ++public protocol RemoteProfileActionDelegate: AnyObject { ++ /// `verify` receives the schedules about to be activated and throws to reject them. ++ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws ++} ++ + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws + func cancelRemoteOverride() async throws +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +new file mode 100644 +index 00000000..f48848cd +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +@@ -0,0 +1,426 @@ ++// ++// ProfileViewModel+FileManagement.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import LoopKit ++import HealthKit ++ ++public enum ProfileValidationError: Error, LocalizedError { ++ case fileLoadError ++ case unsupportedIncrement ++ case correctionRangeError ++ case insulinSensitivityError ++ case carbRatioError ++ case basalRateError ++ case maxBasalRateNotSet ++ ++ public var errorDescription: String? { ++ switch self { ++ case .fileLoadError: ++ return "Unable to load the profile file." ++ case .unsupportedIncrement: ++ return "The profile contains unsupported increments." ++ case .correctionRangeError: ++ return "Correction Range values are out of bounds." ++ case .insulinSensitivityError: ++ return "Insulin Sensitivity values are out of bounds." ++ case .carbRatioError: ++ return "Carb Ratio values are out of bounds." ++ case .basalRateError: ++ return "Basal Rate values are out of bounds." ++ case .maxBasalRateNotSet: ++ return "Maximum Basal Rate is not set." ++ } ++ } ++} ++ ++public enum LoadProfileResult { ++ case success ++ case failure(Error) ++} ++ ++// MARK: File management ++extension ProfileViewModel { ++ private func setCurrentProfile(name: String, syncChange: Bool = false) { ++ let sanitizedProfileName = sanitizeProfileName(name) ++ currentProfileName = sanitizedProfileName ++ if syncChange { ++ triggerProfileSync() ++ } ++ } ++ ++ private func clearCurrentProfile() { ++ currentProfileName = nil ++ triggerProfileSync() ++ } ++ ++ private func getCurrentProfileName() -> String? { ++ return currentProfileName ++ } ++ ++ private var profilesDirectory: URL { ++ let fileManager = FileManager.default ++ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] ++ return documentsDirectory.appendingPathComponent("LoopProfile") ++ } ++ ++ private func createProfileDirectoryIfNotExists() throws { ++ let fileManager = FileManager.default ++ var isDir : ObjCBool = false ++ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { ++ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) ++ } else if !isDir.boolValue { ++ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) ++ } ++ } ++ ++ private func getAllProfileFiles() throws -> [URL] { ++ let fileManager = FileManager.default ++ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) ++ return files.filter { $0.pathExtension == "json" } ++ } ++ ++ private func encodeProfile(_ profile: Profile) throws -> Data { ++ return try JSONEncoder().encode(profile) ++ } ++ ++ private func decodeProfile(from data: Data) throws -> Profile { ++ return try JSONDecoder().decode(Profile.self, from: data) ++ } ++ ++ public func sanitizeProfileName(_ name: String) -> String { ++ // Replace problematic characters ++ var sanitized = name.replacingOccurrences(of: ".", with: "_") ++ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") ++ ++ // Limit the name to 32 characters ++ if sanitized.count > 32 { ++ sanitized = String(sanitized.prefix(32)) ++ } ++ ++ return sanitized ++ } ++ ++ public func saveProfile(withName name: String) { ++ let sanitizedName = sanitizeProfileName(name) ++ ++ do { ++ try createProfileDirectoryIfNotExists() ++ ++ if let existingProfile = getProfileReference(withName: sanitizedName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let profile = Profile( ++ name: sanitizedName, ++ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ carbRatioSchedule: therapySettings.carbRatioSchedule!, ++ basalRateSchedule: therapySettings.basalRateSchedule!, ++ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ sortOrder: -1 ++ ) ++ ++ let jsonData = try encodeProfile(profile) ++ ++ let dateFormatter = DateFormatter() ++ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" ++ let fileName = dateFormatter.string(from: Date()) + ".json" ++ let fileURL = profilesDirectory.appendingPathComponent(fileName) ++ ++ try jsonData.write(to: fileURL) ++ setCurrentProfile(name: sanitizedName, syncChange: true) ++ ++ self.loadProfiles() ++ } catch { ++ print("An error occurred while saving profile: \(error)") ++ } ++ } ++ ++ public func renameProfile(oldName: String, newName: String) { ++ let sanitizedProfileName = sanitizeProfileName(newName) ++ ++ if currentProfileName == oldName { ++ setCurrentProfile(name: sanitizedProfileName, syncChange: true) ++ } ++ ++ do { ++ guard let profileReference = getProfileReference(withName: oldName) else { ++ print("Profile with old name not found.") ++ return ++ } ++ ++ let profile = try getProfile(from: profileReference) ++ ++ let newProfile = Profile( ++ name: sanitizedProfileName, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ ++ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ ++ try jsonData.write(to: fileURL) ++ ++ self.loadProfiles() ++ triggerProfileSync() ++ } catch { ++ print("An error occurred while renaming profile: \(error)") ++ } ++ } ++ ++ public func updateProfilesOrder() { ++ for (index, profileReference) in profiles.enumerated() { ++ do { ++ var profile = try getProfile(from: profileReference) ++ let newProfile = Profile( ++ name: profile.name, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: index ++ ) ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try jsonData.write(to: fileURL) ++ ++ } catch { ++ print("An error occurred while updating profile order: \(error)") ++ } ++ } ++ loadProfiles() ++ } ++ ++ public func loadProfiles() { ++ do { ++ let profileFiles = try getAllProfileFiles() ++ ++ var newProfiles = [ProfileReference]() ++ for fileURL in profileFiles { ++ let data = try Data(contentsOf: fileURL) ++ let profile = try decodeProfile(from: data) ++ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) ++ newProfiles.append(profileRef) ++ } ++ ++ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } ++ ++ self.profiles = newProfiles ++ } catch { ++ print("An error occurred while loading profiles: \(error)") ++ } ++ } ++ ++ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { ++ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && ++ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && ++ therapySettings.basalRateSchedule == profile.basalRateSchedule && ++ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { ++ self.setCurrentProfile(name: profile.name, syncChange: true) ++ completion(.success) ++ } ++ else ++ { ++ do { ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ switch result { ++ case .success(let syncedSchedule): ++ DispatchQueue.main.async { ++ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) ++ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) ++ self?.saveBasalRates(basalRates: syncedSchedule) ++ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ print("New profile loaded") ++ self?.setCurrentProfile(name: profile.name, syncChange: false) ++ completion(.success) ++ } ++ case .failure(let error): ++ print("An error occurred while syncing basal rates: \(error)") ++ completion(.failure(error)) ++ } ++ }) ++ } ++ } ++ } ++ ++ func getProfileReference(withName name: String) -> ProfileReference? { ++ let sanitizedInputName = sanitizeProfileName(name) ++ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) ++ } ++ ++ public func removeProfile(profile: Profile) { ++ guard let profileReference = getProfileReference(withName: profile.name) else { ++ print("No ProfileReference found for given Profile") ++ return ++ } ++ ++ removeProfile(profileReference: profileReference) ++ if getCurrentProfileName() == profile.name { ++ clearCurrentProfile() ++ } else { ++ triggerProfileSync() ++ } ++ } ++ ++ public func removeProfile(profileReference: ProfileReference) { ++ do { ++ let fileManager = FileManager.default ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try fileManager.removeItem(at: fileURL) ++ loadProfiles() ++ } catch { ++ print("An error occurred while removing profile: \(error)") ++ } ++ } ++ ++ func doesProfileExist(withName name: String) -> Bool { ++ return profiles.contains(where: { $0.name == name }) ++ } ++ ++ public func getProfile(from profileReference: ProfileReference) throws -> Profile { ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ let data = try Data(contentsOf: fileURL) ++ let getProfile = try decodeProfile(from: data) ++ return getProfile ++ } ++ ++ /// The saved profile named `name`, or nil when there is none. ++ public func savedProfile(named name: String) throws -> Profile? { ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ return nil ++ } ++ return try getProfile(from: profileReference) ++ } ++ ++ public func validateProfile(_ profile: Profile) -> Result { ++ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { ++ return .failure(.fileLoadError) ++ } ++ ++ // Checking Correction Range Schedule ++ for item in profile.correctionRange.items { ++ let minValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) ++ let maxValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) ++ ++ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { ++ return .failure(.correctionRangeError) ++ } ++ } ++ ++ // Checking Insulin Sensitivity Schedule ++ for item in profile.insulinSensitivitySchedule.items { ++ let value = HKQuantity(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: .internationalUnit()), doubleValue: item.value) ++ ++ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { ++ return .failure(.insulinSensitivityError) ++ } ++ } ++ ++ // Checking Carb Ratio Schedule ++ for item in profile.carbRatioSchedule.items { ++ let value = HKQuantity(unit: .gramsPerUnit, doubleValue: item.value) ++ ++ if !Guardrail.carbRatio.absoluteBounds.contains(value) { ++ return .failure(.carbRatioError) ++ } ++ } ++ ++ // Checking Basal Rate Schedule ++ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { ++ for item in profile.basalRateSchedule.items { ++ let value = item.value ++ ++ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { ++ return .failure(.basalRateError) ++ } ++ } ++ } else { ++ return .failure(.maxBasalRateNotSet) ++ } ++ ++ return .success(()) ++ } ++ ++ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value.minValue - item2.value.minValue) > 0.01 || ++ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value - item2.value) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ public func isCurrentProfileOutOfSync() -> Bool { ++ guard let currentProfileName = self.currentProfileName, ++ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), ++ let profile = try? getProfile(from: currentProfileReference), ++ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: HKUnit.milligramsPerDeciliter), ++ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: HKUnit.milligramsPerDeciliter) else { ++ return false // No current profile set or profile not found or schedules not available ++ } ++ ++ var isOutOfSync = false ++ ++ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: HKUnit.milligramsPerDeciliter)!) { ++ print("Glucose target range schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ print("Carb ratio schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ print("Basal rate schedule differs") ++ isOutOfSync = true ++ } ++ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: HKUnit.milligramsPerDeciliter)!) { ++ print("Insulin sensitivity schedule differs") ++ isOutOfSync = true ++ } ++ ++ return isOutOfSync ++ } ++} ++ ++extension Profile { ++ /// Passes the schedules this profile activates to `check`. ++ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { ++ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +new file mode 100644 +index 00000000..a48fa667 +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +@@ -0,0 +1,86 @@ ++// ++// ProfileViewModel.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import Combine ++import LoopKit ++import HealthKit ++import SwiftUI ++ ++public class ProfileViewModel: ObservableObject { ++ ++ @Published public var therapySettings: TherapySettings ++ @Published public var profiles = [ProfileReference]() ++ @Published public var currentProfileName: String? { ++ didSet { ++ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") ++ } ++ } ++ ++ internal weak var delegate: TherapySettingsViewModelDelegate? ++ ++ public init(therapySettings: TherapySettings, ++ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, ++ sensitivityOverridesEnabled: Bool = false, ++ adultChildInsulinModelSelectionEnabled: Bool = false, ++ prescription: Prescription? = nil, ++ delegate: TherapySettingsViewModelDelegate? = nil) { ++ self.therapySettings = therapySettings ++ self.delegate = delegate ++ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") ++ self.loadProfiles() ++ } ++ ++ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return delegate?.pumpSupportedIncrements() ++ } ++} ++ ++public struct Profile: Codable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ var sortOrder: Int? ++} ++ ++public struct ProfileReference: Codable, Equatable { ++ var name: String ++ var fileName: String ++ var sortOrder: Int? ++} ++ ++ ++// MARK: Saving ++extension ProfileViewModel { ++ ++ public func saveCorrectionRange(range: GlucoseRangeSchedule) { ++ therapySettings.glucoseTargetRangeSchedule = range ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveBasalRates(basalRates: BasalRateSchedule) { ++ therapySettings.basalRateSchedule = basalRates ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { ++ therapySettings.carbRatioSchedule = carbRatioSchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { ++ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func triggerProfileSync() { ++ delegate?.updateCurrentProfileName() ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +index 3650ef60..c5c1dc01 100644 +--- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift ++++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { + func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) + func saveCompletion(therapySettings: TherapySettings) + func pumpSupportedIncrements() -> PumpSupportedIncrements? ++ func updateCurrentProfileName() + } + + public class TherapySettingsViewModel: ObservableObject { +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +new file mode 100644 +index 00000000..446910e4 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +@@ -0,0 +1,79 @@ ++// ++// NewProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct NewProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var newProfileName: String ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("New Profile") ++ .font(.headline) ++ }, ++ trailing: { addButton } ++ ) ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var addButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Add") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +new file mode 100644 +index 00000000..6de3598f +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +@@ -0,0 +1,326 @@ ++// ++// ProfilePreviewView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-23. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++import HealthKit ++import LoopKit ++ ++enum ActiveAlert { ++ case load ++ case delete ++ case error ++} ++ ++struct ProfilePreviewView: View { ++ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference ++ @ObservedObject var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @Environment(\.presentationMode) var presentationMode ++ var profile: Profile ++ ++ @State private var showingAlert = false ++ @State private var activeAlert: ActiveAlert = .load ++ @State private var errorText: String? ++ @State private var newProfileName: String = "" ++ @State private var isRenamingProfile = false ++ @State private var shouldDismissSelf: Bool = false ++ ++ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } ++ ++ var body: some View { ++ ZStack { ++ ScrollView { ++ VStack(alignment: .leading) { ++ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ .font(.body) ++ .foregroundColor(.primary) ++ .padding([.leading, .trailing]) ++ .fixedSize(horizontal: false, vertical: true) ++ .textCase(nil) ++ ++ profileCardStack ++ ++ Button(action: { ++ activeAlert = .load ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Load") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.blue) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ isRenamingProfile = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Rename") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.orange) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ activeAlert = .delete ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Delete") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.red) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ } ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .navigationTitle(Text(profile.name)) ++ .alert(isPresented: $showingAlert) { ++ switch activeAlert { ++ case .load: ++ return Alert( ++ title: Text("Load Profile"), ++ message: Text("Do you want to load the profile \(profile.name)?"), ++ primaryButton: .default(Text("Yes"), action: { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .delete: ++ return Alert( ++ title: Text("Delete Profile"), ++ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), ++ primaryButton: .destructive(Text("Delete"), action: { ++ viewModel.removeProfile(profile: profile) ++ self.presentationMode.wrappedValue.dismiss() ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .error: ++ return Alert( ++ title: Text("Error loading profile"), ++ message: Text(errorText ?? "Unknown error"), ++ dismissButton: .default(Text("OK")) ++ ) ++ } ++ } ++ if isRenamingProfile { ++ // Some hacks here to mimic Alert behaviour. ++ // This function should be migrateed to Alert when iOS 16 is required. ++ Color.black.opacity(0.5) ++ .edgesIgnoringSafeArea(.all) ++ .onTapGesture { ++ isRenamingProfile = false ++ } ++ ++ RenameProfileEditor( ++ isPresented: $isRenamingProfile, ++ currentProfileName: profile.name, ++ viewModel: viewModel, ++ shouldDismissParent: $shouldDismissSelf ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ .offset(y: -22) ++ } ++ } ++ .onChange(of: shouldDismissSelf) { shouldDismiss in ++ if shouldDismiss { ++ self.presentationMode.wrappedValue.dismiss() ++ } ++ } ++ } ++ ++ private var profileCardStack: CardStack { ++ var cards: [Card] = [] ++ ++ cards.append(correctionRangeSection) ++ cards.append(carbRatioSection) ++ cards.append(basalRatesSection) ++ cards.append(insulinSensitivitiesSection) ++ ++ return CardStack(cards: cards) ++ } ++ ++ private var correctionRangeSection: Card { ++ card(for: .glucoseTargetRange) { ++ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleRangeItem(time: items[index].startTime, ++ range: items[index].value, ++ unit: glucoseUnit, ++ guardrail: .correctionRange) ++ } ++ } ++ } ++ } ++ ++ private var carbRatioSection: Card { ++ card(for: .carbRatio) { ++ SectionDivider() ++ let items = profile.carbRatioSchedule.items ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .gramsPerUnit, ++ guardrail: .carbRatio) ++ } ++ } ++ } ++ ++ private var basalRatesSection: Card { ++ card(for: .basalRate) { ++ let items = profile.basalRateSchedule.items ++ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates ++ { ++ SectionDivider() ++ let total = profile.basalRateSchedule.total() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .internationalUnitsPerHour, ++ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) ++ } ++ HStack { ++ Text("Total") ++ .bold() ++ .foregroundColor(.primary) ++ Spacer() ++ Text(String(format: "%.2f ",total)) ++ .foregroundColor(.primary) + ++ Text("U/day") ++ .foregroundColor(.secondary) ++ } ++ } ++ } ++ } ++ ++ private var insulinSensitivitiesSection: Card { ++ card(for: .insulinSensitivity) { ++ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items ++ { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: sensitivityUnit, ++ guardrail: .insulinSensitivity) ++ } ++ } ++ } ++ } ++} ++ ++ ++struct SectionWithContent: View where Content: View { ++ let title: String ++ let content: Content ++ ++ init(title: String, ++ content: () -> Content) ++ { ++ self.title = title ++ self.content = content() ++ } ++ ++ public var body: some View { ++ Section { ++ Text(title) ++ .bold() ++ .frame(maxWidth: .infinity, alignment: .leading) ++ content ++ } ++ } ++} ++ ++// MARK: Utilities ++extension ProfilePreviewView { ++ ++ private var glucoseUnit: HKUnit { ++ displayGlucosePreference.unit ++ } ++ ++ private var sensitivityUnit: HKUnit { ++ glucoseUnit.unitDivided(by: .internationalUnit()) ++ } ++ ++ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { ++ Card { ++ SectionWithContent(title: therapySetting.title, ++ content: content) ++ } ++ } ++} ++ ++fileprivate struct SectionDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -16) ++ } ++} ++ ++fileprivate struct SettingsDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -8) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +new file mode 100644 +index 00000000..1c6244a4 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +@@ -0,0 +1,173 @@ ++// ++// ProfileView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import AVFoundation ++import HealthKit ++import LoopKit ++import SwiftUI ++ ++public struct ProfileView: View { ++ @ObservedObject public var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @State private var newProfileName: String = "" ++ @State private var isAddingNewProfile = false ++ @State private var selectedProfileIndex: Int? = nil ++ @State private var refreshID = UUID() ++ ++ enum ActiveAlert: String, Identifiable { ++ case update, info ++ ++ var id: String { ++ return self.rawValue ++ } ++ } ++ @State private var activeAlert: ActiveAlert? ++ ++ public init(viewModel: ProfileViewModel) { ++ self.viewModel = viewModel ++ } ++ ++ private var dismissButton: some View { ++ Button(action: dismissAction) { ++ Text(LocalizedString("Done", comment: "Text for dismiss button")) ++ .bold() ++ } ++ } ++ ++ public var body: some View { ++ ZStack { ++ NavigationView { ++ ConfigurationPageScrollView( ++ content: { ++ VStack(alignment: .leading) { ++ ++ if !viewModel.profiles.isEmpty { ++ List { ++ ForEach(viewModel.profiles.indices, id: \.self) { index in ++ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { ++ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { ++ HStack { ++ if viewModel.currentProfileName == viewModel.profiles[index].name { ++ Image(systemName: "checkmark") ++ .foregroundColor(Color.blue) ++ } else { ++ // Add indentation to align with the checkmark for the active profile ++ Image(systemName: "checkmark") ++ .opacity(0) // Invisible ++ } ++ ++ Text(viewModel.profiles[index].name) ++ } ++ } ++ } ++ } ++ .onMove(perform: moveProfile) ++ }.id(refreshID) ++ ++ } else { ++ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") ++ .font(.subheadline) ++ .foregroundColor(.secondary) ++ .padding([.leading, .trailing]) ++ .multilineTextAlignment(.leading) ++ .lineLimit(nil) ++ .textCase(nil) ++ } ++ } ++ }, ++ actionArea: { EmptyView() } // no action area in this case ++ ) ++ .navigationBarItems( ++ leading: HStack { ++ Button(action: { withAnimation { isAddingNewProfile = true } }) { ++ Image(systemName: "plus") ++ } ++ ++ Button(action: { activeAlert = .info }) { ++ Image(systemName: "info.circle") ++ .resizable() ++ .frame(width: 24, height: 24) ++ } ++ }, ++ trailing: dismissButton ++ ) ++ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .onAppear { ++ if viewModel.isCurrentProfileOutOfSync() { ++ activeAlert = .update ++ } ++ } ++ ++ .alert(item: $activeAlert) { alertType in ++ switch alertType { ++ case .update: ++ return Alert( ++ title: Text("Profile Outdated"), ++ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), ++ primaryButton: .default(Text("Update")) { ++ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") ++ }, ++ secondaryButton: .cancel() ++ ) ++ case .info: ++ return Alert(title: Text("Information"), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ dismissButton: .default(Text("Got it!"))) ++ } ++ } ++ ++ if isAddingNewProfile { ++ DarkenedOverlay() ++ ++ NewProfileEditor( ++ isPresented: $isAddingNewProfile, ++ newProfileName: newProfileName, ++ viewModel: viewModel ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ } ++ } ++ } ++ ++ func moveProfile(from source: IndexSet, to destination: Int) { ++ viewModel.profiles.move(fromOffsets: source, toOffset: destination) ++ refreshID = UUID() ++ viewModel.updateProfilesOrder() ++ } ++} ++ ++struct ProfileView_Previews: PreviewProvider { ++ static let preview_glucoseScheduleItems = [ ++ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), ++ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), ++ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) ++ ] ++ ++ static let preview_therapySettings = TherapySettings( ++ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), ++ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), ++ workout: DoubleRange(99...111), ++ unit: .milligramsPerDeciliter), ++ maximumBolus: 4, ++ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), ++ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: HKUnit.internationalUnit()), dailyItems: []), ++ carbRatioSchedule: nil, ++ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) ++ ++ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] ++ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] ++ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] ++ ++ static var previews: some View { ++ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +new file mode 100644 +index 00000000..8fd26665 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +@@ -0,0 +1,92 @@ ++// ++// RenameProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-09-07. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct RenameProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var currentProfileName: String ++ @State var newProfileName: String = "" ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ @Binding var shouldDismissParent: Bool ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("Rename Profile") ++ .font(.subheadline) ++ ++ }, ++ trailing: { renameButton } ++ ) ++ .onAppear { ++ self.newProfileName = currentProfileName ++ } ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var renameButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ guard newProfileName != currentProfileName else { ++ self.isPresented = false ++ return ++ } ++ ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Rename") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +Submodule LoopOnboarding contains modified content +diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +index cc61f2d..6462e2c 100644 +--- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift ++++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +@@ -379,6 +379,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { + maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount + ) + } ++ ++ func updateCurrentProfileName() { ++ } + } + + +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index 9f75373..18143ab 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -24,6 +24,10 @@ + A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; +@@ -165,6 +169,10 @@ + A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; + A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; +@@ -282,6 +290,7 @@ + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, + A934B6172358F49300949C8B /* StoredSettings.swift */, ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, + A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, + A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, +@@ -341,6 +350,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -383,6 +393,7 @@ + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, + ); + path = Notifications; + sourceTree = ""; +@@ -431,6 +442,7 @@ + A9AA6E5729EB06AA008FFA78 /* Actions */ = { + isa = PBXGroup; + children = ( ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, +@@ -769,6 +781,9 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, + A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, + C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, + ); +@@ -785,6 +800,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, + A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +new file mode 100644 +index 0000000..de73ca9 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,104 @@ ++// ++// SavedProfile.swift ++// NightscoutServiceKit ++// ++ ++import CryptoKit ++import Foundation ++import HealthKit ++import LoopKit ++import NightscoutKit ++ ++/// A profile saved with the Profiles settings screen, read from the file it was saved to. ++struct SavedProfile: Decodable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ ++ private static var directory: URL { ++ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") ++ } ++ ++ static func loadAll() -> [SavedProfile] { ++ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] ++ return files ++ .filter { $0.pathExtension == "json" } ++ .compactMap { try? Data(contentsOf: $0) } ++ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } ++ } ++ ++ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. ++ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { ++ var store: [String: ProfileSet.Profile] = [:] ++ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. ++ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { ++ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) ++ } ++ return store ++ } ++ ++ /// True when this profile uploads with this fingerprint, in either glucose unit. ++ func matches(fingerprint: String) -> Bool { ++ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { ++ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint ++ } ++ } ++ ++ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { ++ guard let correctionRange = correctionRange.schedule(for: unit), ++ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) ++ else { ++ return nil ++ } ++ return ProfileSet.Profile( ++ timezone: basalRateSchedule.timeZone, ++ dia: .hours(6), ++ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, ++ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, ++ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) ++ } ++} ++ ++extension ProfileSet.Profile { ++ /// Followers compute the same value from the uploaded profile and send it with a switch command, ++ /// so Loop only activates the values the follower previewed. ++ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` ++ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. ++ var remoteFingerprint: String { ++ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { ++ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") ++ } ++ let canonical = [ ++ "basal=" + items(basal), ++ "carbratio=" + items(carbratio), ++ "sens=" + items(sensitivity), ++ "target_low=" + items(targetLow), ++ "target_high=" + items(targetHigh), ++ ].joined(separator: ";") ++ let digest = SHA256.hash(data: Data(canonical.utf8)) ++ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index 637988b..7c780ea 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -160,7 +160,7 @@ extension StoredDosingDecision { + recommendedBolus: loopStatusRecommendedBolus, + enacted: automaticDoseDecision?.loopStatusEnacted, + failureReason: automaticDoseDecision?.loopStatusFailureReason, +- testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported]), ++ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]]), + overrideStatus: overrideStatus) + } + +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +index 2adadb9..dd7ac28 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +@@ -94,12 +94,21 @@ extension StoredSettings { + return nil + } + ++ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) ++ store["Default"] = profile ++ var defaultProfile = "Default" ++ ++ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { ++ defaultProfile = name ++ store[name] = profile ++ } ++ + return ProfileSet( + startDate: date, + units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), + enteredBy: "Loop", +- defaultProfile: "Default", +- store: ["Default": profile], ++ defaultProfile: defaultProfile, ++ store: store, + settings: loopSettings, + syncIdentifier: syncIdentifier.uuidString, + isAPNSProduction: Bundle.main.isAPNSProductionEnvironment) +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index 169fb86..18ac515 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -437,6 +437,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + success = true + message = "Override '\(overrideCommand.name)' enacted successfully" + ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ success = true ++ message = "Profile '\(profileSwitchCommand.name)' activated" ++ + case .cancelTemporaryOverride: + commandType = .cancelOverride + try await self.serviceDelegate?.cancelRemoteOverride() +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index 91ad56c..29d3563 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -9,6 +9,7 @@ + import Foundation + + public enum Action: Codable { ++ case profileSwitch(ProfileSwitchAction) + case temporaryScheduleOverride(OverrideAction) + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) +@@ -34,6 +35,8 @@ public enum Action: Codable { + return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") + case .temporaryScheduleOverride: + return LocalizedString("Override", comment: "The remote action name for Override") ++ case .profileSwitch: ++ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") + } + } + +@@ -54,6 +57,8 @@ public enum Action: Codable { + return "" + case .temporaryScheduleOverride(let overrideAction): + return overrideAction.name ++ case .profileSwitch(let profileSwitchAction): ++ return profileSwitchAction.name + } + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +new file mode 100644 +index 0000000..ce03ded +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +@@ -0,0 +1,17 @@ ++// ++// ProfileSwitchAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct ProfileSwitchAction: Codable { ++ ++ public let name: String ++ public let fingerprint: String ++ ++ public init(name: String, fingerprint: String) { ++ self.name = name ++ self.fingerprint = fingerprint ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 6092bc9..aabb47f 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -49,6 +49,7 @@ class RemoteNotificationResponseManager { + case carbsEdit = "carbs_edit" + case override = "override" + case cancelOverride = "cancel_override" ++ case profileSwitch = "profile_switch" + } + + func sendResponseNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +new file mode 100644 +index 0000000..487c620 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -0,0 +1,50 @@ ++// ++// ProfileSwitchRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Switches to a saved profile. `expiration` is required: decoding fails without it. ++public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ public let profileName: String ++ public let fingerprint: String ++ public let remoteAddress: String ++ public let expirationDate: Date ++ public let sentAt: Date? ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ public var expiration: Date? { ++ return expirationDate ++ } ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expirationDate = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification[commandKey] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 6f0524d..87f1e92 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -122,6 +122,8 @@ extension Dictionary { + return try OverrideRemoteNotification(dictionary: self) + } else if OverrideCancelRemoteNotification.includedInNotification(self) { + return try OverrideCancelRemoteNotification(dictionary: self) ++ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { ++ return try ProfileSwitchRemoteNotification(dictionary: self) + } else { + throw RemoteNotificationError.unhandledNotification(self) + } +diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..2fcec94 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +@@ -0,0 +1,71 @@ ++// ++// ProfileSwitchRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "sent-at": "2023-02-25T20:46:35.778Z", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ] ++ ++ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) ++ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ ++ XCTAssertEqual(profileSwitch.profileName, "Sport") ++ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertTrue(profileSwitch.otpValidationRequired()) ++ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { ++ return XCTFail("Expected a profile switch action") ++ } ++ XCTAssertEqual(action.name, "Sport") ++ } ++ ++ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { ++ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ let profile = ProfileSet.Profile( ++ timezone: TimeZone(secondsFromGMT: 3600)!, ++ dia: .hours(6), ++ sensitivity: items([(0, 45)]), ++ carbratio: items([(0, 10)]), ++ basal: items([(0, 0.85), (21600, 1.1)]), ++ targetLow: items([(0, 100)]), ++ targetHigh: items([(0, 110)]), ++ units: "mg/dL") ++ ++ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") ++ } ++} ++ ++private extension ISO8601DateFormatter { ++ static let fractional: ISO8601DateFormatter = { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ }() ++} +diff --git a/profiles.loopcustomization b/profiles.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/profiles/dev_profiles_remote_carb_edit_remote_window.patch b/profiles/dev_profiles_remote_carb_edit_remote_window.patch new file mode 100644 index 0000000..e8599f8 --- /dev/null +++ b/profiles/dev_profiles_remote_carb_edit_remote_window.patch @@ -0,0 +1,2094 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index 8cc5714a..c253e88e 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -433,6 +433,7 @@ final class DeviceDataManager { + ) + + statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) ++ servicesManager.deviceDataManager = self + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, +@@ -1725,6 +1726,10 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { + } + } + ++ func updateCurrentProfileName() { ++ loopManager.updateCurrentProfileName() ++ } ++ + func saveCompletion(therapySettings: TherapySettings) { + + loopManager.mutateSettings { settings in +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index 974122ce..03ffe6c7 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -244,6 +244,10 @@ final class LoopDataManager { + lockedSettings.value + } + ++ func updateCurrentProfileName () { ++ notify(forChange: .preferences) ++ } ++ + func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { + var oldValue: LoopSettings! + let newValue = lockedSettings.mutate { settings in +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 3eb3154b..6cefc092 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -293,6 +293,38 @@ extension NotificationManager { + } + } + ++ static func sendRemoteProfileNotification(name: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfile", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteProfileFailureNotification(name: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ private static func remoteProfileNotificationBody(name: String) -> String { ++ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ } ++ + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { + return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + } +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 1e272bb2..161a42e3 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -28,6 +28,7 @@ class ServicesManager { + + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? ++ weak var deviceDataManager: DeviceDataManager? + + private var services = [Service]() + +@@ -482,3 +483,59 @@ extension Service { + ] + } + } ++ ++// MARK: - RemoteProfileActionDelegate ++ ++extension ServicesManager: RemoteProfileActionDelegate { ++ @MainActor ++ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { ++ do { ++ guard let deviceDataManager else { ++ throw RemoteProfileActionError.unavailable ++ } ++ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) ++ guard deviceDataManager.pumpSupportedIncrements() != nil else { ++ throw RemoteProfileActionError.noPump ++ } ++ guard let profile = try viewModel.savedProfile(named: name) else { ++ throw RemoteProfileActionError.profileNotFound(name) ++ } ++ try profile.verify(with: verify) ++ if case .failure(let error) = viewModel.validateProfile(profile) { ++ throw error ++ } ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ viewModel.triggerProfileSync() ++ NotificationManager.sendRemoteProfileNotification(name: name) ++ } catch { ++ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteProfileActionError: LocalizedError { ++ case unavailable ++ case noPump ++ case profileNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .unavailable: ++ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") ++ case .noPump: ++ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") ++ case .profileNotFound(let name): ++ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) ++ } ++ } ++ } ++} +diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift +index 9f9e3aa0..c1c9ea87 100644 +--- a/Loop/Loop/Views/SettingsView.swift ++++ b/Loop/Loop/Views/SettingsView.swift +@@ -50,6 +50,7 @@ public struct SettingsView: View { + } + + case favoriteFoods ++ case profiles + } + } + +@@ -119,6 +120,19 @@ public struct SettingsView: View { + switch sheet { + case .favoriteFoods: + FavoriteFoodsView() ++ case .profiles: ++ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), ++ sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, ++ adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, ++ delegate: self.viewModel.therapySettingsViewModelDelegate)) ++ .environmentObject(displayGlucosePreference) ++ .environment(\.dismissAction, self.dismiss) ++ .environment(\.appName, self.appName) ++ .environment(\.chartColorPalette, .primary) ++ .environment(\.carbTintColor, self.carbTintColor) ++ .environment(\.glucoseTintColor, self.glucoseTintColor) ++ .environment(\.guidanceColors, self.guidanceColors) ++ .environment(\.insulinTintColor, self.insulinTintColor) + } + } + } +@@ -279,6 +293,11 @@ extension SettingsView { + descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) + } + ++ LargeButton(action: { sheet = .profiles }, ++ includeArrow: true, ++ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), ++ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), ++ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) + ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in + item.view + } +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj +index e4423ce6..89045896 100644 +--- a/LoopKit/LoopKit.xcodeproj/project.pbxproj ++++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj +@@ -873,6 +873,12 @@ + D1A0B000000000000000000C /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; + D1A0B000000000000000000D /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; + D1A0B0000000000000000081 /* PersistenceControllerMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */; }; ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; + E9077D2724ACD59F0066A88D /* InformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9077D2624ACD59F0066A88D /* InformationView.swift */; }; + E9077D2A24ACDE2C0066A88D /* CorrectionRangeInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9077D2924ACDE2C0066A88D /* CorrectionRangeInformationView.swift */; }; + E9086B2924B39EDC0062F5C8 /* ChartsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9086B2824B39EDC0062F5C8 /* ChartsTableViewController.swift */; }; +@@ -1813,6 +1819,12 @@ + D1A0B0000000000000000006 /* Modelv5ToModelv6.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = Modelv5ToModelv6.xcmappingmodel; sourceTree = ""; }; + D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelMappingv4v5Tov6Policy.swift; sourceTree = ""; }; + D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; ++ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; + E9077D2624ACD59F0066A88D /* InformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InformationView.swift; sourceTree = ""; }; + E9077D2924ACDE2C0066A88D /* CorrectionRangeInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionRangeInformationView.swift; sourceTree = ""; }; + E9086B2824B39EDC0062F5C8 /* ChartsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartsTableViewController.swift; sourceTree = ""; }; +@@ -3009,11 +3021,13 @@ + isa = PBXGroup; + children = ( + B455F3A325FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift */, ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, + B4B7C1BB2604E3FC007379F6 /* CorrectionRangeScheduleEditorViewModel.swift */, + B4D4C20C25F95A8700DA809D /* DisplayGlucosePreference.swift */, + B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, + B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, + 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, + ); + path = ViewModels; + sourceTree = ""; +@@ -3201,6 +3215,10 @@ + E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, + 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, + 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, ++ DDAF746629F422C600719F0A /* ProfileView.swift */, ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, + ); + path = "Settings Editors"; + sourceTree = ""; +@@ -3910,6 +3928,7 @@ + E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, + B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, + 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, + 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, + E9E5E56A24D5CCE800B5DFFE /* OverrideViewCell.swift in Sources */, + 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, +@@ -3919,6 +3938,7 @@ + B43D69D62A26642300DF0925 /* DemoPlaceHolderView.swift in Sources */, + E949E38F24B3711E00024DA0 /* InsulinModelInformationView.swift in Sources */, + 895FE08B22011F0C00FCF18A /* EmojiInputHeaderView.swift in Sources */, ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, + 895FE08322011F0C00FCF18A /* OverrideSelectionFooterView.swift in Sources */, + 89AF78C22447E353002B4FCC /* Splat.swift in Sources */, + 893C9F8C2447DBD900CD4185 /* CardBuilder.swift in Sources */, +@@ -3969,9 +3989,11 @@ + E9C58A6E24DA65E400487A17 /* HistoricalOverrideDetailView.swift in Sources */, + 895FE08222011F0C00FCF18A /* LabeledTextFieldTableViewCell.swift in Sources */, + B43DA44224D9CD8500CAFF4E /* Environment+Colors.swift in Sources */, ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, + 892A5DB22231E191008961AB /* LoadingTableViewCell.swift in Sources */, + B4D3D4AD25B8A94E0085BA0F /* DisplayGlucoseUnitObserver.swift in Sources */, + A9D3FF102A6C19CA000C891D /* ChartAxisValueDoubleLog.swift in Sources */, ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, + E96DCB5A24AF74AC007117BC /* SuspendThresholdEditor.swift in Sources */, + E96DCB5824AEF50F007117BC /* SuspendThresholdInformationView.swift in Sources */, + A9D3FF0C2A6C198C000C891D /* CollectionType.swift in Sources */, +@@ -4047,6 +4069,7 @@ + E93C86B624D08CAD0073089B /* InsulinSensitivityInformationView.swift in Sources */, + C18733AF29B9492300519CDF /* Collection.swift in Sources */, + 892A5DA22231E137008961AB /* HUDProvider.swift in Sources */, ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, + 898E6E6C224194060019E459 /* UIColor.swift in Sources */, + 43BA717D201EE7090058961E /* GlucoseRangeTableViewCell.swift in Sources */, + B455F3A425FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift in Sources */, +@@ -4073,6 +4096,7 @@ + 1D1065E9282DC54700026A70 /* VideoPlayView.swift in Sources */, + E99A132E2557548300D3F5B3 /* SegmentedGaugeBar.swift in Sources */, + B455F31825FBEC5F000ED456 /* SuspendThresholdEditorViewModel.swift in Sources */, ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, + 892A5DB32231E191008961AB /* LevelHUDView.swift in Sources */, + B41A60B223D1DBC700636320 /* UIFont.swift in Sources */, + B41A60AF23D1DB5B00636320 /* TableViewTitleLabel.swift in Sources */, +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 8fb254b6..858a5621 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -8,6 +8,12 @@ + + import Foundation + ++/// Switches to a saved therapy profile on a remote command. ++public protocol RemoteProfileActionDelegate: AnyObject { ++ /// `verify` receives the schedules about to be activated and throws to reject them. ++ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws ++} ++ + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride + func cancelRemoteOverride() async throws -> TemporaryScheduleOverride +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +new file mode 100644 +index 00000000..f48848cd +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +@@ -0,0 +1,426 @@ ++// ++// ProfileViewModel+FileManagement.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import LoopKit ++import HealthKit ++ ++public enum ProfileValidationError: Error, LocalizedError { ++ case fileLoadError ++ case unsupportedIncrement ++ case correctionRangeError ++ case insulinSensitivityError ++ case carbRatioError ++ case basalRateError ++ case maxBasalRateNotSet ++ ++ public var errorDescription: String? { ++ switch self { ++ case .fileLoadError: ++ return "Unable to load the profile file." ++ case .unsupportedIncrement: ++ return "The profile contains unsupported increments." ++ case .correctionRangeError: ++ return "Correction Range values are out of bounds." ++ case .insulinSensitivityError: ++ return "Insulin Sensitivity values are out of bounds." ++ case .carbRatioError: ++ return "Carb Ratio values are out of bounds." ++ case .basalRateError: ++ return "Basal Rate values are out of bounds." ++ case .maxBasalRateNotSet: ++ return "Maximum Basal Rate is not set." ++ } ++ } ++} ++ ++public enum LoadProfileResult { ++ case success ++ case failure(Error) ++} ++ ++// MARK: File management ++extension ProfileViewModel { ++ private func setCurrentProfile(name: String, syncChange: Bool = false) { ++ let sanitizedProfileName = sanitizeProfileName(name) ++ currentProfileName = sanitizedProfileName ++ if syncChange { ++ triggerProfileSync() ++ } ++ } ++ ++ private func clearCurrentProfile() { ++ currentProfileName = nil ++ triggerProfileSync() ++ } ++ ++ private func getCurrentProfileName() -> String? { ++ return currentProfileName ++ } ++ ++ private var profilesDirectory: URL { ++ let fileManager = FileManager.default ++ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] ++ return documentsDirectory.appendingPathComponent("LoopProfile") ++ } ++ ++ private func createProfileDirectoryIfNotExists() throws { ++ let fileManager = FileManager.default ++ var isDir : ObjCBool = false ++ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { ++ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) ++ } else if !isDir.boolValue { ++ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) ++ } ++ } ++ ++ private func getAllProfileFiles() throws -> [URL] { ++ let fileManager = FileManager.default ++ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) ++ return files.filter { $0.pathExtension == "json" } ++ } ++ ++ private func encodeProfile(_ profile: Profile) throws -> Data { ++ return try JSONEncoder().encode(profile) ++ } ++ ++ private func decodeProfile(from data: Data) throws -> Profile { ++ return try JSONDecoder().decode(Profile.self, from: data) ++ } ++ ++ public func sanitizeProfileName(_ name: String) -> String { ++ // Replace problematic characters ++ var sanitized = name.replacingOccurrences(of: ".", with: "_") ++ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") ++ ++ // Limit the name to 32 characters ++ if sanitized.count > 32 { ++ sanitized = String(sanitized.prefix(32)) ++ } ++ ++ return sanitized ++ } ++ ++ public func saveProfile(withName name: String) { ++ let sanitizedName = sanitizeProfileName(name) ++ ++ do { ++ try createProfileDirectoryIfNotExists() ++ ++ if let existingProfile = getProfileReference(withName: sanitizedName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let profile = Profile( ++ name: sanitizedName, ++ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ carbRatioSchedule: therapySettings.carbRatioSchedule!, ++ basalRateSchedule: therapySettings.basalRateSchedule!, ++ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ sortOrder: -1 ++ ) ++ ++ let jsonData = try encodeProfile(profile) ++ ++ let dateFormatter = DateFormatter() ++ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" ++ let fileName = dateFormatter.string(from: Date()) + ".json" ++ let fileURL = profilesDirectory.appendingPathComponent(fileName) ++ ++ try jsonData.write(to: fileURL) ++ setCurrentProfile(name: sanitizedName, syncChange: true) ++ ++ self.loadProfiles() ++ } catch { ++ print("An error occurred while saving profile: \(error)") ++ } ++ } ++ ++ public func renameProfile(oldName: String, newName: String) { ++ let sanitizedProfileName = sanitizeProfileName(newName) ++ ++ if currentProfileName == oldName { ++ setCurrentProfile(name: sanitizedProfileName, syncChange: true) ++ } ++ ++ do { ++ guard let profileReference = getProfileReference(withName: oldName) else { ++ print("Profile with old name not found.") ++ return ++ } ++ ++ let profile = try getProfile(from: profileReference) ++ ++ let newProfile = Profile( ++ name: sanitizedProfileName, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ ++ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ ++ try jsonData.write(to: fileURL) ++ ++ self.loadProfiles() ++ triggerProfileSync() ++ } catch { ++ print("An error occurred while renaming profile: \(error)") ++ } ++ } ++ ++ public func updateProfilesOrder() { ++ for (index, profileReference) in profiles.enumerated() { ++ do { ++ var profile = try getProfile(from: profileReference) ++ let newProfile = Profile( ++ name: profile.name, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: index ++ ) ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try jsonData.write(to: fileURL) ++ ++ } catch { ++ print("An error occurred while updating profile order: \(error)") ++ } ++ } ++ loadProfiles() ++ } ++ ++ public func loadProfiles() { ++ do { ++ let profileFiles = try getAllProfileFiles() ++ ++ var newProfiles = [ProfileReference]() ++ for fileURL in profileFiles { ++ let data = try Data(contentsOf: fileURL) ++ let profile = try decodeProfile(from: data) ++ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) ++ newProfiles.append(profileRef) ++ } ++ ++ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } ++ ++ self.profiles = newProfiles ++ } catch { ++ print("An error occurred while loading profiles: \(error)") ++ } ++ } ++ ++ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { ++ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && ++ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && ++ therapySettings.basalRateSchedule == profile.basalRateSchedule && ++ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { ++ self.setCurrentProfile(name: profile.name, syncChange: true) ++ completion(.success) ++ } ++ else ++ { ++ do { ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ switch result { ++ case .success(let syncedSchedule): ++ DispatchQueue.main.async { ++ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) ++ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) ++ self?.saveBasalRates(basalRates: syncedSchedule) ++ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ print("New profile loaded") ++ self?.setCurrentProfile(name: profile.name, syncChange: false) ++ completion(.success) ++ } ++ case .failure(let error): ++ print("An error occurred while syncing basal rates: \(error)") ++ completion(.failure(error)) ++ } ++ }) ++ } ++ } ++ } ++ ++ func getProfileReference(withName name: String) -> ProfileReference? { ++ let sanitizedInputName = sanitizeProfileName(name) ++ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) ++ } ++ ++ public func removeProfile(profile: Profile) { ++ guard let profileReference = getProfileReference(withName: profile.name) else { ++ print("No ProfileReference found for given Profile") ++ return ++ } ++ ++ removeProfile(profileReference: profileReference) ++ if getCurrentProfileName() == profile.name { ++ clearCurrentProfile() ++ } else { ++ triggerProfileSync() ++ } ++ } ++ ++ public func removeProfile(profileReference: ProfileReference) { ++ do { ++ let fileManager = FileManager.default ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try fileManager.removeItem(at: fileURL) ++ loadProfiles() ++ } catch { ++ print("An error occurred while removing profile: \(error)") ++ } ++ } ++ ++ func doesProfileExist(withName name: String) -> Bool { ++ return profiles.contains(where: { $0.name == name }) ++ } ++ ++ public func getProfile(from profileReference: ProfileReference) throws -> Profile { ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ let data = try Data(contentsOf: fileURL) ++ let getProfile = try decodeProfile(from: data) ++ return getProfile ++ } ++ ++ /// The saved profile named `name`, or nil when there is none. ++ public func savedProfile(named name: String) throws -> Profile? { ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ return nil ++ } ++ return try getProfile(from: profileReference) ++ } ++ ++ public func validateProfile(_ profile: Profile) -> Result { ++ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { ++ return .failure(.fileLoadError) ++ } ++ ++ // Checking Correction Range Schedule ++ for item in profile.correctionRange.items { ++ let minValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) ++ let maxValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) ++ ++ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { ++ return .failure(.correctionRangeError) ++ } ++ } ++ ++ // Checking Insulin Sensitivity Schedule ++ for item in profile.insulinSensitivitySchedule.items { ++ let value = HKQuantity(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: .internationalUnit()), doubleValue: item.value) ++ ++ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { ++ return .failure(.insulinSensitivityError) ++ } ++ } ++ ++ // Checking Carb Ratio Schedule ++ for item in profile.carbRatioSchedule.items { ++ let value = HKQuantity(unit: .gramsPerUnit, doubleValue: item.value) ++ ++ if !Guardrail.carbRatio.absoluteBounds.contains(value) { ++ return .failure(.carbRatioError) ++ } ++ } ++ ++ // Checking Basal Rate Schedule ++ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { ++ for item in profile.basalRateSchedule.items { ++ let value = item.value ++ ++ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { ++ return .failure(.basalRateError) ++ } ++ } ++ } else { ++ return .failure(.maxBasalRateNotSet) ++ } ++ ++ return .success(()) ++ } ++ ++ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value.minValue - item2.value.minValue) > 0.01 || ++ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value - item2.value) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ public func isCurrentProfileOutOfSync() -> Bool { ++ guard let currentProfileName = self.currentProfileName, ++ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), ++ let profile = try? getProfile(from: currentProfileReference), ++ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: HKUnit.milligramsPerDeciliter), ++ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: HKUnit.milligramsPerDeciliter) else { ++ return false // No current profile set or profile not found or schedules not available ++ } ++ ++ var isOutOfSync = false ++ ++ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: HKUnit.milligramsPerDeciliter)!) { ++ print("Glucose target range schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ print("Carb ratio schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ print("Basal rate schedule differs") ++ isOutOfSync = true ++ } ++ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: HKUnit.milligramsPerDeciliter)!) { ++ print("Insulin sensitivity schedule differs") ++ isOutOfSync = true ++ } ++ ++ return isOutOfSync ++ } ++} ++ ++extension Profile { ++ /// Passes the schedules this profile activates to `check`. ++ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { ++ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +new file mode 100644 +index 00000000..a48fa667 +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +@@ -0,0 +1,86 @@ ++// ++// ProfileViewModel.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import Combine ++import LoopKit ++import HealthKit ++import SwiftUI ++ ++public class ProfileViewModel: ObservableObject { ++ ++ @Published public var therapySettings: TherapySettings ++ @Published public var profiles = [ProfileReference]() ++ @Published public var currentProfileName: String? { ++ didSet { ++ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") ++ } ++ } ++ ++ internal weak var delegate: TherapySettingsViewModelDelegate? ++ ++ public init(therapySettings: TherapySettings, ++ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, ++ sensitivityOverridesEnabled: Bool = false, ++ adultChildInsulinModelSelectionEnabled: Bool = false, ++ prescription: Prescription? = nil, ++ delegate: TherapySettingsViewModelDelegate? = nil) { ++ self.therapySettings = therapySettings ++ self.delegate = delegate ++ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") ++ self.loadProfiles() ++ } ++ ++ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return delegate?.pumpSupportedIncrements() ++ } ++} ++ ++public struct Profile: Codable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ var sortOrder: Int? ++} ++ ++public struct ProfileReference: Codable, Equatable { ++ var name: String ++ var fileName: String ++ var sortOrder: Int? ++} ++ ++ ++// MARK: Saving ++extension ProfileViewModel { ++ ++ public func saveCorrectionRange(range: GlucoseRangeSchedule) { ++ therapySettings.glucoseTargetRangeSchedule = range ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveBasalRates(basalRates: BasalRateSchedule) { ++ therapySettings.basalRateSchedule = basalRates ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { ++ therapySettings.carbRatioSchedule = carbRatioSchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { ++ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func triggerProfileSync() { ++ delegate?.updateCurrentProfileName() ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +index 3650ef60..c5c1dc01 100644 +--- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift ++++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { + func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) + func saveCompletion(therapySettings: TherapySettings) + func pumpSupportedIncrements() -> PumpSupportedIncrements? ++ func updateCurrentProfileName() + } + + public class TherapySettingsViewModel: ObservableObject { +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +new file mode 100644 +index 00000000..446910e4 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +@@ -0,0 +1,79 @@ ++// ++// NewProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct NewProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var newProfileName: String ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("New Profile") ++ .font(.headline) ++ }, ++ trailing: { addButton } ++ ) ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var addButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Add") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +new file mode 100644 +index 00000000..6de3598f +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +@@ -0,0 +1,326 @@ ++// ++// ProfilePreviewView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-23. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++import HealthKit ++import LoopKit ++ ++enum ActiveAlert { ++ case load ++ case delete ++ case error ++} ++ ++struct ProfilePreviewView: View { ++ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference ++ @ObservedObject var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @Environment(\.presentationMode) var presentationMode ++ var profile: Profile ++ ++ @State private var showingAlert = false ++ @State private var activeAlert: ActiveAlert = .load ++ @State private var errorText: String? ++ @State private var newProfileName: String = "" ++ @State private var isRenamingProfile = false ++ @State private var shouldDismissSelf: Bool = false ++ ++ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } ++ ++ var body: some View { ++ ZStack { ++ ScrollView { ++ VStack(alignment: .leading) { ++ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ .font(.body) ++ .foregroundColor(.primary) ++ .padding([.leading, .trailing]) ++ .fixedSize(horizontal: false, vertical: true) ++ .textCase(nil) ++ ++ profileCardStack ++ ++ Button(action: { ++ activeAlert = .load ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Load") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.blue) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ isRenamingProfile = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Rename") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.orange) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ activeAlert = .delete ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Delete") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.red) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ } ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .navigationTitle(Text(profile.name)) ++ .alert(isPresented: $showingAlert) { ++ switch activeAlert { ++ case .load: ++ return Alert( ++ title: Text("Load Profile"), ++ message: Text("Do you want to load the profile \(profile.name)?"), ++ primaryButton: .default(Text("Yes"), action: { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .delete: ++ return Alert( ++ title: Text("Delete Profile"), ++ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), ++ primaryButton: .destructive(Text("Delete"), action: { ++ viewModel.removeProfile(profile: profile) ++ self.presentationMode.wrappedValue.dismiss() ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .error: ++ return Alert( ++ title: Text("Error loading profile"), ++ message: Text(errorText ?? "Unknown error"), ++ dismissButton: .default(Text("OK")) ++ ) ++ } ++ } ++ if isRenamingProfile { ++ // Some hacks here to mimic Alert behaviour. ++ // This function should be migrateed to Alert when iOS 16 is required. ++ Color.black.opacity(0.5) ++ .edgesIgnoringSafeArea(.all) ++ .onTapGesture { ++ isRenamingProfile = false ++ } ++ ++ RenameProfileEditor( ++ isPresented: $isRenamingProfile, ++ currentProfileName: profile.name, ++ viewModel: viewModel, ++ shouldDismissParent: $shouldDismissSelf ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ .offset(y: -22) ++ } ++ } ++ .onChange(of: shouldDismissSelf) { shouldDismiss in ++ if shouldDismiss { ++ self.presentationMode.wrappedValue.dismiss() ++ } ++ } ++ } ++ ++ private var profileCardStack: CardStack { ++ var cards: [Card] = [] ++ ++ cards.append(correctionRangeSection) ++ cards.append(carbRatioSection) ++ cards.append(basalRatesSection) ++ cards.append(insulinSensitivitiesSection) ++ ++ return CardStack(cards: cards) ++ } ++ ++ private var correctionRangeSection: Card { ++ card(for: .glucoseTargetRange) { ++ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleRangeItem(time: items[index].startTime, ++ range: items[index].value, ++ unit: glucoseUnit, ++ guardrail: .correctionRange) ++ } ++ } ++ } ++ } ++ ++ private var carbRatioSection: Card { ++ card(for: .carbRatio) { ++ SectionDivider() ++ let items = profile.carbRatioSchedule.items ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .gramsPerUnit, ++ guardrail: .carbRatio) ++ } ++ } ++ } ++ ++ private var basalRatesSection: Card { ++ card(for: .basalRate) { ++ let items = profile.basalRateSchedule.items ++ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates ++ { ++ SectionDivider() ++ let total = profile.basalRateSchedule.total() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .internationalUnitsPerHour, ++ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) ++ } ++ HStack { ++ Text("Total") ++ .bold() ++ .foregroundColor(.primary) ++ Spacer() ++ Text(String(format: "%.2f ",total)) ++ .foregroundColor(.primary) + ++ Text("U/day") ++ .foregroundColor(.secondary) ++ } ++ } ++ } ++ } ++ ++ private var insulinSensitivitiesSection: Card { ++ card(for: .insulinSensitivity) { ++ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items ++ { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: sensitivityUnit, ++ guardrail: .insulinSensitivity) ++ } ++ } ++ } ++ } ++} ++ ++ ++struct SectionWithContent: View where Content: View { ++ let title: String ++ let content: Content ++ ++ init(title: String, ++ content: () -> Content) ++ { ++ self.title = title ++ self.content = content() ++ } ++ ++ public var body: some View { ++ Section { ++ Text(title) ++ .bold() ++ .frame(maxWidth: .infinity, alignment: .leading) ++ content ++ } ++ } ++} ++ ++// MARK: Utilities ++extension ProfilePreviewView { ++ ++ private var glucoseUnit: HKUnit { ++ displayGlucosePreference.unit ++ } ++ ++ private var sensitivityUnit: HKUnit { ++ glucoseUnit.unitDivided(by: .internationalUnit()) ++ } ++ ++ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { ++ Card { ++ SectionWithContent(title: therapySetting.title, ++ content: content) ++ } ++ } ++} ++ ++fileprivate struct SectionDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -16) ++ } ++} ++ ++fileprivate struct SettingsDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -8) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +new file mode 100644 +index 00000000..1c6244a4 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +@@ -0,0 +1,173 @@ ++// ++// ProfileView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import AVFoundation ++import HealthKit ++import LoopKit ++import SwiftUI ++ ++public struct ProfileView: View { ++ @ObservedObject public var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @State private var newProfileName: String = "" ++ @State private var isAddingNewProfile = false ++ @State private var selectedProfileIndex: Int? = nil ++ @State private var refreshID = UUID() ++ ++ enum ActiveAlert: String, Identifiable { ++ case update, info ++ ++ var id: String { ++ return self.rawValue ++ } ++ } ++ @State private var activeAlert: ActiveAlert? ++ ++ public init(viewModel: ProfileViewModel) { ++ self.viewModel = viewModel ++ } ++ ++ private var dismissButton: some View { ++ Button(action: dismissAction) { ++ Text(LocalizedString("Done", comment: "Text for dismiss button")) ++ .bold() ++ } ++ } ++ ++ public var body: some View { ++ ZStack { ++ NavigationView { ++ ConfigurationPageScrollView( ++ content: { ++ VStack(alignment: .leading) { ++ ++ if !viewModel.profiles.isEmpty { ++ List { ++ ForEach(viewModel.profiles.indices, id: \.self) { index in ++ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { ++ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { ++ HStack { ++ if viewModel.currentProfileName == viewModel.profiles[index].name { ++ Image(systemName: "checkmark") ++ .foregroundColor(Color.blue) ++ } else { ++ // Add indentation to align with the checkmark for the active profile ++ Image(systemName: "checkmark") ++ .opacity(0) // Invisible ++ } ++ ++ Text(viewModel.profiles[index].name) ++ } ++ } ++ } ++ } ++ .onMove(perform: moveProfile) ++ }.id(refreshID) ++ ++ } else { ++ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") ++ .font(.subheadline) ++ .foregroundColor(.secondary) ++ .padding([.leading, .trailing]) ++ .multilineTextAlignment(.leading) ++ .lineLimit(nil) ++ .textCase(nil) ++ } ++ } ++ }, ++ actionArea: { EmptyView() } // no action area in this case ++ ) ++ .navigationBarItems( ++ leading: HStack { ++ Button(action: { withAnimation { isAddingNewProfile = true } }) { ++ Image(systemName: "plus") ++ } ++ ++ Button(action: { activeAlert = .info }) { ++ Image(systemName: "info.circle") ++ .resizable() ++ .frame(width: 24, height: 24) ++ } ++ }, ++ trailing: dismissButton ++ ) ++ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .onAppear { ++ if viewModel.isCurrentProfileOutOfSync() { ++ activeAlert = .update ++ } ++ } ++ ++ .alert(item: $activeAlert) { alertType in ++ switch alertType { ++ case .update: ++ return Alert( ++ title: Text("Profile Outdated"), ++ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), ++ primaryButton: .default(Text("Update")) { ++ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") ++ }, ++ secondaryButton: .cancel() ++ ) ++ case .info: ++ return Alert(title: Text("Information"), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ dismissButton: .default(Text("Got it!"))) ++ } ++ } ++ ++ if isAddingNewProfile { ++ DarkenedOverlay() ++ ++ NewProfileEditor( ++ isPresented: $isAddingNewProfile, ++ newProfileName: newProfileName, ++ viewModel: viewModel ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ } ++ } ++ } ++ ++ func moveProfile(from source: IndexSet, to destination: Int) { ++ viewModel.profiles.move(fromOffsets: source, toOffset: destination) ++ refreshID = UUID() ++ viewModel.updateProfilesOrder() ++ } ++} ++ ++struct ProfileView_Previews: PreviewProvider { ++ static let preview_glucoseScheduleItems = [ ++ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), ++ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), ++ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) ++ ] ++ ++ static let preview_therapySettings = TherapySettings( ++ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), ++ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), ++ workout: DoubleRange(99...111), ++ unit: .milligramsPerDeciliter), ++ maximumBolus: 4, ++ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), ++ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: HKUnit.internationalUnit()), dailyItems: []), ++ carbRatioSchedule: nil, ++ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) ++ ++ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] ++ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] ++ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] ++ ++ static var previews: some View { ++ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +new file mode 100644 +index 00000000..8fd26665 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +@@ -0,0 +1,92 @@ ++// ++// RenameProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-09-07. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct RenameProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var currentProfileName: String ++ @State var newProfileName: String = "" ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ @Binding var shouldDismissParent: Bool ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("Rename Profile") ++ .font(.subheadline) ++ ++ }, ++ trailing: { renameButton } ++ ) ++ .onAppear { ++ self.newProfileName = currentProfileName ++ } ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var renameButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ guard newProfileName != currentProfileName else { ++ self.isPresented = false ++ return ++ } ++ ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Rename") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +Submodule LoopOnboarding contains modified content +diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +index cc61f2d..6462e2c 100644 +--- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift ++++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +@@ -379,6 +379,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { + maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount + ) + } ++ ++ func updateCurrentProfileName() { ++ } + } + + +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index 9f75373..18143ab 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -24,6 +24,10 @@ + A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; +@@ -165,6 +169,10 @@ + A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; + A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; +@@ -282,6 +290,7 @@ + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, + A934B6172358F49300949C8B /* StoredSettings.swift */, ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, + A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, + A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, +@@ -341,6 +350,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -383,6 +393,7 @@ + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, + ); + path = Notifications; + sourceTree = ""; +@@ -431,6 +442,7 @@ + A9AA6E5729EB06AA008FFA78 /* Actions */ = { + isa = PBXGroup; + children = ( ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, +@@ -769,6 +781,9 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, + A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, + C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, + ); +@@ -785,6 +800,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, + A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +new file mode 100644 +index 0000000..de73ca9 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,104 @@ ++// ++// SavedProfile.swift ++// NightscoutServiceKit ++// ++ ++import CryptoKit ++import Foundation ++import HealthKit ++import LoopKit ++import NightscoutKit ++ ++/// A profile saved with the Profiles settings screen, read from the file it was saved to. ++struct SavedProfile: Decodable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ ++ private static var directory: URL { ++ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") ++ } ++ ++ static func loadAll() -> [SavedProfile] { ++ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] ++ return files ++ .filter { $0.pathExtension == "json" } ++ .compactMap { try? Data(contentsOf: $0) } ++ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } ++ } ++ ++ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. ++ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { ++ var store: [String: ProfileSet.Profile] = [:] ++ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. ++ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { ++ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) ++ } ++ return store ++ } ++ ++ /// True when this profile uploads with this fingerprint, in either glucose unit. ++ func matches(fingerprint: String) -> Bool { ++ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { ++ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint ++ } ++ } ++ ++ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { ++ guard let correctionRange = correctionRange.schedule(for: unit), ++ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) ++ else { ++ return nil ++ } ++ return ProfileSet.Profile( ++ timezone: basalRateSchedule.timeZone, ++ dia: .hours(6), ++ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, ++ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, ++ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) ++ } ++} ++ ++extension ProfileSet.Profile { ++ /// Followers compute the same value from the uploaded profile and send it with a switch command, ++ /// so Loop only activates the values the follower previewed. ++ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` ++ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. ++ var remoteFingerprint: String { ++ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { ++ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") ++ } ++ let canonical = [ ++ "basal=" + items(basal), ++ "carbratio=" + items(carbratio), ++ "sens=" + items(sensitivity), ++ "target_low=" + items(targetLow), ++ "target_high=" + items(targetHigh), ++ ].joined(separator: ";") ++ let digest = SHA256.hash(data: Data(canonical.utf8)) ++ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index 637988b..7c780ea 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -160,7 +160,7 @@ extension StoredDosingDecision { + recommendedBolus: loopStatusRecommendedBolus, + enacted: automaticDoseDecision?.loopStatusEnacted, + failureReason: automaticDoseDecision?.loopStatusFailureReason, +- testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported]), ++ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]]), + overrideStatus: overrideStatus) + } + +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +index 2adadb9..dd7ac28 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +@@ -94,12 +94,21 @@ extension StoredSettings { + return nil + } + ++ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) ++ store["Default"] = profile ++ var defaultProfile = "Default" ++ ++ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { ++ defaultProfile = name ++ store[name] = profile ++ } ++ + return ProfileSet( + startDate: date, + units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), + enteredBy: "Loop", +- defaultProfile: "Default", +- store: ["Default": profile], ++ defaultProfile: defaultProfile, ++ store: store, + settings: loopSettings, + syncIdentifier: syncIdentifier.uuidString, + isAPNSProduction: Bundle.main.isAPNSProductionEnvironment) +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index 91ad56c..29d3563 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -9,6 +9,7 @@ + import Foundation + + public enum Action: Codable { ++ case profileSwitch(ProfileSwitchAction) + case temporaryScheduleOverride(OverrideAction) + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) +@@ -34,6 +35,8 @@ public enum Action: Codable { + return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") + case .temporaryScheduleOverride: + return LocalizedString("Override", comment: "The remote action name for Override") ++ case .profileSwitch: ++ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") + } + } + +@@ -54,6 +57,8 @@ public enum Action: Codable { + return "" + case .temporaryScheduleOverride(let overrideAction): + return overrideAction.name ++ case .profileSwitch(let profileSwitchAction): ++ return profileSwitchAction.name + } + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +new file mode 100644 +index 0000000..ce03ded +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +@@ -0,0 +1,17 @@ ++// ++// ProfileSwitchAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct ProfileSwitchAction: Codable { ++ ++ public let name: String ++ public let fingerprint: String ++ ++ public init(name: String, fingerprint: String) { ++ self.name = name ++ self.fingerprint = fingerprint ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 6092bc9..aabb47f 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -49,6 +49,7 @@ class RemoteNotificationResponseManager { + case carbsEdit = "carbs_edit" + case override = "override" + case cancelOverride = "cancel_override" ++ case profileSwitch = "profile_switch" + } + + func sendResponseNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +new file mode 100644 +index 0000000..ebcd6ef +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -0,0 +1,50 @@ ++// ++// ProfileSwitchRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Switches to a saved profile. `expiration` is required: decoding fails without it. ++public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ public let profileName: String ++ public let fingerprint: String ++ public let remoteAddress: String ++ public let expirationDate: Date ++ public let sentAt: Date ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ public var expiration: Date? { ++ return expirationDate ++ } ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expirationDate = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification[commandKey] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index be43210..1ea8b96 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -118,6 +118,8 @@ extension Dictionary { + return try OverrideRemoteNotification(dictionary: self) + } else if OverrideCancelRemoteNotification.includedInNotification(self) { + return try OverrideCancelRemoteNotification(dictionary: self) ++ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { ++ return try ProfileSwitchRemoteNotification(dictionary: self) + } else { + throw RemoteNotificationError.unhandledNotification(self) + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +index 331ed5c..bff69c8 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +@@ -123,6 +123,27 @@ class RemoteCommandSourceV1: RemoteCommandSource { + status: .success(date: Date(), syncIdentifier: replacementEntry.syncIdentifier ?? "", completionMessage: nil), + notificationJSON: notification + ) ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ successMessage = "Profile '\(profileSwitchCommand.name)' activated" ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: "", completionMessage: nil), ++ notificationJSON: notification ++ ) + case .temporaryScheduleOverride(let overrideCommand): + commandType = .override + let override = try await serviceDelegate.enactRemoteOverride( +@@ -260,6 +281,10 @@ public class StoredRemoteNotification: NSObject, Codable { + self.init(notificationType: .carbsEdit(carbEditNotification), notificationJSONData: notificationJSONData) + } + ++ convenience init(profileSwitchNotification: ProfileSwitchRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .profileSwitch(profileSwitchNotification), notificationJSONData: notificationJSONData) ++ } ++ + convenience init(overrideNotification: OverrideRemoteNotification, notificationJSONData: Data) { + self.init(notificationType: .override(overrideNotification), notificationJSONData: notificationJSONData) + } +@@ -347,6 +372,8 @@ public class StoredRemoteNotification: NSObject, Codable { + return carbDeleteNotification + case let .carbsEdit(carbEditNotification): + return carbEditNotification ++ case let .profileSwitch(profileSwitchNotification): ++ return profileSwitchNotification + case let .override(overrideNotification): + return overrideNotification + case let .overrideCancel(overrideCancelNotification): +@@ -377,6 +404,7 @@ public class StoredRemoteNotification: NSObject, Codable { + case carbsEdit(CarbEditRemoteNotification) + case override(OverrideRemoteNotification) + case overrideCancel(OverrideCancelRemoteNotification) ++ case profileSwitch(ProfileSwitchRemoteNotification) + } + } + +@@ -433,6 +461,9 @@ actor RecentNotifications { + } else if let carbEditNotification = remoteNotification as? CarbEditRemoteNotification { + let storedNotification = StoredRemoteNotification(carbEditNotification: carbEditNotification, notificationJSONData: data) + try storeNotification(storedNotification) ++ } else if let profileSwitchNotification = remoteNotification as? ProfileSwitchRemoteNotification { ++ let storedNotification = StoredRemoteNotification(profileSwitchNotification: profileSwitchNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) + } else if let overrideNotification = remoteNotification as? OverrideRemoteNotification { + let storedNotification = StoredRemoteNotification(overrideNotification: overrideNotification, notificationJSONData: data) + try storeNotification(storedNotification) +diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..2fcec94 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +@@ -0,0 +1,71 @@ ++// ++// ProfileSwitchRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "sent-at": "2023-02-25T20:46:35.778Z", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ] ++ ++ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) ++ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ ++ XCTAssertEqual(profileSwitch.profileName, "Sport") ++ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertTrue(profileSwitch.otpValidationRequired()) ++ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { ++ return XCTFail("Expected a profile switch action") ++ } ++ XCTAssertEqual(action.name, "Sport") ++ } ++ ++ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { ++ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ let profile = ProfileSet.Profile( ++ timezone: TimeZone(secondsFromGMT: 3600)!, ++ dia: .hours(6), ++ sensitivity: items([(0, 45)]), ++ carbratio: items([(0, 10)]), ++ basal: items([(0, 0.85), (21600, 1.1)]), ++ targetLow: items([(0, 100)]), ++ targetHigh: items([(0, 110)]), ++ units: "mg/dL") ++ ++ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") ++ } ++} ++ ++private extension ISO8601DateFormatter { ++ static let fractional: ISO8601DateFormatter = { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ }() ++} +diff --git a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +index 0bfd600..bb9423b 100644 +--- a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift ++++ b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +@@ -60,6 +60,8 @@ extension StoredRemoteNotification { + return "Edit Carbs" + case .override: + return "Override" ++ case .profileSwitch: ++ return "Profile Switch" + case .overrideCancel: + return "Cancel Override" + } +@@ -84,6 +86,8 @@ extension StoredRemoteNotification { + return String(format: "%.0f g", notification.amount) + case .override(let notification): + return notification.name ++ case .profileSwitch(let notification): ++ return notification.profileName + case .overrideCancel: + return "" + } +diff --git a/profiles.loopcustomization b/profiles.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/profiles/dev_profiles_remote_window.patch b/profiles/dev_profiles_remote_window.patch new file mode 100644 index 0000000..186c582 --- /dev/null +++ b/profiles/dev_profiles_remote_window.patch @@ -0,0 +1,2095 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index 8cc5714a..c253e88e 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -433,6 +433,7 @@ final class DeviceDataManager { + ) + + statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) ++ servicesManager.deviceDataManager = self + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, +@@ -1725,6 +1726,10 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { + } + } + ++ func updateCurrentProfileName() { ++ loopManager.updateCurrentProfileName() ++ } ++ + func saveCompletion(therapySettings: TherapySettings) { + + loopManager.mutateSettings { settings in +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index ff60c424..0a5c29eb 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -244,6 +244,10 @@ final class LoopDataManager { + lockedSettings.value + } + ++ func updateCurrentProfileName () { ++ notify(forChange: .preferences) ++ } ++ + func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { + var oldValue: LoopSettings! + let newValue = lockedSettings.mutate { settings in +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 996d1470..17cff75d 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -263,6 +263,38 @@ extension NotificationManager { + } + } + ++ static func sendRemoteProfileNotification(name: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfile", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteProfileFailureNotification(name: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ private static func remoteProfileNotificationBody(name: String) -> String { ++ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ } ++ + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { + return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + } +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 0b498420..f5f0d3c5 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -28,6 +28,7 @@ class ServicesManager { + + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? ++ weak var deviceDataManager: DeviceDataManager? + + private var services = [Service]() + +@@ -447,3 +448,59 @@ extension Service { + ] + } + } ++ ++// MARK: - RemoteProfileActionDelegate ++ ++extension ServicesManager: RemoteProfileActionDelegate { ++ @MainActor ++ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { ++ do { ++ guard let deviceDataManager else { ++ throw RemoteProfileActionError.unavailable ++ } ++ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) ++ guard deviceDataManager.pumpSupportedIncrements() != nil else { ++ throw RemoteProfileActionError.noPump ++ } ++ guard let profile = try viewModel.savedProfile(named: name) else { ++ throw RemoteProfileActionError.profileNotFound(name) ++ } ++ try profile.verify(with: verify) ++ if case .failure(let error) = viewModel.validateProfile(profile) { ++ throw error ++ } ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ viewModel.triggerProfileSync() ++ NotificationManager.sendRemoteProfileNotification(name: name) ++ } catch { ++ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteProfileActionError: LocalizedError { ++ case unavailable ++ case noPump ++ case profileNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .unavailable: ++ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") ++ case .noPump: ++ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") ++ case .profileNotFound(let name): ++ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) ++ } ++ } ++ } ++} +diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift +index 9f9e3aa0..c1c9ea87 100644 +--- a/Loop/Loop/Views/SettingsView.swift ++++ b/Loop/Loop/Views/SettingsView.swift +@@ -50,6 +50,7 @@ public struct SettingsView: View { + } + + case favoriteFoods ++ case profiles + } + } + +@@ -119,6 +120,19 @@ public struct SettingsView: View { + switch sheet { + case .favoriteFoods: + FavoriteFoodsView() ++ case .profiles: ++ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), ++ sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, ++ adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, ++ delegate: self.viewModel.therapySettingsViewModelDelegate)) ++ .environmentObject(displayGlucosePreference) ++ .environment(\.dismissAction, self.dismiss) ++ .environment(\.appName, self.appName) ++ .environment(\.chartColorPalette, .primary) ++ .environment(\.carbTintColor, self.carbTintColor) ++ .environment(\.glucoseTintColor, self.glucoseTintColor) ++ .environment(\.guidanceColors, self.guidanceColors) ++ .environment(\.insulinTintColor, self.insulinTintColor) + } + } + } +@@ -279,6 +293,11 @@ extension SettingsView { + descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) + } + ++ LargeButton(action: { sheet = .profiles }, ++ includeArrow: true, ++ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), ++ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), ++ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) + ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in + item.view + } +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj +index e4423ce6..89045896 100644 +--- a/LoopKit/LoopKit.xcodeproj/project.pbxproj ++++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj +@@ -873,6 +873,12 @@ + D1A0B000000000000000000C /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; + D1A0B000000000000000000D /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; + D1A0B0000000000000000081 /* PersistenceControllerMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */; }; ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; + E9077D2724ACD59F0066A88D /* InformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9077D2624ACD59F0066A88D /* InformationView.swift */; }; + E9077D2A24ACDE2C0066A88D /* CorrectionRangeInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9077D2924ACDE2C0066A88D /* CorrectionRangeInformationView.swift */; }; + E9086B2924B39EDC0062F5C8 /* ChartsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9086B2824B39EDC0062F5C8 /* ChartsTableViewController.swift */; }; +@@ -1813,6 +1819,12 @@ + D1A0B0000000000000000006 /* Modelv5ToModelv6.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = Modelv5ToModelv6.xcmappingmodel; sourceTree = ""; }; + D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelMappingv4v5Tov6Policy.swift; sourceTree = ""; }; + D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; ++ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; + E9077D2624ACD59F0066A88D /* InformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InformationView.swift; sourceTree = ""; }; + E9077D2924ACDE2C0066A88D /* CorrectionRangeInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionRangeInformationView.swift; sourceTree = ""; }; + E9086B2824B39EDC0062F5C8 /* ChartsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartsTableViewController.swift; sourceTree = ""; }; +@@ -3009,11 +3021,13 @@ + isa = PBXGroup; + children = ( + B455F3A325FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift */, ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, + B4B7C1BB2604E3FC007379F6 /* CorrectionRangeScheduleEditorViewModel.swift */, + B4D4C20C25F95A8700DA809D /* DisplayGlucosePreference.swift */, + B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, + B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, + 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, + ); + path = ViewModels; + sourceTree = ""; +@@ -3201,6 +3215,10 @@ + E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, + 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, + 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, ++ DDAF746629F422C600719F0A /* ProfileView.swift */, ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, + ); + path = "Settings Editors"; + sourceTree = ""; +@@ -3910,6 +3928,7 @@ + E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, + B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, + 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, + 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, + E9E5E56A24D5CCE800B5DFFE /* OverrideViewCell.swift in Sources */, + 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, +@@ -3919,6 +3938,7 @@ + B43D69D62A26642300DF0925 /* DemoPlaceHolderView.swift in Sources */, + E949E38F24B3711E00024DA0 /* InsulinModelInformationView.swift in Sources */, + 895FE08B22011F0C00FCF18A /* EmojiInputHeaderView.swift in Sources */, ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, + 895FE08322011F0C00FCF18A /* OverrideSelectionFooterView.swift in Sources */, + 89AF78C22447E353002B4FCC /* Splat.swift in Sources */, + 893C9F8C2447DBD900CD4185 /* CardBuilder.swift in Sources */, +@@ -3969,9 +3989,11 @@ + E9C58A6E24DA65E400487A17 /* HistoricalOverrideDetailView.swift in Sources */, + 895FE08222011F0C00FCF18A /* LabeledTextFieldTableViewCell.swift in Sources */, + B43DA44224D9CD8500CAFF4E /* Environment+Colors.swift in Sources */, ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, + 892A5DB22231E191008961AB /* LoadingTableViewCell.swift in Sources */, + B4D3D4AD25B8A94E0085BA0F /* DisplayGlucoseUnitObserver.swift in Sources */, + A9D3FF102A6C19CA000C891D /* ChartAxisValueDoubleLog.swift in Sources */, ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, + E96DCB5A24AF74AC007117BC /* SuspendThresholdEditor.swift in Sources */, + E96DCB5824AEF50F007117BC /* SuspendThresholdInformationView.swift in Sources */, + A9D3FF0C2A6C198C000C891D /* CollectionType.swift in Sources */, +@@ -4047,6 +4069,7 @@ + E93C86B624D08CAD0073089B /* InsulinSensitivityInformationView.swift in Sources */, + C18733AF29B9492300519CDF /* Collection.swift in Sources */, + 892A5DA22231E137008961AB /* HUDProvider.swift in Sources */, ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, + 898E6E6C224194060019E459 /* UIColor.swift in Sources */, + 43BA717D201EE7090058961E /* GlucoseRangeTableViewCell.swift in Sources */, + B455F3A425FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift in Sources */, +@@ -4073,6 +4096,7 @@ + 1D1065E9282DC54700026A70 /* VideoPlayView.swift in Sources */, + E99A132E2557548300D3F5B3 /* SegmentedGaugeBar.swift in Sources */, + B455F31825FBEC5F000ED456 /* SuspendThresholdEditorViewModel.swift in Sources */, ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, + 892A5DB32231E191008961AB /* LevelHUDView.swift in Sources */, + B41A60B223D1DBC700636320 /* UIFont.swift in Sources */, + B41A60AF23D1DB5B00636320 /* TableViewTitleLabel.swift in Sources */, +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index e4279746..6f691598 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -8,6 +8,12 @@ + + import Foundation + ++/// Switches to a saved therapy profile on a remote command. ++public protocol RemoteProfileActionDelegate: AnyObject { ++ /// `verify` receives the schedules about to be activated and throws to reject them. ++ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws ++} ++ + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride + func cancelRemoteOverride() async throws -> TemporaryScheduleOverride +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +new file mode 100644 +index 00000000..f48848cd +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +@@ -0,0 +1,426 @@ ++// ++// ProfileViewModel+FileManagement.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import LoopKit ++import HealthKit ++ ++public enum ProfileValidationError: Error, LocalizedError { ++ case fileLoadError ++ case unsupportedIncrement ++ case correctionRangeError ++ case insulinSensitivityError ++ case carbRatioError ++ case basalRateError ++ case maxBasalRateNotSet ++ ++ public var errorDescription: String? { ++ switch self { ++ case .fileLoadError: ++ return "Unable to load the profile file." ++ case .unsupportedIncrement: ++ return "The profile contains unsupported increments." ++ case .correctionRangeError: ++ return "Correction Range values are out of bounds." ++ case .insulinSensitivityError: ++ return "Insulin Sensitivity values are out of bounds." ++ case .carbRatioError: ++ return "Carb Ratio values are out of bounds." ++ case .basalRateError: ++ return "Basal Rate values are out of bounds." ++ case .maxBasalRateNotSet: ++ return "Maximum Basal Rate is not set." ++ } ++ } ++} ++ ++public enum LoadProfileResult { ++ case success ++ case failure(Error) ++} ++ ++// MARK: File management ++extension ProfileViewModel { ++ private func setCurrentProfile(name: String, syncChange: Bool = false) { ++ let sanitizedProfileName = sanitizeProfileName(name) ++ currentProfileName = sanitizedProfileName ++ if syncChange { ++ triggerProfileSync() ++ } ++ } ++ ++ private func clearCurrentProfile() { ++ currentProfileName = nil ++ triggerProfileSync() ++ } ++ ++ private func getCurrentProfileName() -> String? { ++ return currentProfileName ++ } ++ ++ private var profilesDirectory: URL { ++ let fileManager = FileManager.default ++ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] ++ return documentsDirectory.appendingPathComponent("LoopProfile") ++ } ++ ++ private func createProfileDirectoryIfNotExists() throws { ++ let fileManager = FileManager.default ++ var isDir : ObjCBool = false ++ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { ++ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) ++ } else if !isDir.boolValue { ++ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) ++ } ++ } ++ ++ private func getAllProfileFiles() throws -> [URL] { ++ let fileManager = FileManager.default ++ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) ++ return files.filter { $0.pathExtension == "json" } ++ } ++ ++ private func encodeProfile(_ profile: Profile) throws -> Data { ++ return try JSONEncoder().encode(profile) ++ } ++ ++ private func decodeProfile(from data: Data) throws -> Profile { ++ return try JSONDecoder().decode(Profile.self, from: data) ++ } ++ ++ public func sanitizeProfileName(_ name: String) -> String { ++ // Replace problematic characters ++ var sanitized = name.replacingOccurrences(of: ".", with: "_") ++ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") ++ ++ // Limit the name to 32 characters ++ if sanitized.count > 32 { ++ sanitized = String(sanitized.prefix(32)) ++ } ++ ++ return sanitized ++ } ++ ++ public func saveProfile(withName name: String) { ++ let sanitizedName = sanitizeProfileName(name) ++ ++ do { ++ try createProfileDirectoryIfNotExists() ++ ++ if let existingProfile = getProfileReference(withName: sanitizedName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let profile = Profile( ++ name: sanitizedName, ++ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ carbRatioSchedule: therapySettings.carbRatioSchedule!, ++ basalRateSchedule: therapySettings.basalRateSchedule!, ++ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ sortOrder: -1 ++ ) ++ ++ let jsonData = try encodeProfile(profile) ++ ++ let dateFormatter = DateFormatter() ++ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" ++ let fileName = dateFormatter.string(from: Date()) + ".json" ++ let fileURL = profilesDirectory.appendingPathComponent(fileName) ++ ++ try jsonData.write(to: fileURL) ++ setCurrentProfile(name: sanitizedName, syncChange: true) ++ ++ self.loadProfiles() ++ } catch { ++ print("An error occurred while saving profile: \(error)") ++ } ++ } ++ ++ public func renameProfile(oldName: String, newName: String) { ++ let sanitizedProfileName = sanitizeProfileName(newName) ++ ++ if currentProfileName == oldName { ++ setCurrentProfile(name: sanitizedProfileName, syncChange: true) ++ } ++ ++ do { ++ guard let profileReference = getProfileReference(withName: oldName) else { ++ print("Profile with old name not found.") ++ return ++ } ++ ++ let profile = try getProfile(from: profileReference) ++ ++ let newProfile = Profile( ++ name: sanitizedProfileName, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ ++ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ ++ try jsonData.write(to: fileURL) ++ ++ self.loadProfiles() ++ triggerProfileSync() ++ } catch { ++ print("An error occurred while renaming profile: \(error)") ++ } ++ } ++ ++ public func updateProfilesOrder() { ++ for (index, profileReference) in profiles.enumerated() { ++ do { ++ var profile = try getProfile(from: profileReference) ++ let newProfile = Profile( ++ name: profile.name, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: index ++ ) ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try jsonData.write(to: fileURL) ++ ++ } catch { ++ print("An error occurred while updating profile order: \(error)") ++ } ++ } ++ loadProfiles() ++ } ++ ++ public func loadProfiles() { ++ do { ++ let profileFiles = try getAllProfileFiles() ++ ++ var newProfiles = [ProfileReference]() ++ for fileURL in profileFiles { ++ let data = try Data(contentsOf: fileURL) ++ let profile = try decodeProfile(from: data) ++ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) ++ newProfiles.append(profileRef) ++ } ++ ++ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } ++ ++ self.profiles = newProfiles ++ } catch { ++ print("An error occurred while loading profiles: \(error)") ++ } ++ } ++ ++ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { ++ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && ++ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && ++ therapySettings.basalRateSchedule == profile.basalRateSchedule && ++ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { ++ self.setCurrentProfile(name: profile.name, syncChange: true) ++ completion(.success) ++ } ++ else ++ { ++ do { ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ switch result { ++ case .success(let syncedSchedule): ++ DispatchQueue.main.async { ++ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) ++ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) ++ self?.saveBasalRates(basalRates: syncedSchedule) ++ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ print("New profile loaded") ++ self?.setCurrentProfile(name: profile.name, syncChange: false) ++ completion(.success) ++ } ++ case .failure(let error): ++ print("An error occurred while syncing basal rates: \(error)") ++ completion(.failure(error)) ++ } ++ }) ++ } ++ } ++ } ++ ++ func getProfileReference(withName name: String) -> ProfileReference? { ++ let sanitizedInputName = sanitizeProfileName(name) ++ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) ++ } ++ ++ public func removeProfile(profile: Profile) { ++ guard let profileReference = getProfileReference(withName: profile.name) else { ++ print("No ProfileReference found for given Profile") ++ return ++ } ++ ++ removeProfile(profileReference: profileReference) ++ if getCurrentProfileName() == profile.name { ++ clearCurrentProfile() ++ } else { ++ triggerProfileSync() ++ } ++ } ++ ++ public func removeProfile(profileReference: ProfileReference) { ++ do { ++ let fileManager = FileManager.default ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try fileManager.removeItem(at: fileURL) ++ loadProfiles() ++ } catch { ++ print("An error occurred while removing profile: \(error)") ++ } ++ } ++ ++ func doesProfileExist(withName name: String) -> Bool { ++ return profiles.contains(where: { $0.name == name }) ++ } ++ ++ public func getProfile(from profileReference: ProfileReference) throws -> Profile { ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ let data = try Data(contentsOf: fileURL) ++ let getProfile = try decodeProfile(from: data) ++ return getProfile ++ } ++ ++ /// The saved profile named `name`, or nil when there is none. ++ public func savedProfile(named name: String) throws -> Profile? { ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ return nil ++ } ++ return try getProfile(from: profileReference) ++ } ++ ++ public func validateProfile(_ profile: Profile) -> Result { ++ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { ++ return .failure(.fileLoadError) ++ } ++ ++ // Checking Correction Range Schedule ++ for item in profile.correctionRange.items { ++ let minValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) ++ let maxValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) ++ ++ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { ++ return .failure(.correctionRangeError) ++ } ++ } ++ ++ // Checking Insulin Sensitivity Schedule ++ for item in profile.insulinSensitivitySchedule.items { ++ let value = HKQuantity(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: .internationalUnit()), doubleValue: item.value) ++ ++ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { ++ return .failure(.insulinSensitivityError) ++ } ++ } ++ ++ // Checking Carb Ratio Schedule ++ for item in profile.carbRatioSchedule.items { ++ let value = HKQuantity(unit: .gramsPerUnit, doubleValue: item.value) ++ ++ if !Guardrail.carbRatio.absoluteBounds.contains(value) { ++ return .failure(.carbRatioError) ++ } ++ } ++ ++ // Checking Basal Rate Schedule ++ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { ++ for item in profile.basalRateSchedule.items { ++ let value = item.value ++ ++ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { ++ return .failure(.basalRateError) ++ } ++ } ++ } else { ++ return .failure(.maxBasalRateNotSet) ++ } ++ ++ return .success(()) ++ } ++ ++ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value.minValue - item2.value.minValue) > 0.01 || ++ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value - item2.value) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ public func isCurrentProfileOutOfSync() -> Bool { ++ guard let currentProfileName = self.currentProfileName, ++ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), ++ let profile = try? getProfile(from: currentProfileReference), ++ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: HKUnit.milligramsPerDeciliter), ++ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: HKUnit.milligramsPerDeciliter) else { ++ return false // No current profile set or profile not found or schedules not available ++ } ++ ++ var isOutOfSync = false ++ ++ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: HKUnit.milligramsPerDeciliter)!) { ++ print("Glucose target range schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ print("Carb ratio schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ print("Basal rate schedule differs") ++ isOutOfSync = true ++ } ++ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: HKUnit.milligramsPerDeciliter)!) { ++ print("Insulin sensitivity schedule differs") ++ isOutOfSync = true ++ } ++ ++ return isOutOfSync ++ } ++} ++ ++extension Profile { ++ /// Passes the schedules this profile activates to `check`. ++ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { ++ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +new file mode 100644 +index 00000000..a48fa667 +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +@@ -0,0 +1,86 @@ ++// ++// ProfileViewModel.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import Combine ++import LoopKit ++import HealthKit ++import SwiftUI ++ ++public class ProfileViewModel: ObservableObject { ++ ++ @Published public var therapySettings: TherapySettings ++ @Published public var profiles = [ProfileReference]() ++ @Published public var currentProfileName: String? { ++ didSet { ++ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") ++ } ++ } ++ ++ internal weak var delegate: TherapySettingsViewModelDelegate? ++ ++ public init(therapySettings: TherapySettings, ++ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, ++ sensitivityOverridesEnabled: Bool = false, ++ adultChildInsulinModelSelectionEnabled: Bool = false, ++ prescription: Prescription? = nil, ++ delegate: TherapySettingsViewModelDelegate? = nil) { ++ self.therapySettings = therapySettings ++ self.delegate = delegate ++ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") ++ self.loadProfiles() ++ } ++ ++ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return delegate?.pumpSupportedIncrements() ++ } ++} ++ ++public struct Profile: Codable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ var sortOrder: Int? ++} ++ ++public struct ProfileReference: Codable, Equatable { ++ var name: String ++ var fileName: String ++ var sortOrder: Int? ++} ++ ++ ++// MARK: Saving ++extension ProfileViewModel { ++ ++ public func saveCorrectionRange(range: GlucoseRangeSchedule) { ++ therapySettings.glucoseTargetRangeSchedule = range ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveBasalRates(basalRates: BasalRateSchedule) { ++ therapySettings.basalRateSchedule = basalRates ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { ++ therapySettings.carbRatioSchedule = carbRatioSchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { ++ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func triggerProfileSync() { ++ delegate?.updateCurrentProfileName() ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +index 3650ef60..c5c1dc01 100644 +--- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift ++++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { + func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) + func saveCompletion(therapySettings: TherapySettings) + func pumpSupportedIncrements() -> PumpSupportedIncrements? ++ func updateCurrentProfileName() + } + + public class TherapySettingsViewModel: ObservableObject { +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +new file mode 100644 +index 00000000..446910e4 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +@@ -0,0 +1,79 @@ ++// ++// NewProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct NewProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var newProfileName: String ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("New Profile") ++ .font(.headline) ++ }, ++ trailing: { addButton } ++ ) ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var addButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Add") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +new file mode 100644 +index 00000000..6de3598f +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +@@ -0,0 +1,326 @@ ++// ++// ProfilePreviewView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-23. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++import HealthKit ++import LoopKit ++ ++enum ActiveAlert { ++ case load ++ case delete ++ case error ++} ++ ++struct ProfilePreviewView: View { ++ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference ++ @ObservedObject var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @Environment(\.presentationMode) var presentationMode ++ var profile: Profile ++ ++ @State private var showingAlert = false ++ @State private var activeAlert: ActiveAlert = .load ++ @State private var errorText: String? ++ @State private var newProfileName: String = "" ++ @State private var isRenamingProfile = false ++ @State private var shouldDismissSelf: Bool = false ++ ++ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } ++ ++ var body: some View { ++ ZStack { ++ ScrollView { ++ VStack(alignment: .leading) { ++ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ .font(.body) ++ .foregroundColor(.primary) ++ .padding([.leading, .trailing]) ++ .fixedSize(horizontal: false, vertical: true) ++ .textCase(nil) ++ ++ profileCardStack ++ ++ Button(action: { ++ activeAlert = .load ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Load") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.blue) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ isRenamingProfile = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Rename") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.orange) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ activeAlert = .delete ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Delete") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.red) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ } ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .navigationTitle(Text(profile.name)) ++ .alert(isPresented: $showingAlert) { ++ switch activeAlert { ++ case .load: ++ return Alert( ++ title: Text("Load Profile"), ++ message: Text("Do you want to load the profile \(profile.name)?"), ++ primaryButton: .default(Text("Yes"), action: { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .delete: ++ return Alert( ++ title: Text("Delete Profile"), ++ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), ++ primaryButton: .destructive(Text("Delete"), action: { ++ viewModel.removeProfile(profile: profile) ++ self.presentationMode.wrappedValue.dismiss() ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .error: ++ return Alert( ++ title: Text("Error loading profile"), ++ message: Text(errorText ?? "Unknown error"), ++ dismissButton: .default(Text("OK")) ++ ) ++ } ++ } ++ if isRenamingProfile { ++ // Some hacks here to mimic Alert behaviour. ++ // This function should be migrateed to Alert when iOS 16 is required. ++ Color.black.opacity(0.5) ++ .edgesIgnoringSafeArea(.all) ++ .onTapGesture { ++ isRenamingProfile = false ++ } ++ ++ RenameProfileEditor( ++ isPresented: $isRenamingProfile, ++ currentProfileName: profile.name, ++ viewModel: viewModel, ++ shouldDismissParent: $shouldDismissSelf ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ .offset(y: -22) ++ } ++ } ++ .onChange(of: shouldDismissSelf) { shouldDismiss in ++ if shouldDismiss { ++ self.presentationMode.wrappedValue.dismiss() ++ } ++ } ++ } ++ ++ private var profileCardStack: CardStack { ++ var cards: [Card] = [] ++ ++ cards.append(correctionRangeSection) ++ cards.append(carbRatioSection) ++ cards.append(basalRatesSection) ++ cards.append(insulinSensitivitiesSection) ++ ++ return CardStack(cards: cards) ++ } ++ ++ private var correctionRangeSection: Card { ++ card(for: .glucoseTargetRange) { ++ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleRangeItem(time: items[index].startTime, ++ range: items[index].value, ++ unit: glucoseUnit, ++ guardrail: .correctionRange) ++ } ++ } ++ } ++ } ++ ++ private var carbRatioSection: Card { ++ card(for: .carbRatio) { ++ SectionDivider() ++ let items = profile.carbRatioSchedule.items ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .gramsPerUnit, ++ guardrail: .carbRatio) ++ } ++ } ++ } ++ ++ private var basalRatesSection: Card { ++ card(for: .basalRate) { ++ let items = profile.basalRateSchedule.items ++ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates ++ { ++ SectionDivider() ++ let total = profile.basalRateSchedule.total() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .internationalUnitsPerHour, ++ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) ++ } ++ HStack { ++ Text("Total") ++ .bold() ++ .foregroundColor(.primary) ++ Spacer() ++ Text(String(format: "%.2f ",total)) ++ .foregroundColor(.primary) + ++ Text("U/day") ++ .foregroundColor(.secondary) ++ } ++ } ++ } ++ } ++ ++ private var insulinSensitivitiesSection: Card { ++ card(for: .insulinSensitivity) { ++ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items ++ { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: sensitivityUnit, ++ guardrail: .insulinSensitivity) ++ } ++ } ++ } ++ } ++} ++ ++ ++struct SectionWithContent: View where Content: View { ++ let title: String ++ let content: Content ++ ++ init(title: String, ++ content: () -> Content) ++ { ++ self.title = title ++ self.content = content() ++ } ++ ++ public var body: some View { ++ Section { ++ Text(title) ++ .bold() ++ .frame(maxWidth: .infinity, alignment: .leading) ++ content ++ } ++ } ++} ++ ++// MARK: Utilities ++extension ProfilePreviewView { ++ ++ private var glucoseUnit: HKUnit { ++ displayGlucosePreference.unit ++ } ++ ++ private var sensitivityUnit: HKUnit { ++ glucoseUnit.unitDivided(by: .internationalUnit()) ++ } ++ ++ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { ++ Card { ++ SectionWithContent(title: therapySetting.title, ++ content: content) ++ } ++ } ++} ++ ++fileprivate struct SectionDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -16) ++ } ++} ++ ++fileprivate struct SettingsDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -8) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +new file mode 100644 +index 00000000..1c6244a4 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +@@ -0,0 +1,173 @@ ++// ++// ProfileView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import AVFoundation ++import HealthKit ++import LoopKit ++import SwiftUI ++ ++public struct ProfileView: View { ++ @ObservedObject public var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @State private var newProfileName: String = "" ++ @State private var isAddingNewProfile = false ++ @State private var selectedProfileIndex: Int? = nil ++ @State private var refreshID = UUID() ++ ++ enum ActiveAlert: String, Identifiable { ++ case update, info ++ ++ var id: String { ++ return self.rawValue ++ } ++ } ++ @State private var activeAlert: ActiveAlert? ++ ++ public init(viewModel: ProfileViewModel) { ++ self.viewModel = viewModel ++ } ++ ++ private var dismissButton: some View { ++ Button(action: dismissAction) { ++ Text(LocalizedString("Done", comment: "Text for dismiss button")) ++ .bold() ++ } ++ } ++ ++ public var body: some View { ++ ZStack { ++ NavigationView { ++ ConfigurationPageScrollView( ++ content: { ++ VStack(alignment: .leading) { ++ ++ if !viewModel.profiles.isEmpty { ++ List { ++ ForEach(viewModel.profiles.indices, id: \.self) { index in ++ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { ++ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { ++ HStack { ++ if viewModel.currentProfileName == viewModel.profiles[index].name { ++ Image(systemName: "checkmark") ++ .foregroundColor(Color.blue) ++ } else { ++ // Add indentation to align with the checkmark for the active profile ++ Image(systemName: "checkmark") ++ .opacity(0) // Invisible ++ } ++ ++ Text(viewModel.profiles[index].name) ++ } ++ } ++ } ++ } ++ .onMove(perform: moveProfile) ++ }.id(refreshID) ++ ++ } else { ++ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") ++ .font(.subheadline) ++ .foregroundColor(.secondary) ++ .padding([.leading, .trailing]) ++ .multilineTextAlignment(.leading) ++ .lineLimit(nil) ++ .textCase(nil) ++ } ++ } ++ }, ++ actionArea: { EmptyView() } // no action area in this case ++ ) ++ .navigationBarItems( ++ leading: HStack { ++ Button(action: { withAnimation { isAddingNewProfile = true } }) { ++ Image(systemName: "plus") ++ } ++ ++ Button(action: { activeAlert = .info }) { ++ Image(systemName: "info.circle") ++ .resizable() ++ .frame(width: 24, height: 24) ++ } ++ }, ++ trailing: dismissButton ++ ) ++ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .onAppear { ++ if viewModel.isCurrentProfileOutOfSync() { ++ activeAlert = .update ++ } ++ } ++ ++ .alert(item: $activeAlert) { alertType in ++ switch alertType { ++ case .update: ++ return Alert( ++ title: Text("Profile Outdated"), ++ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), ++ primaryButton: .default(Text("Update")) { ++ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") ++ }, ++ secondaryButton: .cancel() ++ ) ++ case .info: ++ return Alert(title: Text("Information"), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ dismissButton: .default(Text("Got it!"))) ++ } ++ } ++ ++ if isAddingNewProfile { ++ DarkenedOverlay() ++ ++ NewProfileEditor( ++ isPresented: $isAddingNewProfile, ++ newProfileName: newProfileName, ++ viewModel: viewModel ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ } ++ } ++ } ++ ++ func moveProfile(from source: IndexSet, to destination: Int) { ++ viewModel.profiles.move(fromOffsets: source, toOffset: destination) ++ refreshID = UUID() ++ viewModel.updateProfilesOrder() ++ } ++} ++ ++struct ProfileView_Previews: PreviewProvider { ++ static let preview_glucoseScheduleItems = [ ++ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), ++ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), ++ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) ++ ] ++ ++ static let preview_therapySettings = TherapySettings( ++ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), ++ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), ++ workout: DoubleRange(99...111), ++ unit: .milligramsPerDeciliter), ++ maximumBolus: 4, ++ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), ++ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: HKUnit.internationalUnit()), dailyItems: []), ++ carbRatioSchedule: nil, ++ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) ++ ++ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] ++ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] ++ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] ++ ++ static var previews: some View { ++ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +new file mode 100644 +index 00000000..8fd26665 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +@@ -0,0 +1,92 @@ ++// ++// RenameProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-09-07. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct RenameProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var currentProfileName: String ++ @State var newProfileName: String = "" ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ @Binding var shouldDismissParent: Bool ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("Rename Profile") ++ .font(.subheadline) ++ ++ }, ++ trailing: { renameButton } ++ ) ++ .onAppear { ++ self.newProfileName = currentProfileName ++ } ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var renameButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ guard newProfileName != currentProfileName else { ++ self.isPresented = false ++ return ++ } ++ ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Rename") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +Submodule LoopOnboarding contains modified content +diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +index cc61f2d..6462e2c 100644 +--- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift ++++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +@@ -379,6 +379,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { + maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount + ) + } ++ ++ func updateCurrentProfileName() { ++ } + } + + +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index 69f7a26..14a7691 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -24,6 +24,10 @@ + A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; +@@ -158,6 +162,10 @@ + A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; + A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; +@@ -268,6 +276,7 @@ + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, + A934B6172358F49300949C8B /* StoredSettings.swift */, ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, + A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, + A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, +@@ -324,6 +333,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -364,6 +374,7 @@ + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, + ); + path = Notifications; + sourceTree = ""; +@@ -412,6 +423,7 @@ + A9AA6E5729EB06AA008FFA78 /* Actions */ = { + isa = PBXGroup; + children = ( ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, +@@ -744,6 +756,9 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, + A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, + C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, + ); +@@ -757,6 +772,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, + A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +new file mode 100644 +index 0000000..de73ca9 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,104 @@ ++// ++// SavedProfile.swift ++// NightscoutServiceKit ++// ++ ++import CryptoKit ++import Foundation ++import HealthKit ++import LoopKit ++import NightscoutKit ++ ++/// A profile saved with the Profiles settings screen, read from the file it was saved to. ++struct SavedProfile: Decodable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ ++ private static var directory: URL { ++ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") ++ } ++ ++ static func loadAll() -> [SavedProfile] { ++ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] ++ return files ++ .filter { $0.pathExtension == "json" } ++ .compactMap { try? Data(contentsOf: $0) } ++ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } ++ } ++ ++ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. ++ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { ++ var store: [String: ProfileSet.Profile] = [:] ++ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. ++ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { ++ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) ++ } ++ return store ++ } ++ ++ /// True when this profile uploads with this fingerprint, in either glucose unit. ++ func matches(fingerprint: String) -> Bool { ++ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { ++ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint ++ } ++ } ++ ++ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { ++ guard let correctionRange = correctionRange.schedule(for: unit), ++ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) ++ else { ++ return nil ++ } ++ return ProfileSet.Profile( ++ timezone: basalRateSchedule.timeZone, ++ dia: .hours(6), ++ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, ++ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, ++ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) ++ } ++} ++ ++extension ProfileSet.Profile { ++ /// Followers compute the same value from the uploaded profile and send it with a switch command, ++ /// so Loop only activates the values the follower previewed. ++ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` ++ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. ++ var remoteFingerprint: String { ++ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { ++ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") ++ } ++ let canonical = [ ++ "basal=" + items(basal), ++ "carbratio=" + items(carbratio), ++ "sens=" + items(sensitivity), ++ "target_low=" + items(targetLow), ++ "target_high=" + items(targetHigh), ++ ].joined(separator: ";") ++ let digest = SHA256.hash(data: Data(canonical.utf8)) ++ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index 4e1d49e..b66fb97 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -159,7 +159,8 @@ extension StoredDosingDecision { + automaticDoseRecommendation: loopStatusAutomaticDoseRecommendation, + recommendedBolus: loopStatusRecommendedBolus, + enacted: automaticDoseDecision?.loopStatusEnacted, +- failureReason: automaticDoseDecision?.loopStatusFailureReason), ++ failureReason: automaticDoseDecision?.loopStatusFailureReason, ++ testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]]), + overrideStatus: overrideStatus) + } + +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +index 2adadb9..dd7ac28 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +@@ -94,12 +94,21 @@ extension StoredSettings { + return nil + } + ++ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) ++ store["Default"] = profile ++ var defaultProfile = "Default" ++ ++ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { ++ defaultProfile = name ++ store[name] = profile ++ } ++ + return ProfileSet( + startDate: date, + units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), + enteredBy: "Loop", +- defaultProfile: "Default", +- store: ["Default": profile], ++ defaultProfile: defaultProfile, ++ store: store, + settings: loopSettings, + syncIdentifier: syncIdentifier.uuidString, + isAPNSProduction: Bundle.main.isAPNSProductionEnvironment) +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index a92b509..29313b6 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -9,6 +9,7 @@ + import Foundation + + public enum Action: Codable { ++ case profileSwitch(ProfileSwitchAction) + case temporaryScheduleOverride(OverrideAction) + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) +@@ -28,6 +29,8 @@ public enum Action: Codable { + return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") + case .temporaryScheduleOverride: + return LocalizedString("Override", comment: "The remote action name for Override") ++ case .profileSwitch: ++ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") + } + } + +@@ -43,6 +46,8 @@ public enum Action: Codable { + return "" + case .temporaryScheduleOverride(let overrideAction): + return overrideAction.name ++ case .profileSwitch(let profileSwitchAction): ++ return profileSwitchAction.name + } + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +new file mode 100644 +index 0000000..ce03ded +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +@@ -0,0 +1,17 @@ ++// ++// ProfileSwitchAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct ProfileSwitchAction: Codable { ++ ++ public let name: String ++ public let fingerprint: String ++ ++ public init(name: String, fingerprint: String) { ++ self.name = name ++ self.fingerprint = fingerprint ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 95274e4..4457f80 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -45,6 +45,7 @@ class RemoteNotificationResponseManager { + case carbs = "carbs" + case override = "override" + case cancelOverride = "cancel_override" ++ case profileSwitch = "profile_switch" + } + + func sendResponseNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +new file mode 100644 +index 0000000..ebcd6ef +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -0,0 +1,50 @@ ++// ++// ProfileSwitchRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Switches to a saved profile. `expiration` is required: decoding fails without it. ++public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ public let profileName: String ++ public let fingerprint: String ++ public let remoteAddress: String ++ public let expirationDate: Date ++ public let sentAt: Date ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ public var expiration: Date? { ++ return expirationDate ++ } ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expirationDate = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification[commandKey] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 24e63d7..a0f58a0 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -104,6 +104,8 @@ extension Dictionary { + return try OverrideRemoteNotification(dictionary: self) + } else if OverrideCancelRemoteNotification.includedInNotification(self) { + return try OverrideCancelRemoteNotification(dictionary: self) ++ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { ++ return try ProfileSwitchRemoteNotification(dictionary: self) + } else { + throw RemoteNotificationError.unhandledNotification(self) + } +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +index 84ff118..0912feb 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift +@@ -96,6 +96,27 @@ class RemoteCommandSourceV1: RemoteCommandSource { + status: .success(date: Date(), syncIdentifier: carbEntry.syncIdentifier ?? "", completionMessage: nil), + notificationJSON: notification + ) ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ successMessage = "Profile '\(profileSwitchCommand.name)' activated" ++ await handleEnactmentCompletion( ++ remoteNotification: remoteNotification, ++ status: .success(date: Date(), syncIdentifier: "", completionMessage: nil), ++ notificationJSON: notification ++ ) + case .temporaryScheduleOverride(let overrideCommand): + commandType = .override + let override = try await serviceDelegate.enactRemoteOverride( +@@ -223,6 +244,10 @@ public class StoredRemoteNotification: NSObject, Codable { + self.init(notificationType: .carbs(carbNotification), notificationJSONData: notificationJSONData) + } + ++ convenience init(profileSwitchNotification: ProfileSwitchRemoteNotification, notificationJSONData: Data) { ++ self.init(notificationType: .profileSwitch(profileSwitchNotification), notificationJSONData: notificationJSONData) ++ } ++ + convenience init(overrideNotification: OverrideRemoteNotification, notificationJSONData: Data) { + self.init(notificationType: .override(overrideNotification), notificationJSONData: notificationJSONData) + } +@@ -306,6 +331,8 @@ public class StoredRemoteNotification: NSObject, Codable { + return bolusNotification + case let .carbs(carbNotification): + return carbNotification ++ case let .profileSwitch(profileSwitchNotification): ++ return profileSwitchNotification + case let .override(overrideNotification): + return overrideNotification + case let .overrideCancel(overrideCancelNotification): +@@ -334,6 +361,7 @@ public class StoredRemoteNotification: NSObject, Codable { + case carbs(CarbRemoteNotification) + case override(OverrideRemoteNotification) + case overrideCancel(OverrideCancelRemoteNotification) ++ case profileSwitch(ProfileSwitchRemoteNotification) + } + } + +@@ -384,6 +412,9 @@ actor RecentNotifications { + } else if let carbNotification = remoteNotification as? CarbRemoteNotification { + let storedNotification = StoredRemoteNotification(carbNotification: carbNotification, notificationJSONData: data) + try storeNotification(storedNotification) ++ } else if let profileSwitchNotification = remoteNotification as? ProfileSwitchRemoteNotification { ++ let storedNotification = StoredRemoteNotification(profileSwitchNotification: profileSwitchNotification, notificationJSONData: data) ++ try storeNotification(storedNotification) + } else if let overrideNotification = remoteNotification as? OverrideRemoteNotification { + let storedNotification = StoredRemoteNotification(overrideNotification: overrideNotification, notificationJSONData: data) + try storeNotification(storedNotification) +diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..2fcec94 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +@@ -0,0 +1,71 @@ ++// ++// ProfileSwitchRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "sent-at": "2023-02-25T20:46:35.778Z", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ] ++ ++ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) ++ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ ++ XCTAssertEqual(profileSwitch.profileName, "Sport") ++ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertTrue(profileSwitch.otpValidationRequired()) ++ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { ++ return XCTFail("Expected a profile switch action") ++ } ++ XCTAssertEqual(action.name, "Sport") ++ } ++ ++ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { ++ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ let profile = ProfileSet.Profile( ++ timezone: TimeZone(secondsFromGMT: 3600)!, ++ dia: .hours(6), ++ sensitivity: items([(0, 45)]), ++ carbratio: items([(0, 10)]), ++ basal: items([(0, 0.85), (21600, 1.1)]), ++ targetLow: items([(0, 100)]), ++ targetHigh: items([(0, 110)]), ++ units: "mg/dL") ++ ++ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") ++ } ++} ++ ++private extension ISO8601DateFormatter { ++ static let fractional: ISO8601DateFormatter = { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ }() ++} +diff --git a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +index 2a3fff3..4ce6ab7 100644 +--- a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift ++++ b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift +@@ -56,6 +56,8 @@ extension StoredRemoteNotification { + return "Carbs" + case .override: + return "Override" ++ case .profileSwitch: ++ return "Profile Switch" + case .overrideCancel: + return "Cancel Override" + } +@@ -76,6 +78,8 @@ extension StoredRemoteNotification { + return String(format: "%.0f g", notification.amount) + case .override(let notification): + return notification.name ++ case .profileSwitch(let notification): ++ return notification.profileName + case .overrideCancel: + return "" + } +diff --git a/profiles.loopcustomization b/profiles.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/profiles/nextdev_profiles.patch b/profiles/nextdev_profiles.patch index ce9bf2d..407ac8d 100644 --- a/profiles/nextdev_profiles.patch +++ b/profiles/nextdev_profiles.patch @@ -1,9 +1,9 @@ Submodule Loop contains modified content diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index 3c9d61cb..6b70853e 100644 +index 3c9d61cb..5591c9a5 100644 --- a/Loop/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -1407,6 +1407,12 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { +@@ -1407,6 +1407,13 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { return try await pumpManager?.syncDeliveryLimits(limits: deliveryLimits) ?? deliveryLimits } @@ -11,11 +11,24 @@ index 3c9d61cb..6b70853e 100644 + NotificationCenter.default.post(name: .LoopDataUpdated, + object: nil, + userInfo: [LoopDataManager.LoopUpdateContextKey: LoopUpdateContext.preferences.rawValue]) ++ settingsManager.storeSettings() + } + func saveCompletion(therapySettings: TherapySettings) { settingsManager.mutateLoopSettings { settings in settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule +diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift +index 6b263045..fe3c3e7f 100644 +--- a/Loop/Loop/Managers/LoopAppManager.swift ++++ b/Loop/Loop/Managers/LoopAppManager.swift +@@ -401,6 +401,7 @@ class LoopAppManager: NSObject { + + dosingDecisionStore.delegate = deviceDataManager + remoteDataServicesManager.delegate = deviceDataManager ++ servicesManager.therapySettingsViewModelDelegate = deviceDataManager + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceDataManager.deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift index 30ab8fb2..28f07307 100644 --- a/Loop/Loop/Managers/LoopDataManager.swift @@ -29,6 +42,120 @@ index 30ab8fb2..28f07307 100644 var scheduleOverride: TemporaryScheduleOverride? { temporaryPresetsManager.scheduleOverride } +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 42eff19f..c4d0f32e 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -298,6 +298,38 @@ extension NotificationManager { + UNUserNotificationCenter.current().add(request) + } + ++ static func sendRemoteProfileNotification(name: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfile", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteProfileFailureNotification(name: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ private static func remoteProfileNotificationBody(name: String) -> String { ++ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ } ++ + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { + return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + } +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index b557f184..15602f08 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -30,6 +30,7 @@ class ServicesManager { + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? + weak var supportManager: SupportManager? ++ weak var therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? + + private var services = [Service]() + +@@ -423,3 +424,58 @@ extension Service { + ] + } + } ++ ++// MARK: - RemoteProfileActionDelegate ++ ++extension ServicesManager: RemoteProfileActionDelegate { ++ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { ++ do { ++ guard let therapySettingsViewModelDelegate else { ++ throw RemoteProfileActionError.unavailable ++ } ++ let viewModel = ProfileViewModel(therapySettings: settingsManager.therapySettings, delegate: therapySettingsViewModelDelegate) ++ guard therapySettingsViewModelDelegate.pumpSupportedIncrements() != nil else { ++ throw RemoteProfileActionError.noPump ++ } ++ guard let profile = try viewModel.savedProfile(named: name) else { ++ throw RemoteProfileActionError.profileNotFound(name) ++ } ++ try profile.verify(with: verify) ++ if case .failure(let error) = viewModel.validateProfile(profile) { ++ throw error ++ } ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ viewModel.triggerProfileSync() ++ NotificationManager.sendRemoteProfileNotification(name: name) ++ } catch { ++ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteProfileActionError: LocalizedError { ++ case unavailable ++ case noPump ++ case profileNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .unavailable: ++ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: profile view model delegate missing.") ++ case .noPump: ++ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") ++ case .profileNotFound(let name): ++ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) ++ } ++ } ++ } ++} diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift index 1faefbc5..737acf28 100644 --- a/Loop/Loop/Views/SettingsView.swift @@ -127,12 +254,29 @@ index cc88ad17..c543b73a 100644 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, 892A5DA02231E130008961AB /* CompletionNotifying.swift in Sources */, +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 8f824906..a7c9c653 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -8,6 +8,12 @@ + + import Foundation + ++/// Switches to a saved therapy profile on a remote command. ++public protocol RemoteProfileActionDelegate: AnyObject { ++ /// `verify` receives the schedules about to be activated and throws to reject them. ++ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws ++} ++ + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws + func cancelRemoteOverride() async throws diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..cd8d48b8 +index 00000000..0a287cac --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,409 @@ +@@ -0,0 +1,427 @@ +// +// ProfileViewModel+FileManagement.swift +// LoopKitUI @@ -310,6 +454,7 @@ index 00000000..cd8d48b8 + try jsonData.write(to: fileURL) + + self.loadProfiles() ++ triggerProfileSync() + } catch { + print("An error occurred while renaming profile: \(error)") + } @@ -406,6 +551,8 @@ index 00000000..cd8d48b8 + removeProfile(profileReference: profileReference) + if getCurrentProfileName() == profile.name { + clearCurrentProfile() ++ } else { ++ triggerProfileSync() + } + } + @@ -431,6 +578,14 @@ index 00000000..cd8d48b8 + return getProfile + } + ++ /// The saved profile named `name`, or nil when there is none. ++ public func savedProfile(named name: String) throws -> Profile? { ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ return nil ++ } ++ return try getProfile(from: profileReference) ++ } ++ + public func validateProfile(_ profile: Profile) -> Result { + guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { + return .failure(.fileLoadError) @@ -542,6 +697,13 @@ index 00000000..cd8d48b8 + return isOutOfSync + } +} ++ ++extension Profile { ++ /// Passes the schedules this profile activates to `check`. ++ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { ++ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) ++ } ++} diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift new file mode 100644 index 00000000..a48fa667 @@ -1363,15 +1525,214 @@ index 1ddc82b..54421a1 100644 Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index 00096e2..cf6883e 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -23,6 +23,10 @@ + A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; +@@ -156,6 +160,10 @@ + A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; + A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; +@@ -265,6 +273,7 @@ + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, + A934B6172358F49300949C8B /* StoredSettings.swift */, ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, + A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, + A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, +@@ -321,6 +330,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -361,6 +371,7 @@ + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, + ); + path = Notifications; + sourceTree = ""; +@@ -409,6 +420,7 @@ + A9AA6E5729EB06AA008FFA78 /* Actions */ = { + isa = PBXGroup; + children = ( ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, +@@ -740,6 +752,9 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, + A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, + C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, + ); +@@ -753,6 +768,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, + A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +new file mode 100644 +index 0000000..588ded1 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,104 @@ ++// ++// SavedProfile.swift ++// NightscoutServiceKit ++// ++ ++import CryptoKit ++import Foundation ++import LoopAlgorithm ++import LoopKit ++import NightscoutKit ++ ++/// A profile saved with the Profiles settings screen, read from the file it was saved to. ++struct SavedProfile: Decodable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ ++ private static var directory: URL { ++ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") ++ } ++ ++ static func loadAll() -> [SavedProfile] { ++ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] ++ return files ++ .filter { $0.pathExtension == "json" } ++ .compactMap { try? Data(contentsOf: $0) } ++ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } ++ } ++ ++ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. ++ static func nightscoutStore(unit: LoopUnit) -> [String: ProfileSet.Profile] { ++ var store: [String: ProfileSet.Profile] = [:] ++ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. ++ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { ++ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) ++ } ++ return store ++ } ++ ++ /// True when this profile uploads with this fingerprint, in either glucose unit. ++ func matches(fingerprint: String) -> Bool { ++ [LoopUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { ++ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint ++ } ++ } ++ ++ func nightscoutProfile(unit: LoopUnit) -> ProfileSet.Profile? { ++ guard let correctionRange = correctionRange.schedule(for: unit), ++ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) ++ else { ++ return nil ++ } ++ return ProfileSet.Profile( ++ timezone: basalRateSchedule.timeZone, ++ dia: .hours(6), ++ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, ++ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, ++ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) ++ } ++} ++ ++extension ProfileSet.Profile { ++ /// Followers compute the same value from the uploaded profile and send it with a switch command, ++ /// so Loop only activates the values the follower previewed. ++ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` ++ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. ++ var remoteFingerprint: String { ++ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { ++ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") ++ } ++ let canonical = [ ++ "basal=" + items(basal), ++ "carbratio=" + items(carbratio), ++ "sens=" + items(sensitivity), ++ "target_low=" + items(targetLow), ++ "target_high=" + items(targetHigh), ++ ].joined(separator: ";") ++ let digest = SHA256.hash(data: Data(canonical.utf8)) ++ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index 2f12b47..2dec600 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -180,6 +180,7 @@ extension StoredDosingDecision { + enacted: automaticDoseDecision?.loopStatusEnacted, + failureReason: automaticDoseDecision?.loopStatusFailureReason, + failureCode: automaticDoseDecision?.errors.first?.id, ++ testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]], + algorithmExperiments: (automaticDoseDecision?.algorithmExperiments ?? algorithmExperiments)?.map { $0.jsonObject }), + overrideStatus: overrideStatus) + } diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -index 3142223..790330a 100644 +index 3142223..ae6fb9a 100644 --- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -@@ -93,12 +93,20 @@ extension StoredSettings { +@@ -93,12 +93,21 @@ extension StoredSettings { return nil } -+ var store = ["Default": profile] ++ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) ++ store["Default"] = profile + var defaultProfile = "Default" + + if let name = UserDefaults.standard.string(forKey: "currentProfileName") { @@ -1390,6 +1751,246 @@ index 3142223..790330a 100644 settings: loopSettings, syncIdentifier: syncIdentifier.uuidString, isAPNSProduction: Bundle.main.isAPNSProductionEnvironment, +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index cd6d2c6..73db786 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -459,6 +459,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + success = true + message = "Override '\(overrideCommand.name)' enacted successfully" + ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ success = true ++ message = "Profile '\(profileSwitchCommand.name)' activated" ++ + case .cancelTemporaryOverride: + commandType = .cancelOverride + try await self.serviceDelegate?.cancelRemoteOverride() +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index a92b509..29313b6 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -9,6 +9,7 @@ + import Foundation + + public enum Action: Codable { ++ case profileSwitch(ProfileSwitchAction) + case temporaryScheduleOverride(OverrideAction) + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) +@@ -28,6 +29,8 @@ public enum Action: Codable { + return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") + case .temporaryScheduleOverride: + return LocalizedString("Override", comment: "The remote action name for Override") ++ case .profileSwitch: ++ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") + } + } + +@@ -43,6 +46,8 @@ public enum Action: Codable { + return "" + case .temporaryScheduleOverride(let overrideAction): + return overrideAction.name ++ case .profileSwitch(let profileSwitchAction): ++ return profileSwitchAction.name + } + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +new file mode 100644 +index 0000000..ce03ded +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +@@ -0,0 +1,17 @@ ++// ++// ProfileSwitchAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct ProfileSwitchAction: Codable { ++ ++ public let name: String ++ public let fingerprint: String ++ ++ public init(name: String, fingerprint: String) { ++ self.name = name ++ self.fingerprint = fingerprint ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 95274e4..4457f80 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -45,6 +45,7 @@ class RemoteNotificationResponseManager { + case carbs = "carbs" + case override = "override" + case cancelOverride = "cancel_override" ++ case profileSwitch = "profile_switch" + } + + func sendResponseNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +new file mode 100644 +index 0000000..487c620 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -0,0 +1,50 @@ ++// ++// ProfileSwitchRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Switches to a saved profile. `expiration` is required: decoding fails without it. ++public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ public let profileName: String ++ public let fingerprint: String ++ public let remoteAddress: String ++ public let expirationDate: Date ++ public let sentAt: Date? ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ public var expiration: Date? { ++ return expirationDate ++ } ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expirationDate = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification[commandKey] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 0c5f8e3..3362bf7 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -108,6 +108,8 @@ extension Dictionary { + return try OverrideRemoteNotification(dictionary: self) + } else if OverrideCancelRemoteNotification.includedInNotification(self) { + return try OverrideCancelRemoteNotification(dictionary: self) ++ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { ++ return try ProfileSwitchRemoteNotification(dictionary: self) + } else { + throw RemoteNotificationError.unhandledNotification(self) + } +diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..2fcec94 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +@@ -0,0 +1,71 @@ ++// ++// ProfileSwitchRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "sent-at": "2023-02-25T20:46:35.778Z", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ] ++ ++ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) ++ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ ++ XCTAssertEqual(profileSwitch.profileName, "Sport") ++ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertTrue(profileSwitch.otpValidationRequired()) ++ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { ++ return XCTFail("Expected a profile switch action") ++ } ++ XCTAssertEqual(action.name, "Sport") ++ } ++ ++ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { ++ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ let profile = ProfileSet.Profile( ++ timezone: TimeZone(secondsFromGMT: 3600)!, ++ dia: .hours(6), ++ sensitivity: items([(0, 45)]), ++ carbratio: items([(0, 10)]), ++ basal: items([(0, 0.85), (21600, 1.1)]), ++ targetLow: items([(0, 100)]), ++ targetHigh: items([(0, 110)]), ++ units: "mg/dL") ++ ++ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") ++ } ++} ++ ++private extension ISO8601DateFormatter { ++ static let fractional: ISO8601DateFormatter = { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ }() ++} diff --git a/profiles.loopcustomization b/profiles.loopcustomization new file mode 100644 index 0000000..e69de29 diff --git a/profiles/nextdev_profiles_remote_carb_edit.patch b/profiles/nextdev_profiles_remote_carb_edit.patch new file mode 100644 index 0000000..40a9678 --- /dev/null +++ b/profiles/nextdev_profiles_remote_carb_edit.patch @@ -0,0 +1,1997 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index 3c9d61cb..5591c9a5 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -1407,6 +1407,13 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { + return try await pumpManager?.syncDeliveryLimits(limits: deliveryLimits) ?? deliveryLimits + } + ++ func updateCurrentProfileName() { ++ NotificationCenter.default.post(name: .LoopDataUpdated, ++ object: nil, ++ userInfo: [LoopDataManager.LoopUpdateContextKey: LoopUpdateContext.preferences.rawValue]) ++ settingsManager.storeSettings() ++ } ++ + func saveCompletion(therapySettings: TherapySettings) { + settingsManager.mutateLoopSettings { settings in + settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule +diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift +index 6b263045..fe3c3e7f 100644 +--- a/Loop/Loop/Managers/LoopAppManager.swift ++++ b/Loop/Loop/Managers/LoopAppManager.swift +@@ -401,6 +401,7 @@ class LoopAppManager: NSObject { + + dosingDecisionStore.delegate = deviceDataManager + remoteDataServicesManager.delegate = deviceDataManager ++ servicesManager.therapySettingsViewModelDelegate = deviceDataManager + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceDataManager.deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index 7ecb16d1..61cdb5c0 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -1723,7 +1723,7 @@ extension LoopDataManager: ManualDoseViewModelDelegate { + var settings: StoredSettings { + settingsProvider.settings + } +- ++ + var scheduleOverride: TemporaryScheduleOverride? { + temporaryPresetsManager.scheduleOverride + } +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 18975ce1..1cf442dd 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -326,6 +326,38 @@ extension NotificationManager { + UNUserNotificationCenter.current().add(request) + } + ++ static func sendRemoteProfileNotification(name: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfile", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteProfileFailureNotification(name: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ private static func remoteProfileNotificationBody(name: String) -> String { ++ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ } ++ + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { + return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + } +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 56cb3642..ffafea70 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -30,6 +30,7 @@ class ServicesManager { + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? + weak var supportManager: SupportManager? ++ weak var therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? + + private var services = [Service]() + +@@ -467,3 +468,58 @@ extension Service { + ] + } + } ++ ++// MARK: - RemoteProfileActionDelegate ++ ++extension ServicesManager: RemoteProfileActionDelegate { ++ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { ++ do { ++ guard let therapySettingsViewModelDelegate else { ++ throw RemoteProfileActionError.unavailable ++ } ++ let viewModel = ProfileViewModel(therapySettings: settingsManager.therapySettings, delegate: therapySettingsViewModelDelegate) ++ guard therapySettingsViewModelDelegate.pumpSupportedIncrements() != nil else { ++ throw RemoteProfileActionError.noPump ++ } ++ guard let profile = try viewModel.savedProfile(named: name) else { ++ throw RemoteProfileActionError.profileNotFound(name) ++ } ++ try profile.verify(with: verify) ++ if case .failure(let error) = viewModel.validateProfile(profile) { ++ throw error ++ } ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ viewModel.triggerProfileSync() ++ NotificationManager.sendRemoteProfileNotification(name: name) ++ } catch { ++ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteProfileActionError: LocalizedError { ++ case unavailable ++ case noPump ++ case profileNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .unavailable: ++ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: profile view model delegate missing.") ++ case .noPump: ++ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") ++ case .profileNotFound(let name): ++ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) ++ } ++ } ++ } ++} +diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift +index 1faefbc5..737acf28 100644 +--- a/Loop/Loop/Views/SettingsView.swift ++++ b/Loop/Loop/Views/SettingsView.swift +@@ -55,6 +55,7 @@ struct SettingsView: View { + + case favoriteFoods + case presets ++ case profiles + } + } + +@@ -155,6 +156,9 @@ struct SettingsView: View { + } + case .favoriteFoods: + FavoriteFoodsView(insightsDelegate: viewModel.favoriteFoodInsightsDelegate) ++ case .profiles: ++ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), ++ delegate: self.viewModel.therapySettingsViewModelDelegate)) + } + } + .environmentObject(displayGlucosePreference) +@@ -416,6 +420,11 @@ extension SettingsView { + .accessibilityIdentifier("button_TherapySettings") + } + ++ LargeButton(action: { sheet = .profiles }, ++ includeArrow: true, ++ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), ++ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), ++ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) + ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in + item.view + } +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj +index cc88ad17..c543b73a 100644 +--- a/LoopKit/LoopKit.xcodeproj/project.pbxproj ++++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj +@@ -7,6 +7,12 @@ + objects = { + + /* Begin PBXBuildFile section */ ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; + 9D3D2DA3B9824EF48981F0E4 /* StoredInsulinModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CAB9E826A3254800A57273 /* StoredInsulinModel.swift */; }; + 485ACF65C30744BFBC979CEF /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A985464E251449FE0099C1A6 /* NotificationSettings.swift */; }; + 2383C3D7967B4602B8EB4A5B /* SettingsObject+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = A912BE2A245B9E8600CBE199 /* SettingsObject+CoreDataProperties.swift */; }; +@@ -1065,6 +1071,12 @@ + /* End PBXCopyFilesBuildPhase section */ + + /* Begin PBXFileReference section */ ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; ++ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; + D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; + 142CB75C2A60C01E0075748A /* ListButtonStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListButtonStyle.swift; sourceTree = ""; }; + 142CB7602A60C0440075748A /* FavoriteFood.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FavoriteFood.swift; sourceTree = ""; }; +@@ -2971,6 +2983,8 @@ + B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, + B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, + 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, + ); + path = ViewModels; + sourceTree = ""; +@@ -3146,6 +3160,10 @@ + E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, + 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, + 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, ++ DDAF746629F422C600719F0A /* ProfileView.swift */, ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, + ); + path = "Settings Editors"; + sourceTree = ""; +@@ -3815,6 +3833,12 @@ + E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, + B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, + 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, + 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, + 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, + 892A5DA02231E130008961AB /* CompletionNotifying.swift in Sources */, +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 6d3f9db2..5f1f37f3 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -8,6 +8,12 @@ + + import Foundation + ++/// Switches to a saved therapy profile on a remote command. ++public protocol RemoteProfileActionDelegate: AnyObject { ++ /// `verify` receives the schedules about to be activated and throws to reject them. ++ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws ++} ++ + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws + func cancelRemoteOverride() async throws +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +new file mode 100644 +index 00000000..0a287cac +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +@@ -0,0 +1,427 @@ ++// ++// ProfileViewModel+FileManagement.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import LoopKit ++import HealthKit ++import LoopAlgorithm ++ ++public enum ProfileValidationError: Error, LocalizedError { ++ case fileLoadError ++ case unsupportedIncrement ++ case correctionRangeError ++ case insulinSensitivityError ++ case carbRatioError ++ case basalRateError ++ case maxBasalRateNotSet ++ ++ public var errorDescription: String? { ++ switch self { ++ case .fileLoadError: ++ return "Unable to load the profile file." ++ case .unsupportedIncrement: ++ return "The profile contains unsupported increments." ++ case .correctionRangeError: ++ return "Correction Range values are out of bounds." ++ case .insulinSensitivityError: ++ return "Insulin Sensitivity values are out of bounds." ++ case .carbRatioError: ++ return "Carb Ratio values are out of bounds." ++ case .basalRateError: ++ return "Basal Rate values are out of bounds." ++ case .maxBasalRateNotSet: ++ return "Maximum Basal Rate is not set." ++ } ++ } ++} ++ ++public enum LoadProfileResult { ++ case success ++ case failure(Error) ++} ++ ++// MARK: File management ++extension ProfileViewModel { ++ private func setCurrentProfile(name: String, syncChange: Bool = false) { ++ let sanitizedProfileName = sanitizeProfileName(name) ++ currentProfileName = sanitizedProfileName ++ if syncChange { ++ triggerProfileSync() ++ } ++ } ++ ++ private func clearCurrentProfile() { ++ currentProfileName = nil ++ triggerProfileSync() ++ } ++ ++ private func getCurrentProfileName() -> String? { ++ return currentProfileName ++ } ++ ++ private var profilesDirectory: URL { ++ let fileManager = FileManager.default ++ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] ++ return documentsDirectory.appendingPathComponent("LoopProfile") ++ } ++ ++ private func createProfileDirectoryIfNotExists() throws { ++ let fileManager = FileManager.default ++ var isDir : ObjCBool = false ++ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { ++ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) ++ } else if !isDir.boolValue { ++ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) ++ } ++ } ++ ++ private func getAllProfileFiles() throws -> [URL] { ++ let fileManager = FileManager.default ++ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) ++ return files.filter { $0.pathExtension == "json" } ++ } ++ ++ private func encodeProfile(_ profile: Profile) throws -> Data { ++ return try JSONEncoder().encode(profile) ++ } ++ ++ private func decodeProfile(from data: Data) throws -> Profile { ++ return try JSONDecoder().decode(Profile.self, from: data) ++ } ++ ++ public func sanitizeProfileName(_ name: String) -> String { ++ // Replace problematic characters ++ var sanitized = name.replacingOccurrences(of: ".", with: "_") ++ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") ++ ++ // Limit the name to 32 characters ++ if sanitized.count > 32 { ++ sanitized = String(sanitized.prefix(32)) ++ } ++ ++ return sanitized ++ } ++ ++ public func saveProfile(withName name: String) { ++ let sanitizedName = sanitizeProfileName(name) ++ ++ do { ++ try createProfileDirectoryIfNotExists() ++ ++ if let existingProfile = getProfileReference(withName: sanitizedName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let profile = Profile( ++ name: sanitizedName, ++ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ carbRatioSchedule: therapySettings.carbRatioSchedule!, ++ basalRateSchedule: therapySettings.basalRateSchedule!, ++ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ sortOrder: -1 ++ ) ++ ++ let jsonData = try encodeProfile(profile) ++ ++ let dateFormatter = DateFormatter() ++ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" ++ let fileName = dateFormatter.string(from: Date()) + ".json" ++ let fileURL = profilesDirectory.appendingPathComponent(fileName) ++ ++ try jsonData.write(to: fileURL) ++ setCurrentProfile(name: sanitizedName, syncChange: true) ++ ++ self.loadProfiles() ++ } catch { ++ print("An error occurred while saving profile: \(error)") ++ } ++ } ++ ++ public func renameProfile(oldName: String, newName: String) { ++ let sanitizedProfileName = sanitizeProfileName(newName) ++ ++ if currentProfileName == oldName { ++ setCurrentProfile(name: sanitizedProfileName, syncChange: true) ++ } ++ ++ do { ++ guard let profileReference = getProfileReference(withName: oldName) else { ++ print("Profile with old name not found.") ++ return ++ } ++ ++ let profile = try getProfile(from: profileReference) ++ ++ let newProfile = Profile( ++ name: sanitizedProfileName, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ ++ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ ++ try jsonData.write(to: fileURL) ++ ++ self.loadProfiles() ++ triggerProfileSync() ++ } catch { ++ print("An error occurred while renaming profile: \(error)") ++ } ++ } ++ ++ public func updateProfilesOrder() { ++ for (index, profileReference) in profiles.enumerated() { ++ do { ++ var profile = try getProfile(from: profileReference) ++ let newProfile = Profile( ++ name: profile.name, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: index ++ ) ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try jsonData.write(to: fileURL) ++ ++ } catch { ++ print("An error occurred while updating profile order: \(error)") ++ } ++ } ++ loadProfiles() ++ } ++ ++ public func loadProfiles() { ++ do { ++ let profileFiles = try getAllProfileFiles() ++ ++ var newProfiles = [ProfileReference]() ++ for fileURL in profileFiles { ++ let data = try Data(contentsOf: fileURL) ++ let profile = try decodeProfile(from: data) ++ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) ++ newProfiles.append(profileRef) ++ } ++ ++ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } ++ ++ self.profiles = newProfiles ++ } catch { ++ print("An error occurred while loading profiles: \(error)") ++ } ++ } ++ ++ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { ++ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && ++ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && ++ therapySettings.basalRateSchedule == profile.basalRateSchedule && ++ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { ++ self.setCurrentProfile(name: profile.name, syncChange: true) ++ completion(.success) ++ } ++ else ++ { ++ do { ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ switch result { ++ case .success(let syncedSchedule): ++ DispatchQueue.main.async { ++ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter ++ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) ++ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) ++ self?.saveBasalRates(basalRates: syncedSchedule) ++ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ print("New profile loaded") ++ self?.setCurrentProfile(name: profile.name, syncChange: false) ++ completion(.success) ++ } ++ case .failure(let error): ++ print("An error occurred while syncing basal rates: \(error)") ++ completion(.failure(error)) ++ } ++ }) ++ } ++ } ++ } ++ ++ func getProfileReference(withName name: String) -> ProfileReference? { ++ let sanitizedInputName = sanitizeProfileName(name) ++ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) ++ } ++ ++ public func removeProfile(profile: Profile) { ++ guard let profileReference = getProfileReference(withName: profile.name) else { ++ print("No ProfileReference found for given Profile") ++ return ++ } ++ ++ removeProfile(profileReference: profileReference) ++ if getCurrentProfileName() == profile.name { ++ clearCurrentProfile() ++ } else { ++ triggerProfileSync() ++ } ++ } ++ ++ public func removeProfile(profileReference: ProfileReference) { ++ do { ++ let fileManager = FileManager.default ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try fileManager.removeItem(at: fileURL) ++ loadProfiles() ++ } catch { ++ print("An error occurred while removing profile: \(error)") ++ } ++ } ++ ++ func doesProfileExist(withName name: String) -> Bool { ++ return profiles.contains(where: { $0.name == name }) ++ } ++ ++ public func getProfile(from profileReference: ProfileReference) throws -> Profile { ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ let data = try Data(contentsOf: fileURL) ++ let getProfile = try decodeProfile(from: data) ++ return getProfile ++ } ++ ++ /// The saved profile named `name`, or nil when there is none. ++ public func savedProfile(named name: String) throws -> Profile? { ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ return nil ++ } ++ return try getProfile(from: profileReference) ++ } ++ ++ public func validateProfile(_ profile: Profile) -> Result { ++ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { ++ return .failure(.fileLoadError) ++ } ++ ++ // Checking Correction Range Schedule ++ for item in profile.correctionRange.items { ++ let minValue = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) ++ let maxValue = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) ++ ++ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { ++ return .failure(.correctionRangeError) ++ } ++ } ++ ++ // Checking Insulin Sensitivity Schedule ++ for item in profile.insulinSensitivitySchedule.items { ++ let value = LoopQuantity(unit: .milligramsPerDeciliterPerInternationalUnit, doubleValue: item.value) ++ ++ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { ++ return .failure(.insulinSensitivityError) ++ } ++ } ++ ++ // Checking Carb Ratio Schedule ++ for item in profile.carbRatioSchedule.items { ++ let value = LoopQuantity(unit: .gramsPerUnit, doubleValue: item.value) ++ ++ if !Guardrail.carbRatio.absoluteBounds.contains(value) { ++ return .failure(.carbRatioError) ++ } ++ } ++ ++ // Checking Basal Rate Schedule ++ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { ++ for item in profile.basalRateSchedule.items { ++ let value = item.value ++ ++ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { ++ return .failure(.basalRateError) ++ } ++ } ++ } else { ++ return .failure(.maxBasalRateNotSet) ++ } ++ ++ return .success(()) ++ } ++ ++ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value.minValue - item2.value.minValue) > 0.01 || ++ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value - item2.value) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ public func isCurrentProfileOutOfSync() -> Bool { ++ guard let currentProfileName = self.currentProfileName, ++ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), ++ let profile = try? getProfile(from: currentProfileReference), ++ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: LoopUnit.milligramsPerDeciliter), ++ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: LoopUnit.milligramsPerDeciliter) else { ++ return false // No current profile set or profile not found or schedules not available ++ } ++ ++ var isOutOfSync = false ++ ++ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: LoopUnit.milligramsPerDeciliter)!) { ++ print("Glucose target range schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ print("Carb ratio schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ print("Basal rate schedule differs") ++ isOutOfSync = true ++ } ++ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: LoopUnit.milligramsPerDeciliter)!) { ++ print("Insulin sensitivity schedule differs") ++ isOutOfSync = true ++ } ++ ++ return isOutOfSync ++ } ++} ++ ++extension Profile { ++ /// Passes the schedules this profile activates to `check`. ++ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { ++ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +new file mode 100644 +index 00000000..a48fa667 +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +@@ -0,0 +1,86 @@ ++// ++// ProfileViewModel.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import Combine ++import LoopKit ++import HealthKit ++import SwiftUI ++ ++public class ProfileViewModel: ObservableObject { ++ ++ @Published public var therapySettings: TherapySettings ++ @Published public var profiles = [ProfileReference]() ++ @Published public var currentProfileName: String? { ++ didSet { ++ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") ++ } ++ } ++ ++ internal weak var delegate: TherapySettingsViewModelDelegate? ++ ++ public init(therapySettings: TherapySettings, ++ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, ++ sensitivityOverridesEnabled: Bool = false, ++ adultChildInsulinModelSelectionEnabled: Bool = false, ++ prescription: Prescription? = nil, ++ delegate: TherapySettingsViewModelDelegate? = nil) { ++ self.therapySettings = therapySettings ++ self.delegate = delegate ++ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") ++ self.loadProfiles() ++ } ++ ++ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return delegate?.pumpSupportedIncrements() ++ } ++} ++ ++public struct Profile: Codable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ var sortOrder: Int? ++} ++ ++public struct ProfileReference: Codable, Equatable { ++ var name: String ++ var fileName: String ++ var sortOrder: Int? ++} ++ ++ ++// MARK: Saving ++extension ProfileViewModel { ++ ++ public func saveCorrectionRange(range: GlucoseRangeSchedule) { ++ therapySettings.glucoseTargetRangeSchedule = range ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveBasalRates(basalRates: BasalRateSchedule) { ++ therapySettings.basalRateSchedule = basalRates ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { ++ therapySettings.carbRatioSchedule = carbRatioSchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { ++ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func triggerProfileSync() { ++ delegate?.updateCurrentProfileName() ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +index 81be1865..913c82ac 100644 +--- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift ++++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { + func syncDeliveryLimits(deliveryLimits: DeliveryLimits) async throws -> DeliveryLimits + func saveCompletion(therapySettings: TherapySettings) + func pumpSupportedIncrements() -> PumpSupportedIncrements? ++ func updateCurrentProfileName() + } + + @Observable +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +new file mode 100644 +index 00000000..446910e4 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +@@ -0,0 +1,79 @@ ++// ++// NewProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct NewProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var newProfileName: String ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("New Profile") ++ .font(.headline) ++ }, ++ trailing: { addButton } ++ ) ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var addButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Add") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +new file mode 100644 +index 00000000..3566055d +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +@@ -0,0 +1,334 @@ ++// ++// ProfilePreviewView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-23. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++import HealthKit ++import LoopKit ++import LoopAlgorithm ++ ++enum ActiveAlert { ++ case load ++ case delete ++ case error ++} ++ ++struct ProfilePreviewView: View { ++ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference ++ @ObservedObject var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @Environment(\.presentationMode) var presentationMode ++ var profile: Profile ++ ++ @State private var showingAlert = false ++ @State private var activeAlert: ActiveAlert = .load ++ @State private var errorText: String? ++ @State private var newProfileName: String = "" ++ @State private var isRenamingProfile = false ++ @State private var shouldDismissSelf: Bool = false ++ ++ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } ++ ++ var body: some View { ++ ZStack { ++ ScrollView { ++ VStack(alignment: .leading) { ++ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ .font(.body) ++ .foregroundColor(.primary) ++ .padding([.leading, .trailing]) ++ .fixedSize(horizontal: false, vertical: true) ++ .textCase(nil) ++ ++ profileCardStack ++ ++ Button(action: { ++ activeAlert = .load ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Load") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.blue) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ isRenamingProfile = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Rename") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.orange) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ activeAlert = .delete ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Delete") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.red) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ } ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .navigationTitle(Text(profile.name)) ++ .alert(isPresented: $showingAlert) { ++ switch activeAlert { ++ case .load: ++ return Alert( ++ title: Text("Load Profile"), ++ message: Text("Do you want to load the profile \(profile.name)?"), ++ primaryButton: .default(Text("Yes"), action: { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .delete: ++ return Alert( ++ title: Text("Delete Profile"), ++ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), ++ primaryButton: .destructive(Text("Delete"), action: { ++ viewModel.removeProfile(profile: profile) ++ self.presentationMode.wrappedValue.dismiss() ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .error: ++ return Alert( ++ title: Text("Error loading profile"), ++ message: Text(errorText ?? "Unknown error"), ++ dismissButton: .default(Text("OK")) ++ ) ++ } ++ } ++ if isRenamingProfile { ++ // Some hacks here to mimic Alert behaviour. ++ // This function should be migrateed to Alert when iOS 16 is required. ++ Color.black.opacity(0.5) ++ .edgesIgnoringSafeArea(.all) ++ .onTapGesture { ++ isRenamingProfile = false ++ } ++ ++ RenameProfileEditor( ++ isPresented: $isRenamingProfile, ++ currentProfileName: profile.name, ++ viewModel: viewModel, ++ shouldDismissParent: $shouldDismissSelf ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ .offset(y: -22) ++ } ++ } ++ .onChange(of: shouldDismissSelf) { shouldDismiss in ++ if shouldDismiss { ++ self.presentationMode.wrappedValue.dismiss() ++ } ++ } ++ } ++ ++ private var profileCardStack: CardStack { ++ var cards: [Card] = [] ++ ++ cards.append(correctionRangeSection) ++ cards.append(carbRatioSection) ++ cards.append(basalRatesSection) ++ cards.append(insulinSensitivitiesSection) ++ ++ return CardStack(cards: cards) ++ } ++ ++ private var correctionRangeSection: Card { ++ card(for: .glucoseTargetRange) { ++ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleRangeItem(time: items[index].startTime, ++ range: items[index].value, ++ unit: glucoseUnit, ++ guardrail: .correctionRange) ++ } ++ } ++ } ++ } ++ ++ private var carbRatioSection: Card { ++ card(for: .carbRatio) { ++ SectionDivider() ++ let items = profile.carbRatioSchedule.items ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .gramsPerUnit, ++ guardrail: .carbRatio) ++ } ++ } ++ } ++ ++ private var basalRatesSection: Card { ++ card(for: .basalRate) { ++ let items = profile.basalRateSchedule.items ++ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates ++ { ++ SectionDivider() ++ let total = profile.basalRateSchedule.total() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .internationalUnitsPerHour, ++ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) ++ } ++ HStack { ++ Text("Total") ++ .bold() ++ .foregroundColor(.primary) ++ Spacer() ++ Text(String(format: "%.2f ",total)) ++ .foregroundColor(.primary) + ++ Text("U/day") ++ .foregroundColor(.secondary) ++ } ++ } ++ } ++ } ++ ++ private var insulinSensitivitiesSection: Card { ++ card(for: .insulinSensitivity) { ++ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items ++ { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: sensitivityUnit, ++ guardrail: .insulinSensitivity) ++ } ++ } ++ } ++ } ++} ++ ++ ++struct SectionWithContent: View where Content: View { ++ let title: String ++ let content: Content ++ ++ init(title: String, ++ content: () -> Content) ++ { ++ self.title = title ++ self.content = content() ++ } ++ ++ public var body: some View { ++ Section { ++ Text(title) ++ .bold() ++ .frame(maxWidth: .infinity, alignment: .leading) ++ content ++ } ++ } ++} ++ ++// MARK: Utilities ++extension ProfilePreviewView { ++ ++ private var glucoseUnit: LoopUnit { ++ displayGlucosePreference.unit ++ } ++ ++ private var sensitivityUnit: LoopUnit { ++ switch glucoseUnit { ++ case .milligramsPerDeciliter: ++ return .milligramsPerDeciliterPerInternationalUnit ++ case .millimolesPerLiter: ++ return .millimolesPerLiterPerInternationalUnit ++ default: ++ return .milligramsPerDeciliterPerInternationalUnit ++ } ++ } ++ ++ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { ++ Card { ++ SectionWithContent(title: therapySetting.title, ++ content: content) ++ } ++ } ++} ++ ++fileprivate struct SectionDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -16) ++ } ++} ++ ++fileprivate struct SettingsDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -8) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +new file mode 100644 +index 00000000..a1f35678 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +@@ -0,0 +1,172 @@ ++// ++// ProfileView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import AVFoundation ++import HealthKit ++import LoopKit ++import SwiftUI ++ ++public struct ProfileView: View { ++ @ObservedObject public var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @State private var newProfileName: String = "" ++ @State private var isAddingNewProfile = false ++ @State private var selectedProfileIndex: Int? = nil ++ @State private var refreshID = UUID() ++ ++ enum ActiveAlert: String, Identifiable { ++ case update, info ++ ++ var id: String { ++ return self.rawValue ++ } ++ } ++ @State private var activeAlert: ActiveAlert? ++ ++ public init(viewModel: ProfileViewModel) { ++ self.viewModel = viewModel ++ } ++ ++ private var dismissButton: some View { ++ Button(action: dismissAction) { ++ Text(LocalizedString("Done", comment: "Text for dismiss button")) ++ .bold() ++ } ++ } ++ ++ public var body: some View { ++ ZStack { ++ NavigationView { ++ ConfigurationPageScrollView( ++ content: { ++ VStack(alignment: .leading) { ++ ++ if !viewModel.profiles.isEmpty { ++ List { ++ ForEach(viewModel.profiles.indices, id: \.self) { index in ++ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { ++ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { ++ HStack { ++ if viewModel.currentProfileName == viewModel.profiles[index].name { ++ Image(systemName: "checkmark") ++ .foregroundColor(Color.blue) ++ } else { ++ // Add indentation to align with the checkmark for the active profile ++ Image(systemName: "checkmark") ++ .opacity(0) // Invisible ++ } ++ ++ Text(viewModel.profiles[index].name) ++ } ++ } ++ } ++ } ++ .onMove(perform: moveProfile) ++ }.id(refreshID) ++ ++ } else { ++ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") ++ .font(.subheadline) ++ .foregroundColor(.secondary) ++ .padding([.leading, .trailing]) ++ .multilineTextAlignment(.leading) ++ .lineLimit(nil) ++ .textCase(nil) ++ } ++ } ++ }, ++ actionArea: { EmptyView() } // no action area in this case ++ ) ++ .navigationBarItems( ++ leading: HStack { ++ Button(action: { withAnimation { isAddingNewProfile = true } }) { ++ Image(systemName: "plus") ++ } ++ ++ Button(action: { activeAlert = .info }) { ++ Image(systemName: "info.circle") ++ .resizable() ++ .frame(width: 24, height: 24) ++ } ++ }, ++ trailing: dismissButton ++ ) ++ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .onAppear { ++ if viewModel.isCurrentProfileOutOfSync() { ++ activeAlert = .update ++ } ++ } ++ ++ .alert(item: $activeAlert) { alertType in ++ switch alertType { ++ case .update: ++ return Alert( ++ title: Text("Profile Outdated"), ++ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), ++ primaryButton: .default(Text("Update")) { ++ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") ++ }, ++ secondaryButton: .cancel() ++ ) ++ case .info: ++ return Alert(title: Text("Information"), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ dismissButton: .default(Text("Got it!"))) ++ } ++ } ++ ++ if isAddingNewProfile { ++ DarkenedOverlay() ++ ++ NewProfileEditor( ++ isPresented: $isAddingNewProfile, ++ newProfileName: newProfileName, ++ viewModel: viewModel ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ } ++ } ++ } ++ ++ func moveProfile(from source: IndexSet, to destination: Int) { ++ viewModel.profiles.move(fromOffsets: source, toOffset: destination) ++ refreshID = UUID() ++ viewModel.updateProfilesOrder() ++ } ++} ++ ++struct ProfileView_Previews: PreviewProvider { ++ static let preview_glucoseScheduleItems = [ ++ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), ++ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), ++ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) ++ ] ++ ++ static let preview_therapySettings = TherapySettings( ++ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), ++ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), ++ unit: .milligramsPerDeciliter), ++ maximumBolus: 4, ++ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), ++ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: .milligramsPerDeciliterPerInternationalUnit, dailyItems: []), ++ carbRatioSchedule: nil, ++ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) ++ ++ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] ++ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] ++ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] ++ ++ static var previews: some View { ++ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +new file mode 100644 +index 00000000..8fd26665 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +@@ -0,0 +1,92 @@ ++// ++// RenameProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-09-07. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct RenameProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var currentProfileName: String ++ @State var newProfileName: String = "" ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ @Binding var shouldDismissParent: Bool ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("Rename Profile") ++ .font(.subheadline) ++ ++ }, ++ trailing: { renameButton } ++ ) ++ .onAppear { ++ self.newProfileName = currentProfileName ++ } ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var renameButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ guard newProfileName != currentProfileName else { ++ self.isPresented = false ++ return ++ } ++ ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Rename") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +Submodule LoopOnboarding contains modified content +diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +index 1ddc82b..54421a1 100644 +--- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift ++++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +@@ -381,6 +381,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { + maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount + ) + } ++ ++ func updateCurrentProfileName() { ++ } + } + + +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index ea85f5a..eb8f03d 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -23,6 +23,10 @@ + A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; +@@ -163,6 +167,10 @@ + A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; + A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; +@@ -279,6 +287,7 @@ + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, + A934B6172358F49300949C8B /* StoredSettings.swift */, ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, + A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, + A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, +@@ -338,6 +347,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -380,6 +390,7 @@ + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, + ); + path = Notifications; + sourceTree = ""; +@@ -428,6 +439,7 @@ + A9AA6E5729EB06AA008FFA78 /* Actions */ = { + isa = PBXGroup; + children = ( ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, +@@ -765,6 +777,9 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, + A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, + C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, + ); +@@ -781,6 +796,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, + A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +new file mode 100644 +index 0000000..588ded1 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,104 @@ ++// ++// SavedProfile.swift ++// NightscoutServiceKit ++// ++ ++import CryptoKit ++import Foundation ++import LoopAlgorithm ++import LoopKit ++import NightscoutKit ++ ++/// A profile saved with the Profiles settings screen, read from the file it was saved to. ++struct SavedProfile: Decodable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ ++ private static var directory: URL { ++ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") ++ } ++ ++ static func loadAll() -> [SavedProfile] { ++ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] ++ return files ++ .filter { $0.pathExtension == "json" } ++ .compactMap { try? Data(contentsOf: $0) } ++ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } ++ } ++ ++ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. ++ static func nightscoutStore(unit: LoopUnit) -> [String: ProfileSet.Profile] { ++ var store: [String: ProfileSet.Profile] = [:] ++ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. ++ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { ++ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) ++ } ++ return store ++ } ++ ++ /// True when this profile uploads with this fingerprint, in either glucose unit. ++ func matches(fingerprint: String) -> Bool { ++ [LoopUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { ++ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint ++ } ++ } ++ ++ func nightscoutProfile(unit: LoopUnit) -> ProfileSet.Profile? { ++ guard let correctionRange = correctionRange.schedule(for: unit), ++ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) ++ else { ++ return nil ++ } ++ return ProfileSet.Profile( ++ timezone: basalRateSchedule.timeZone, ++ dia: .hours(6), ++ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, ++ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, ++ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) ++ } ++} ++ ++extension ProfileSet.Profile { ++ /// Followers compute the same value from the uploaded profile and send it with a switch command, ++ /// so Loop only activates the values the follower previewed. ++ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` ++ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. ++ var remoteFingerprint: String { ++ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { ++ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") ++ } ++ let canonical = [ ++ "basal=" + items(basal), ++ "carbratio=" + items(carbratio), ++ "sens=" + items(sensitivity), ++ "target_low=" + items(targetLow), ++ "target_high=" + items(targetHigh), ++ ].joined(separator: ";") ++ let digest = SHA256.hash(data: Data(canonical.utf8)) ++ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index e7bb1ad..1b43f76 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -180,7 +180,7 @@ extension StoredDosingDecision { + enacted: automaticDoseDecision?.loopStatusEnacted, + failureReason: automaticDoseDecision?.loopStatusFailureReason, + failureCode: automaticDoseDecision?.errors.first?.id, +- testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported], ++ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]], + algorithmExperiments: (automaticDoseDecision?.algorithmExperiments ?? algorithmExperiments)?.map { $0.jsonObject }), + overrideStatus: overrideStatus) + } +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +index 3142223..ae6fb9a 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +@@ -93,12 +93,21 @@ extension StoredSettings { + return nil + } + ++ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) ++ store["Default"] = profile ++ var defaultProfile = "Default" ++ ++ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { ++ defaultProfile = name ++ store[name] = profile ++ } ++ + return ProfileSet( + startDate: date, + units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), + enteredBy: "Loop", +- defaultProfile: "Default", +- store: ["Default": profile], ++ defaultProfile: defaultProfile, ++ store: store, + settings: loopSettings, + syncIdentifier: syncIdentifier.uuidString, + isAPNSProduction: Bundle.main.isAPNSProductionEnvironment, +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index fe3c774..3a513fb 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -460,6 +460,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + success = true + message = "Override '\(overrideCommand.name)' enacted successfully" + ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ success = true ++ message = "Profile '\(profileSwitchCommand.name)' activated" ++ + case .cancelTemporaryOverride: + commandType = .cancelOverride + try await self.serviceDelegate?.cancelRemoteOverride() +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index 91ad56c..29d3563 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -9,6 +9,7 @@ + import Foundation + + public enum Action: Codable { ++ case profileSwitch(ProfileSwitchAction) + case temporaryScheduleOverride(OverrideAction) + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) +@@ -34,6 +35,8 @@ public enum Action: Codable { + return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") + case .temporaryScheduleOverride: + return LocalizedString("Override", comment: "The remote action name for Override") ++ case .profileSwitch: ++ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") + } + } + +@@ -54,6 +57,8 @@ public enum Action: Codable { + return "" + case .temporaryScheduleOverride(let overrideAction): + return overrideAction.name ++ case .profileSwitch(let profileSwitchAction): ++ return profileSwitchAction.name + } + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +new file mode 100644 +index 0000000..ce03ded +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +@@ -0,0 +1,17 @@ ++// ++// ProfileSwitchAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct ProfileSwitchAction: Codable { ++ ++ public let name: String ++ public let fingerprint: String ++ ++ public init(name: String, fingerprint: String) { ++ self.name = name ++ self.fingerprint = fingerprint ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 6092bc9..aabb47f 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -49,6 +49,7 @@ class RemoteNotificationResponseManager { + case carbsEdit = "carbs_edit" + case override = "override" + case cancelOverride = "cancel_override" ++ case profileSwitch = "profile_switch" + } + + func sendResponseNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +new file mode 100644 +index 0000000..487c620 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -0,0 +1,50 @@ ++// ++// ProfileSwitchRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Switches to a saved profile. `expiration` is required: decoding fails without it. ++public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ public let profileName: String ++ public let fingerprint: String ++ public let remoteAddress: String ++ public let expirationDate: Date ++ public let sentAt: Date? ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ public var expiration: Date? { ++ return expirationDate ++ } ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expirationDate = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification[commandKey] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 6f0524d..87f1e92 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -122,6 +122,8 @@ extension Dictionary { + return try OverrideRemoteNotification(dictionary: self) + } else if OverrideCancelRemoteNotification.includedInNotification(self) { + return try OverrideCancelRemoteNotification(dictionary: self) ++ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { ++ return try ProfileSwitchRemoteNotification(dictionary: self) + } else { + throw RemoteNotificationError.unhandledNotification(self) + } +diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..2fcec94 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +@@ -0,0 +1,71 @@ ++// ++// ProfileSwitchRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "sent-at": "2023-02-25T20:46:35.778Z", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ] ++ ++ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) ++ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ ++ XCTAssertEqual(profileSwitch.profileName, "Sport") ++ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertTrue(profileSwitch.otpValidationRequired()) ++ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { ++ return XCTFail("Expected a profile switch action") ++ } ++ XCTAssertEqual(action.name, "Sport") ++ } ++ ++ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { ++ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ let profile = ProfileSet.Profile( ++ timezone: TimeZone(secondsFromGMT: 3600)!, ++ dia: .hours(6), ++ sensitivity: items([(0, 45)]), ++ carbratio: items([(0, 10)]), ++ basal: items([(0, 0.85), (21600, 1.1)]), ++ targetLow: items([(0, 100)]), ++ targetHigh: items([(0, 110)]), ++ units: "mg/dL") ++ ++ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") ++ } ++} ++ ++private extension ISO8601DateFormatter { ++ static let fractional: ISO8601DateFormatter = { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ }() ++} +diff --git a/profiles.loopcustomization b/profiles.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/profiles/nextdev_profiles_remote_carb_edit_remote_window.patch b/profiles/nextdev_profiles_remote_carb_edit_remote_window.patch new file mode 100644 index 0000000..ec1128a --- /dev/null +++ b/profiles/nextdev_profiles_remote_carb_edit_remote_window.patch @@ -0,0 +1,1997 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index 3c9d61cb..5591c9a5 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -1407,6 +1407,13 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { + return try await pumpManager?.syncDeliveryLimits(limits: deliveryLimits) ?? deliveryLimits + } + ++ func updateCurrentProfileName() { ++ NotificationCenter.default.post(name: .LoopDataUpdated, ++ object: nil, ++ userInfo: [LoopDataManager.LoopUpdateContextKey: LoopUpdateContext.preferences.rawValue]) ++ settingsManager.storeSettings() ++ } ++ + func saveCompletion(therapySettings: TherapySettings) { + settingsManager.mutateLoopSettings { settings in + settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule +diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift +index dec6dff3..0b8248c2 100644 +--- a/Loop/Loop/Managers/LoopAppManager.swift ++++ b/Loop/Loop/Managers/LoopAppManager.swift +@@ -401,6 +401,7 @@ class LoopAppManager: NSObject { + + dosingDecisionStore.delegate = deviceDataManager + remoteDataServicesManager.delegate = deviceDataManager ++ servicesManager.therapySettingsViewModelDelegate = deviceDataManager + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceDataManager.deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index 69f660ca..feb53338 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -1744,7 +1744,7 @@ extension LoopDataManager: ManualDoseViewModelDelegate { + var settings: StoredSettings { + settingsProvider.settings + } +- ++ + var scheduleOverride: TemporaryScheduleOverride? { + temporaryPresetsManager.scheduleOverride + } +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 18975ce1..1cf442dd 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -326,6 +326,38 @@ extension NotificationManager { + UNUserNotificationCenter.current().add(request) + } + ++ static func sendRemoteProfileNotification(name: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfile", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteProfileFailureNotification(name: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ private static func remoteProfileNotificationBody(name: String) -> String { ++ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ } ++ + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { + return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + } +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index 3aaf5212..65a47001 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -30,6 +30,7 @@ class ServicesManager { + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? + weak var supportManager: SupportManager? ++ weak var therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? + + private var services = [Service]() + +@@ -476,3 +477,58 @@ extension Service { + ] + } + } ++ ++// MARK: - RemoteProfileActionDelegate ++ ++extension ServicesManager: RemoteProfileActionDelegate { ++ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { ++ do { ++ guard let therapySettingsViewModelDelegate else { ++ throw RemoteProfileActionError.unavailable ++ } ++ let viewModel = ProfileViewModel(therapySettings: settingsManager.therapySettings, delegate: therapySettingsViewModelDelegate) ++ guard therapySettingsViewModelDelegate.pumpSupportedIncrements() != nil else { ++ throw RemoteProfileActionError.noPump ++ } ++ guard let profile = try viewModel.savedProfile(named: name) else { ++ throw RemoteProfileActionError.profileNotFound(name) ++ } ++ try profile.verify(with: verify) ++ if case .failure(let error) = viewModel.validateProfile(profile) { ++ throw error ++ } ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ viewModel.triggerProfileSync() ++ NotificationManager.sendRemoteProfileNotification(name: name) ++ } catch { ++ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteProfileActionError: LocalizedError { ++ case unavailable ++ case noPump ++ case profileNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .unavailable: ++ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: profile view model delegate missing.") ++ case .noPump: ++ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") ++ case .profileNotFound(let name): ++ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) ++ } ++ } ++ } ++} +diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift +index 1faefbc5..737acf28 100644 +--- a/Loop/Loop/Views/SettingsView.swift ++++ b/Loop/Loop/Views/SettingsView.swift +@@ -55,6 +55,7 @@ struct SettingsView: View { + + case favoriteFoods + case presets ++ case profiles + } + } + +@@ -155,6 +156,9 @@ struct SettingsView: View { + } + case .favoriteFoods: + FavoriteFoodsView(insightsDelegate: viewModel.favoriteFoodInsightsDelegate) ++ case .profiles: ++ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), ++ delegate: self.viewModel.therapySettingsViewModelDelegate)) + } + } + .environmentObject(displayGlucosePreference) +@@ -416,6 +420,11 @@ extension SettingsView { + .accessibilityIdentifier("button_TherapySettings") + } + ++ LargeButton(action: { sheet = .profiles }, ++ includeArrow: true, ++ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), ++ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), ++ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) + ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in + item.view + } +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj +index cc88ad17..c543b73a 100644 +--- a/LoopKit/LoopKit.xcodeproj/project.pbxproj ++++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj +@@ -7,6 +7,12 @@ + objects = { + + /* Begin PBXBuildFile section */ ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; + 9D3D2DA3B9824EF48981F0E4 /* StoredInsulinModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CAB9E826A3254800A57273 /* StoredInsulinModel.swift */; }; + 485ACF65C30744BFBC979CEF /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A985464E251449FE0099C1A6 /* NotificationSettings.swift */; }; + 2383C3D7967B4602B8EB4A5B /* SettingsObject+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = A912BE2A245B9E8600CBE199 /* SettingsObject+CoreDataProperties.swift */; }; +@@ -1065,6 +1071,12 @@ + /* End PBXCopyFilesBuildPhase section */ + + /* Begin PBXFileReference section */ ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; ++ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; + D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; + 142CB75C2A60C01E0075748A /* ListButtonStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListButtonStyle.swift; sourceTree = ""; }; + 142CB7602A60C0440075748A /* FavoriteFood.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FavoriteFood.swift; sourceTree = ""; }; +@@ -2971,6 +2983,8 @@ + B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, + B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, + 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, + ); + path = ViewModels; + sourceTree = ""; +@@ -3146,6 +3160,10 @@ + E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, + 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, + 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, ++ DDAF746629F422C600719F0A /* ProfileView.swift */, ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, + ); + path = "Settings Editors"; + sourceTree = ""; +@@ -3815,6 +3833,12 @@ + E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, + B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, + 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, + 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, + 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, + 892A5DA02231E130008961AB /* CompletionNotifying.swift in Sources */, +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 7da27b70..39c0eee9 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -8,6 +8,12 @@ + + import Foundation + ++/// Switches to a saved therapy profile on a remote command. ++public protocol RemoteProfileActionDelegate: AnyObject { ++ /// `verify` receives the schedules about to be activated and throws to reject them. ++ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws ++} ++ + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws + func cancelRemoteOverride() async throws +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +new file mode 100644 +index 00000000..0a287cac +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +@@ -0,0 +1,427 @@ ++// ++// ProfileViewModel+FileManagement.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import LoopKit ++import HealthKit ++import LoopAlgorithm ++ ++public enum ProfileValidationError: Error, LocalizedError { ++ case fileLoadError ++ case unsupportedIncrement ++ case correctionRangeError ++ case insulinSensitivityError ++ case carbRatioError ++ case basalRateError ++ case maxBasalRateNotSet ++ ++ public var errorDescription: String? { ++ switch self { ++ case .fileLoadError: ++ return "Unable to load the profile file." ++ case .unsupportedIncrement: ++ return "The profile contains unsupported increments." ++ case .correctionRangeError: ++ return "Correction Range values are out of bounds." ++ case .insulinSensitivityError: ++ return "Insulin Sensitivity values are out of bounds." ++ case .carbRatioError: ++ return "Carb Ratio values are out of bounds." ++ case .basalRateError: ++ return "Basal Rate values are out of bounds." ++ case .maxBasalRateNotSet: ++ return "Maximum Basal Rate is not set." ++ } ++ } ++} ++ ++public enum LoadProfileResult { ++ case success ++ case failure(Error) ++} ++ ++// MARK: File management ++extension ProfileViewModel { ++ private func setCurrentProfile(name: String, syncChange: Bool = false) { ++ let sanitizedProfileName = sanitizeProfileName(name) ++ currentProfileName = sanitizedProfileName ++ if syncChange { ++ triggerProfileSync() ++ } ++ } ++ ++ private func clearCurrentProfile() { ++ currentProfileName = nil ++ triggerProfileSync() ++ } ++ ++ private func getCurrentProfileName() -> String? { ++ return currentProfileName ++ } ++ ++ private var profilesDirectory: URL { ++ let fileManager = FileManager.default ++ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] ++ return documentsDirectory.appendingPathComponent("LoopProfile") ++ } ++ ++ private func createProfileDirectoryIfNotExists() throws { ++ let fileManager = FileManager.default ++ var isDir : ObjCBool = false ++ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { ++ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) ++ } else if !isDir.boolValue { ++ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) ++ } ++ } ++ ++ private func getAllProfileFiles() throws -> [URL] { ++ let fileManager = FileManager.default ++ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) ++ return files.filter { $0.pathExtension == "json" } ++ } ++ ++ private func encodeProfile(_ profile: Profile) throws -> Data { ++ return try JSONEncoder().encode(profile) ++ } ++ ++ private func decodeProfile(from data: Data) throws -> Profile { ++ return try JSONDecoder().decode(Profile.self, from: data) ++ } ++ ++ public func sanitizeProfileName(_ name: String) -> String { ++ // Replace problematic characters ++ var sanitized = name.replacingOccurrences(of: ".", with: "_") ++ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") ++ ++ // Limit the name to 32 characters ++ if sanitized.count > 32 { ++ sanitized = String(sanitized.prefix(32)) ++ } ++ ++ return sanitized ++ } ++ ++ public func saveProfile(withName name: String) { ++ let sanitizedName = sanitizeProfileName(name) ++ ++ do { ++ try createProfileDirectoryIfNotExists() ++ ++ if let existingProfile = getProfileReference(withName: sanitizedName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let profile = Profile( ++ name: sanitizedName, ++ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ carbRatioSchedule: therapySettings.carbRatioSchedule!, ++ basalRateSchedule: therapySettings.basalRateSchedule!, ++ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ sortOrder: -1 ++ ) ++ ++ let jsonData = try encodeProfile(profile) ++ ++ let dateFormatter = DateFormatter() ++ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" ++ let fileName = dateFormatter.string(from: Date()) + ".json" ++ let fileURL = profilesDirectory.appendingPathComponent(fileName) ++ ++ try jsonData.write(to: fileURL) ++ setCurrentProfile(name: sanitizedName, syncChange: true) ++ ++ self.loadProfiles() ++ } catch { ++ print("An error occurred while saving profile: \(error)") ++ } ++ } ++ ++ public func renameProfile(oldName: String, newName: String) { ++ let sanitizedProfileName = sanitizeProfileName(newName) ++ ++ if currentProfileName == oldName { ++ setCurrentProfile(name: sanitizedProfileName, syncChange: true) ++ } ++ ++ do { ++ guard let profileReference = getProfileReference(withName: oldName) else { ++ print("Profile with old name not found.") ++ return ++ } ++ ++ let profile = try getProfile(from: profileReference) ++ ++ let newProfile = Profile( ++ name: sanitizedProfileName, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ ++ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ ++ try jsonData.write(to: fileURL) ++ ++ self.loadProfiles() ++ triggerProfileSync() ++ } catch { ++ print("An error occurred while renaming profile: \(error)") ++ } ++ } ++ ++ public func updateProfilesOrder() { ++ for (index, profileReference) in profiles.enumerated() { ++ do { ++ var profile = try getProfile(from: profileReference) ++ let newProfile = Profile( ++ name: profile.name, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: index ++ ) ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try jsonData.write(to: fileURL) ++ ++ } catch { ++ print("An error occurred while updating profile order: \(error)") ++ } ++ } ++ loadProfiles() ++ } ++ ++ public func loadProfiles() { ++ do { ++ let profileFiles = try getAllProfileFiles() ++ ++ var newProfiles = [ProfileReference]() ++ for fileURL in profileFiles { ++ let data = try Data(contentsOf: fileURL) ++ let profile = try decodeProfile(from: data) ++ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) ++ newProfiles.append(profileRef) ++ } ++ ++ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } ++ ++ self.profiles = newProfiles ++ } catch { ++ print("An error occurred while loading profiles: \(error)") ++ } ++ } ++ ++ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { ++ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && ++ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && ++ therapySettings.basalRateSchedule == profile.basalRateSchedule && ++ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { ++ self.setCurrentProfile(name: profile.name, syncChange: true) ++ completion(.success) ++ } ++ else ++ { ++ do { ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ switch result { ++ case .success(let syncedSchedule): ++ DispatchQueue.main.async { ++ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter ++ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) ++ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) ++ self?.saveBasalRates(basalRates: syncedSchedule) ++ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ print("New profile loaded") ++ self?.setCurrentProfile(name: profile.name, syncChange: false) ++ completion(.success) ++ } ++ case .failure(let error): ++ print("An error occurred while syncing basal rates: \(error)") ++ completion(.failure(error)) ++ } ++ }) ++ } ++ } ++ } ++ ++ func getProfileReference(withName name: String) -> ProfileReference? { ++ let sanitizedInputName = sanitizeProfileName(name) ++ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) ++ } ++ ++ public func removeProfile(profile: Profile) { ++ guard let profileReference = getProfileReference(withName: profile.name) else { ++ print("No ProfileReference found for given Profile") ++ return ++ } ++ ++ removeProfile(profileReference: profileReference) ++ if getCurrentProfileName() == profile.name { ++ clearCurrentProfile() ++ } else { ++ triggerProfileSync() ++ } ++ } ++ ++ public func removeProfile(profileReference: ProfileReference) { ++ do { ++ let fileManager = FileManager.default ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try fileManager.removeItem(at: fileURL) ++ loadProfiles() ++ } catch { ++ print("An error occurred while removing profile: \(error)") ++ } ++ } ++ ++ func doesProfileExist(withName name: String) -> Bool { ++ return profiles.contains(where: { $0.name == name }) ++ } ++ ++ public func getProfile(from profileReference: ProfileReference) throws -> Profile { ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ let data = try Data(contentsOf: fileURL) ++ let getProfile = try decodeProfile(from: data) ++ return getProfile ++ } ++ ++ /// The saved profile named `name`, or nil when there is none. ++ public func savedProfile(named name: String) throws -> Profile? { ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ return nil ++ } ++ return try getProfile(from: profileReference) ++ } ++ ++ public func validateProfile(_ profile: Profile) -> Result { ++ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { ++ return .failure(.fileLoadError) ++ } ++ ++ // Checking Correction Range Schedule ++ for item in profile.correctionRange.items { ++ let minValue = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) ++ let maxValue = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) ++ ++ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { ++ return .failure(.correctionRangeError) ++ } ++ } ++ ++ // Checking Insulin Sensitivity Schedule ++ for item in profile.insulinSensitivitySchedule.items { ++ let value = LoopQuantity(unit: .milligramsPerDeciliterPerInternationalUnit, doubleValue: item.value) ++ ++ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { ++ return .failure(.insulinSensitivityError) ++ } ++ } ++ ++ // Checking Carb Ratio Schedule ++ for item in profile.carbRatioSchedule.items { ++ let value = LoopQuantity(unit: .gramsPerUnit, doubleValue: item.value) ++ ++ if !Guardrail.carbRatio.absoluteBounds.contains(value) { ++ return .failure(.carbRatioError) ++ } ++ } ++ ++ // Checking Basal Rate Schedule ++ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { ++ for item in profile.basalRateSchedule.items { ++ let value = item.value ++ ++ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { ++ return .failure(.basalRateError) ++ } ++ } ++ } else { ++ return .failure(.maxBasalRateNotSet) ++ } ++ ++ return .success(()) ++ } ++ ++ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value.minValue - item2.value.minValue) > 0.01 || ++ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value - item2.value) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ public func isCurrentProfileOutOfSync() -> Bool { ++ guard let currentProfileName = self.currentProfileName, ++ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), ++ let profile = try? getProfile(from: currentProfileReference), ++ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: LoopUnit.milligramsPerDeciliter), ++ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: LoopUnit.milligramsPerDeciliter) else { ++ return false // No current profile set or profile not found or schedules not available ++ } ++ ++ var isOutOfSync = false ++ ++ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: LoopUnit.milligramsPerDeciliter)!) { ++ print("Glucose target range schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ print("Carb ratio schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ print("Basal rate schedule differs") ++ isOutOfSync = true ++ } ++ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: LoopUnit.milligramsPerDeciliter)!) { ++ print("Insulin sensitivity schedule differs") ++ isOutOfSync = true ++ } ++ ++ return isOutOfSync ++ } ++} ++ ++extension Profile { ++ /// Passes the schedules this profile activates to `check`. ++ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { ++ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +new file mode 100644 +index 00000000..a48fa667 +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +@@ -0,0 +1,86 @@ ++// ++// ProfileViewModel.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import Combine ++import LoopKit ++import HealthKit ++import SwiftUI ++ ++public class ProfileViewModel: ObservableObject { ++ ++ @Published public var therapySettings: TherapySettings ++ @Published public var profiles = [ProfileReference]() ++ @Published public var currentProfileName: String? { ++ didSet { ++ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") ++ } ++ } ++ ++ internal weak var delegate: TherapySettingsViewModelDelegate? ++ ++ public init(therapySettings: TherapySettings, ++ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, ++ sensitivityOverridesEnabled: Bool = false, ++ adultChildInsulinModelSelectionEnabled: Bool = false, ++ prescription: Prescription? = nil, ++ delegate: TherapySettingsViewModelDelegate? = nil) { ++ self.therapySettings = therapySettings ++ self.delegate = delegate ++ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") ++ self.loadProfiles() ++ } ++ ++ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return delegate?.pumpSupportedIncrements() ++ } ++} ++ ++public struct Profile: Codable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ var sortOrder: Int? ++} ++ ++public struct ProfileReference: Codable, Equatable { ++ var name: String ++ var fileName: String ++ var sortOrder: Int? ++} ++ ++ ++// MARK: Saving ++extension ProfileViewModel { ++ ++ public func saveCorrectionRange(range: GlucoseRangeSchedule) { ++ therapySettings.glucoseTargetRangeSchedule = range ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveBasalRates(basalRates: BasalRateSchedule) { ++ therapySettings.basalRateSchedule = basalRates ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { ++ therapySettings.carbRatioSchedule = carbRatioSchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { ++ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func triggerProfileSync() { ++ delegate?.updateCurrentProfileName() ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +index 81be1865..913c82ac 100644 +--- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift ++++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { + func syncDeliveryLimits(deliveryLimits: DeliveryLimits) async throws -> DeliveryLimits + func saveCompletion(therapySettings: TherapySettings) + func pumpSupportedIncrements() -> PumpSupportedIncrements? ++ func updateCurrentProfileName() + } + + @Observable +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +new file mode 100644 +index 00000000..446910e4 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +@@ -0,0 +1,79 @@ ++// ++// NewProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct NewProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var newProfileName: String ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("New Profile") ++ .font(.headline) ++ }, ++ trailing: { addButton } ++ ) ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var addButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Add") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +new file mode 100644 +index 00000000..3566055d +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +@@ -0,0 +1,334 @@ ++// ++// ProfilePreviewView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-23. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++import HealthKit ++import LoopKit ++import LoopAlgorithm ++ ++enum ActiveAlert { ++ case load ++ case delete ++ case error ++} ++ ++struct ProfilePreviewView: View { ++ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference ++ @ObservedObject var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @Environment(\.presentationMode) var presentationMode ++ var profile: Profile ++ ++ @State private var showingAlert = false ++ @State private var activeAlert: ActiveAlert = .load ++ @State private var errorText: String? ++ @State private var newProfileName: String = "" ++ @State private var isRenamingProfile = false ++ @State private var shouldDismissSelf: Bool = false ++ ++ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } ++ ++ var body: some View { ++ ZStack { ++ ScrollView { ++ VStack(alignment: .leading) { ++ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ .font(.body) ++ .foregroundColor(.primary) ++ .padding([.leading, .trailing]) ++ .fixedSize(horizontal: false, vertical: true) ++ .textCase(nil) ++ ++ profileCardStack ++ ++ Button(action: { ++ activeAlert = .load ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Load") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.blue) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ isRenamingProfile = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Rename") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.orange) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ activeAlert = .delete ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Delete") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.red) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ } ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .navigationTitle(Text(profile.name)) ++ .alert(isPresented: $showingAlert) { ++ switch activeAlert { ++ case .load: ++ return Alert( ++ title: Text("Load Profile"), ++ message: Text("Do you want to load the profile \(profile.name)?"), ++ primaryButton: .default(Text("Yes"), action: { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .delete: ++ return Alert( ++ title: Text("Delete Profile"), ++ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), ++ primaryButton: .destructive(Text("Delete"), action: { ++ viewModel.removeProfile(profile: profile) ++ self.presentationMode.wrappedValue.dismiss() ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .error: ++ return Alert( ++ title: Text("Error loading profile"), ++ message: Text(errorText ?? "Unknown error"), ++ dismissButton: .default(Text("OK")) ++ ) ++ } ++ } ++ if isRenamingProfile { ++ // Some hacks here to mimic Alert behaviour. ++ // This function should be migrateed to Alert when iOS 16 is required. ++ Color.black.opacity(0.5) ++ .edgesIgnoringSafeArea(.all) ++ .onTapGesture { ++ isRenamingProfile = false ++ } ++ ++ RenameProfileEditor( ++ isPresented: $isRenamingProfile, ++ currentProfileName: profile.name, ++ viewModel: viewModel, ++ shouldDismissParent: $shouldDismissSelf ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ .offset(y: -22) ++ } ++ } ++ .onChange(of: shouldDismissSelf) { shouldDismiss in ++ if shouldDismiss { ++ self.presentationMode.wrappedValue.dismiss() ++ } ++ } ++ } ++ ++ private var profileCardStack: CardStack { ++ var cards: [Card] = [] ++ ++ cards.append(correctionRangeSection) ++ cards.append(carbRatioSection) ++ cards.append(basalRatesSection) ++ cards.append(insulinSensitivitiesSection) ++ ++ return CardStack(cards: cards) ++ } ++ ++ private var correctionRangeSection: Card { ++ card(for: .glucoseTargetRange) { ++ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleRangeItem(time: items[index].startTime, ++ range: items[index].value, ++ unit: glucoseUnit, ++ guardrail: .correctionRange) ++ } ++ } ++ } ++ } ++ ++ private var carbRatioSection: Card { ++ card(for: .carbRatio) { ++ SectionDivider() ++ let items = profile.carbRatioSchedule.items ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .gramsPerUnit, ++ guardrail: .carbRatio) ++ } ++ } ++ } ++ ++ private var basalRatesSection: Card { ++ card(for: .basalRate) { ++ let items = profile.basalRateSchedule.items ++ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates ++ { ++ SectionDivider() ++ let total = profile.basalRateSchedule.total() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .internationalUnitsPerHour, ++ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) ++ } ++ HStack { ++ Text("Total") ++ .bold() ++ .foregroundColor(.primary) ++ Spacer() ++ Text(String(format: "%.2f ",total)) ++ .foregroundColor(.primary) + ++ Text("U/day") ++ .foregroundColor(.secondary) ++ } ++ } ++ } ++ } ++ ++ private var insulinSensitivitiesSection: Card { ++ card(for: .insulinSensitivity) { ++ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items ++ { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: sensitivityUnit, ++ guardrail: .insulinSensitivity) ++ } ++ } ++ } ++ } ++} ++ ++ ++struct SectionWithContent: View where Content: View { ++ let title: String ++ let content: Content ++ ++ init(title: String, ++ content: () -> Content) ++ { ++ self.title = title ++ self.content = content() ++ } ++ ++ public var body: some View { ++ Section { ++ Text(title) ++ .bold() ++ .frame(maxWidth: .infinity, alignment: .leading) ++ content ++ } ++ } ++} ++ ++// MARK: Utilities ++extension ProfilePreviewView { ++ ++ private var glucoseUnit: LoopUnit { ++ displayGlucosePreference.unit ++ } ++ ++ private var sensitivityUnit: LoopUnit { ++ switch glucoseUnit { ++ case .milligramsPerDeciliter: ++ return .milligramsPerDeciliterPerInternationalUnit ++ case .millimolesPerLiter: ++ return .millimolesPerLiterPerInternationalUnit ++ default: ++ return .milligramsPerDeciliterPerInternationalUnit ++ } ++ } ++ ++ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { ++ Card { ++ SectionWithContent(title: therapySetting.title, ++ content: content) ++ } ++ } ++} ++ ++fileprivate struct SectionDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -16) ++ } ++} ++ ++fileprivate struct SettingsDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -8) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +new file mode 100644 +index 00000000..a1f35678 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +@@ -0,0 +1,172 @@ ++// ++// ProfileView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import AVFoundation ++import HealthKit ++import LoopKit ++import SwiftUI ++ ++public struct ProfileView: View { ++ @ObservedObject public var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @State private var newProfileName: String = "" ++ @State private var isAddingNewProfile = false ++ @State private var selectedProfileIndex: Int? = nil ++ @State private var refreshID = UUID() ++ ++ enum ActiveAlert: String, Identifiable { ++ case update, info ++ ++ var id: String { ++ return self.rawValue ++ } ++ } ++ @State private var activeAlert: ActiveAlert? ++ ++ public init(viewModel: ProfileViewModel) { ++ self.viewModel = viewModel ++ } ++ ++ private var dismissButton: some View { ++ Button(action: dismissAction) { ++ Text(LocalizedString("Done", comment: "Text for dismiss button")) ++ .bold() ++ } ++ } ++ ++ public var body: some View { ++ ZStack { ++ NavigationView { ++ ConfigurationPageScrollView( ++ content: { ++ VStack(alignment: .leading) { ++ ++ if !viewModel.profiles.isEmpty { ++ List { ++ ForEach(viewModel.profiles.indices, id: \.self) { index in ++ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { ++ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { ++ HStack { ++ if viewModel.currentProfileName == viewModel.profiles[index].name { ++ Image(systemName: "checkmark") ++ .foregroundColor(Color.blue) ++ } else { ++ // Add indentation to align with the checkmark for the active profile ++ Image(systemName: "checkmark") ++ .opacity(0) // Invisible ++ } ++ ++ Text(viewModel.profiles[index].name) ++ } ++ } ++ } ++ } ++ .onMove(perform: moveProfile) ++ }.id(refreshID) ++ ++ } else { ++ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") ++ .font(.subheadline) ++ .foregroundColor(.secondary) ++ .padding([.leading, .trailing]) ++ .multilineTextAlignment(.leading) ++ .lineLimit(nil) ++ .textCase(nil) ++ } ++ } ++ }, ++ actionArea: { EmptyView() } // no action area in this case ++ ) ++ .navigationBarItems( ++ leading: HStack { ++ Button(action: { withAnimation { isAddingNewProfile = true } }) { ++ Image(systemName: "plus") ++ } ++ ++ Button(action: { activeAlert = .info }) { ++ Image(systemName: "info.circle") ++ .resizable() ++ .frame(width: 24, height: 24) ++ } ++ }, ++ trailing: dismissButton ++ ) ++ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .onAppear { ++ if viewModel.isCurrentProfileOutOfSync() { ++ activeAlert = .update ++ } ++ } ++ ++ .alert(item: $activeAlert) { alertType in ++ switch alertType { ++ case .update: ++ return Alert( ++ title: Text("Profile Outdated"), ++ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), ++ primaryButton: .default(Text("Update")) { ++ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") ++ }, ++ secondaryButton: .cancel() ++ ) ++ case .info: ++ return Alert(title: Text("Information"), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ dismissButton: .default(Text("Got it!"))) ++ } ++ } ++ ++ if isAddingNewProfile { ++ DarkenedOverlay() ++ ++ NewProfileEditor( ++ isPresented: $isAddingNewProfile, ++ newProfileName: newProfileName, ++ viewModel: viewModel ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ } ++ } ++ } ++ ++ func moveProfile(from source: IndexSet, to destination: Int) { ++ viewModel.profiles.move(fromOffsets: source, toOffset: destination) ++ refreshID = UUID() ++ viewModel.updateProfilesOrder() ++ } ++} ++ ++struct ProfileView_Previews: PreviewProvider { ++ static let preview_glucoseScheduleItems = [ ++ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), ++ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), ++ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) ++ ] ++ ++ static let preview_therapySettings = TherapySettings( ++ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), ++ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), ++ unit: .milligramsPerDeciliter), ++ maximumBolus: 4, ++ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), ++ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: .milligramsPerDeciliterPerInternationalUnit, dailyItems: []), ++ carbRatioSchedule: nil, ++ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) ++ ++ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] ++ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] ++ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] ++ ++ static var previews: some View { ++ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +new file mode 100644 +index 00000000..8fd26665 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +@@ -0,0 +1,92 @@ ++// ++// RenameProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-09-07. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct RenameProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var currentProfileName: String ++ @State var newProfileName: String = "" ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ @Binding var shouldDismissParent: Bool ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("Rename Profile") ++ .font(.subheadline) ++ ++ }, ++ trailing: { renameButton } ++ ) ++ .onAppear { ++ self.newProfileName = currentProfileName ++ } ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var renameButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ guard newProfileName != currentProfileName else { ++ self.isPresented = false ++ return ++ } ++ ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Rename") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +Submodule LoopOnboarding contains modified content +diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +index 1ddc82b..54421a1 100644 +--- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift ++++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +@@ -381,6 +381,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { + maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount + ) + } ++ ++ func updateCurrentProfileName() { ++ } + } + + +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index ea85f5a..eb8f03d 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -23,6 +23,10 @@ + A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; +@@ -163,6 +167,10 @@ + A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; + A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; +@@ -279,6 +287,7 @@ + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, + A934B6172358F49300949C8B /* StoredSettings.swift */, ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, + A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, + A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, +@@ -338,6 +347,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -380,6 +390,7 @@ + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, + ); + path = Notifications; + sourceTree = ""; +@@ -428,6 +439,7 @@ + A9AA6E5729EB06AA008FFA78 /* Actions */ = { + isa = PBXGroup; + children = ( ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, +@@ -765,6 +777,9 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, + A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, + C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, + ); +@@ -781,6 +796,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, + A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +new file mode 100644 +index 0000000..588ded1 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,104 @@ ++// ++// SavedProfile.swift ++// NightscoutServiceKit ++// ++ ++import CryptoKit ++import Foundation ++import LoopAlgorithm ++import LoopKit ++import NightscoutKit ++ ++/// A profile saved with the Profiles settings screen, read from the file it was saved to. ++struct SavedProfile: Decodable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ ++ private static var directory: URL { ++ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") ++ } ++ ++ static func loadAll() -> [SavedProfile] { ++ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] ++ return files ++ .filter { $0.pathExtension == "json" } ++ .compactMap { try? Data(contentsOf: $0) } ++ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } ++ } ++ ++ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. ++ static func nightscoutStore(unit: LoopUnit) -> [String: ProfileSet.Profile] { ++ var store: [String: ProfileSet.Profile] = [:] ++ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. ++ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { ++ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) ++ } ++ return store ++ } ++ ++ /// True when this profile uploads with this fingerprint, in either glucose unit. ++ func matches(fingerprint: String) -> Bool { ++ [LoopUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { ++ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint ++ } ++ } ++ ++ func nightscoutProfile(unit: LoopUnit) -> ProfileSet.Profile? { ++ guard let correctionRange = correctionRange.schedule(for: unit), ++ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) ++ else { ++ return nil ++ } ++ return ProfileSet.Profile( ++ timezone: basalRateSchedule.timeZone, ++ dia: .hours(6), ++ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, ++ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, ++ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) ++ } ++} ++ ++extension ProfileSet.Profile { ++ /// Followers compute the same value from the uploaded profile and send it with a switch command, ++ /// so Loop only activates the values the follower previewed. ++ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` ++ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. ++ var remoteFingerprint: String { ++ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { ++ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") ++ } ++ let canonical = [ ++ "basal=" + items(basal), ++ "carbratio=" + items(carbratio), ++ "sens=" + items(sensitivity), ++ "target_low=" + items(targetLow), ++ "target_high=" + items(targetHigh), ++ ].joined(separator: ";") ++ let digest = SHA256.hash(data: Data(canonical.utf8)) ++ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index e7bb1ad..1b43f76 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -180,7 +180,7 @@ extension StoredDosingDecision { + enacted: automaticDoseDecision?.loopStatusEnacted, + failureReason: automaticDoseDecision?.loopStatusFailureReason, + failureCode: automaticDoseDecision?.errors.first?.id, +- testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported], ++ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]], + algorithmExperiments: (automaticDoseDecision?.algorithmExperiments ?? algorithmExperiments)?.map { $0.jsonObject }), + overrideStatus: overrideStatus) + } +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +index 3142223..ae6fb9a 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +@@ -93,12 +93,21 @@ extension StoredSettings { + return nil + } + ++ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) ++ store["Default"] = profile ++ var defaultProfile = "Default" ++ ++ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { ++ defaultProfile = name ++ store[name] = profile ++ } ++ + return ProfileSet( + startDate: date, + units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), + enteredBy: "Loop", +- defaultProfile: "Default", +- store: ["Default": profile], ++ defaultProfile: defaultProfile, ++ store: store, + settings: loopSettings, + syncIdentifier: syncIdentifier.uuidString, + isAPNSProduction: Bundle.main.isAPNSProductionEnvironment, +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index c3fc48b..64a668c 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -474,6 +474,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + success = true + message = "Override '\(overrideCommand.name)' enacted successfully" + ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ success = true ++ message = "Profile '\(profileSwitchCommand.name)' activated" ++ + case .cancelTemporaryOverride: + commandType = .cancelOverride + try await serviceDelegate.cancelRemoteOverride() +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index 91ad56c..29d3563 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -9,6 +9,7 @@ + import Foundation + + public enum Action: Codable { ++ case profileSwitch(ProfileSwitchAction) + case temporaryScheduleOverride(OverrideAction) + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) +@@ -34,6 +35,8 @@ public enum Action: Codable { + return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") + case .temporaryScheduleOverride: + return LocalizedString("Override", comment: "The remote action name for Override") ++ case .profileSwitch: ++ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") + } + } + +@@ -54,6 +57,8 @@ public enum Action: Codable { + return "" + case .temporaryScheduleOverride(let overrideAction): + return overrideAction.name ++ case .profileSwitch(let profileSwitchAction): ++ return profileSwitchAction.name + } + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +new file mode 100644 +index 0000000..ce03ded +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +@@ -0,0 +1,17 @@ ++// ++// ProfileSwitchAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct ProfileSwitchAction: Codable { ++ ++ public let name: String ++ public let fingerprint: String ++ ++ public init(name: String, fingerprint: String) { ++ self.name = name ++ self.fingerprint = fingerprint ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 6092bc9..aabb47f 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -49,6 +49,7 @@ class RemoteNotificationResponseManager { + case carbsEdit = "carbs_edit" + case override = "override" + case cancelOverride = "cancel_override" ++ case profileSwitch = "profile_switch" + } + + func sendResponseNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +new file mode 100644 +index 0000000..ebcd6ef +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -0,0 +1,50 @@ ++// ++// ProfileSwitchRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Switches to a saved profile. `expiration` is required: decoding fails without it. ++public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ public let profileName: String ++ public let fingerprint: String ++ public let remoteAddress: String ++ public let expirationDate: Date ++ public let sentAt: Date ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ public var expiration: Date? { ++ return expirationDate ++ } ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expirationDate = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification[commandKey] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index be43210..1ea8b96 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -118,6 +118,8 @@ extension Dictionary { + return try OverrideRemoteNotification(dictionary: self) + } else if OverrideCancelRemoteNotification.includedInNotification(self) { + return try OverrideCancelRemoteNotification(dictionary: self) ++ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { ++ return try ProfileSwitchRemoteNotification(dictionary: self) + } else { + throw RemoteNotificationError.unhandledNotification(self) + } +diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..2fcec94 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +@@ -0,0 +1,71 @@ ++// ++// ProfileSwitchRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "sent-at": "2023-02-25T20:46:35.778Z", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ] ++ ++ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) ++ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ ++ XCTAssertEqual(profileSwitch.profileName, "Sport") ++ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertTrue(profileSwitch.otpValidationRequired()) ++ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { ++ return XCTFail("Expected a profile switch action") ++ } ++ XCTAssertEqual(action.name, "Sport") ++ } ++ ++ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { ++ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ let profile = ProfileSet.Profile( ++ timezone: TimeZone(secondsFromGMT: 3600)!, ++ dia: .hours(6), ++ sensitivity: items([(0, 45)]), ++ carbratio: items([(0, 10)]), ++ basal: items([(0, 0.85), (21600, 1.1)]), ++ targetLow: items([(0, 100)]), ++ targetHigh: items([(0, 110)]), ++ units: "mg/dL") ++ ++ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") ++ } ++} ++ ++private extension ISO8601DateFormatter { ++ static let fractional: ISO8601DateFormatter = { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ }() ++} +diff --git a/profiles.loopcustomization b/profiles.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/profiles/nextdev_profiles_remote_window.patch b/profiles/nextdev_profiles_remote_window.patch new file mode 100644 index 0000000..9b7b2a7 --- /dev/null +++ b/profiles/nextdev_profiles_remote_window.patch @@ -0,0 +1,1996 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift +index 3c9d61cb..5591c9a5 100644 +--- a/Loop/Loop/Managers/DeviceDataManager.swift ++++ b/Loop/Loop/Managers/DeviceDataManager.swift +@@ -1407,6 +1407,13 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { + return try await pumpManager?.syncDeliveryLimits(limits: deliveryLimits) ?? deliveryLimits + } + ++ func updateCurrentProfileName() { ++ NotificationCenter.default.post(name: .LoopDataUpdated, ++ object: nil, ++ userInfo: [LoopDataManager.LoopUpdateContextKey: LoopUpdateContext.preferences.rawValue]) ++ settingsManager.storeSettings() ++ } ++ + func saveCompletion(therapySettings: TherapySettings) { + settingsManager.mutateLoopSettings { settings in + settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule +diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift +index dec6dff3..0b8248c2 100644 +--- a/Loop/Loop/Managers/LoopAppManager.swift ++++ b/Loop/Loop/Managers/LoopAppManager.swift +@@ -401,6 +401,7 @@ class LoopAppManager: NSObject { + + dosingDecisionStore.delegate = deviceDataManager + remoteDataServicesManager.delegate = deviceDataManager ++ servicesManager.therapySettingsViewModelDelegate = deviceDataManager + + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceDataManager.deviceLog, alertManager.alertStore] + criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index 18f5e18d..1966dcde 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -1705,7 +1705,7 @@ extension LoopDataManager: ManualDoseViewModelDelegate { + var settings: StoredSettings { + settingsProvider.settings + } +- ++ + var scheduleOverride: TemporaryScheduleOverride? { + temporaryPresetsManager.scheduleOverride + } +diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift +index 42eff19f..c4d0f32e 100644 +--- a/Loop/Loop/Managers/NotificationManager.swift ++++ b/Loop/Loop/Managers/NotificationManager.swift +@@ -298,6 +298,38 @@ extension NotificationManager { + UNUserNotificationCenter.current().add(request) + } + ++ static func sendRemoteProfileNotification(name: String) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfile", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ static func sendRemoteProfileFailureNotification(name: String, error: Error) { ++ let notification = UNMutableNotificationContent() ++ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ ++ private static func remoteProfileNotificationBody(name: String) -> String { ++ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ } ++ + private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { + return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + } +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index e81a230b..8e7c86ef 100644 +--- a/Loop/Loop/Managers/ServicesManager.swift ++++ b/Loop/Loop/Managers/ServicesManager.swift +@@ -30,6 +30,7 @@ class ServicesManager { + weak var servicesManagerDelegate: ServicesManagerDelegate? + weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? + weak var supportManager: SupportManager? ++ weak var therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? + + private var services = [Service]() + +@@ -432,3 +433,58 @@ extension Service { + ] + } + } ++ ++// MARK: - RemoteProfileActionDelegate ++ ++extension ServicesManager: RemoteProfileActionDelegate { ++ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { ++ do { ++ guard let therapySettingsViewModelDelegate else { ++ throw RemoteProfileActionError.unavailable ++ } ++ let viewModel = ProfileViewModel(therapySettings: settingsManager.therapySettings, delegate: therapySettingsViewModelDelegate) ++ guard therapySettingsViewModelDelegate.pumpSupportedIncrements() != nil else { ++ throw RemoteProfileActionError.noPump ++ } ++ guard let profile = try viewModel.savedProfile(named: name) else { ++ throw RemoteProfileActionError.profileNotFound(name) ++ } ++ try profile.verify(with: verify) ++ if case .failure(let error) = viewModel.validateProfile(profile) { ++ throw error ++ } ++ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ viewModel.triggerProfileSync() ++ NotificationManager.sendRemoteProfileNotification(name: name) ++ } catch { ++ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ throw error ++ } ++ } ++ ++ enum RemoteProfileActionError: LocalizedError { ++ case unavailable ++ case noPump ++ case profileNotFound(String) ++ ++ var errorDescription: String? { ++ switch self { ++ case .unavailable: ++ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: profile view model delegate missing.") ++ case .noPump: ++ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") ++ case .profileNotFound(let name): ++ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) ++ } ++ } ++ } ++} +diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift +index 1faefbc5..737acf28 100644 +--- a/Loop/Loop/Views/SettingsView.swift ++++ b/Loop/Loop/Views/SettingsView.swift +@@ -55,6 +55,7 @@ struct SettingsView: View { + + case favoriteFoods + case presets ++ case profiles + } + } + +@@ -155,6 +156,9 @@ struct SettingsView: View { + } + case .favoriteFoods: + FavoriteFoodsView(insightsDelegate: viewModel.favoriteFoodInsightsDelegate) ++ case .profiles: ++ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), ++ delegate: self.viewModel.therapySettingsViewModelDelegate)) + } + } + .environmentObject(displayGlucosePreference) +@@ -416,6 +420,11 @@ extension SettingsView { + .accessibilityIdentifier("button_TherapySettings") + } + ++ LargeButton(action: { sheet = .profiles }, ++ includeArrow: true, ++ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), ++ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), ++ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) + ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in + item.view + } +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj +index cc88ad17..c543b73a 100644 +--- a/LoopKit/LoopKit.xcodeproj/project.pbxproj ++++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj +@@ -7,6 +7,12 @@ + objects = { + + /* Begin PBXBuildFile section */ ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; + 9D3D2DA3B9824EF48981F0E4 /* StoredInsulinModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CAB9E826A3254800A57273 /* StoredInsulinModel.swift */; }; + 485ACF65C30744BFBC979CEF /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A985464E251449FE0099C1A6 /* NotificationSettings.swift */; }; + 2383C3D7967B4602B8EB4A5B /* SettingsObject+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = A912BE2A245B9E8600CBE199 /* SettingsObject+CoreDataProperties.swift */; }; +@@ -1065,6 +1071,12 @@ + /* End PBXCopyFilesBuildPhase section */ + + /* Begin PBXFileReference section */ ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; ++ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; + D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; + 142CB75C2A60C01E0075748A /* ListButtonStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListButtonStyle.swift; sourceTree = ""; }; + 142CB7602A60C0440075748A /* FavoriteFood.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FavoriteFood.swift; sourceTree = ""; }; +@@ -2971,6 +2983,8 @@ + B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, + B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, + 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, ++ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, + ); + path = ViewModels; + sourceTree = ""; +@@ -3146,6 +3160,10 @@ + E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, + 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, + 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, ++ DDAF746629F422C600719F0A /* ProfileView.swift */, ++ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, ++ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, ++ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, + ); + path = "Settings Editors"; + sourceTree = ""; +@@ -3815,6 +3833,12 @@ + E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, + B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, + 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, ++ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, ++ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, ++ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, ++ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, ++ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, ++ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, + 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, + 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, + 892A5DA02231E130008961AB /* CompletionNotifying.swift in Sources */, +diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +index 90d64a0f..b82f71ed 100644 +--- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift ++++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift +@@ -8,6 +8,12 @@ + + import Foundation + ++/// Switches to a saved therapy profile on a remote command. ++public protocol RemoteProfileActionDelegate: AnyObject { ++ /// `verify` receives the schedules about to be activated and throws to reject them. ++ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws ++} ++ + public protocol RemoteActionDelegate: AnyObject { + func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws + func cancelRemoteOverride() async throws +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +new file mode 100644 +index 00000000..0a287cac +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift +@@ -0,0 +1,427 @@ ++// ++// ProfileViewModel+FileManagement.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import LoopKit ++import HealthKit ++import LoopAlgorithm ++ ++public enum ProfileValidationError: Error, LocalizedError { ++ case fileLoadError ++ case unsupportedIncrement ++ case correctionRangeError ++ case insulinSensitivityError ++ case carbRatioError ++ case basalRateError ++ case maxBasalRateNotSet ++ ++ public var errorDescription: String? { ++ switch self { ++ case .fileLoadError: ++ return "Unable to load the profile file." ++ case .unsupportedIncrement: ++ return "The profile contains unsupported increments." ++ case .correctionRangeError: ++ return "Correction Range values are out of bounds." ++ case .insulinSensitivityError: ++ return "Insulin Sensitivity values are out of bounds." ++ case .carbRatioError: ++ return "Carb Ratio values are out of bounds." ++ case .basalRateError: ++ return "Basal Rate values are out of bounds." ++ case .maxBasalRateNotSet: ++ return "Maximum Basal Rate is not set." ++ } ++ } ++} ++ ++public enum LoadProfileResult { ++ case success ++ case failure(Error) ++} ++ ++// MARK: File management ++extension ProfileViewModel { ++ private func setCurrentProfile(name: String, syncChange: Bool = false) { ++ let sanitizedProfileName = sanitizeProfileName(name) ++ currentProfileName = sanitizedProfileName ++ if syncChange { ++ triggerProfileSync() ++ } ++ } ++ ++ private func clearCurrentProfile() { ++ currentProfileName = nil ++ triggerProfileSync() ++ } ++ ++ private func getCurrentProfileName() -> String? { ++ return currentProfileName ++ } ++ ++ private var profilesDirectory: URL { ++ let fileManager = FileManager.default ++ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] ++ return documentsDirectory.appendingPathComponent("LoopProfile") ++ } ++ ++ private func createProfileDirectoryIfNotExists() throws { ++ let fileManager = FileManager.default ++ var isDir : ObjCBool = false ++ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { ++ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) ++ } else if !isDir.boolValue { ++ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) ++ } ++ } ++ ++ private func getAllProfileFiles() throws -> [URL] { ++ let fileManager = FileManager.default ++ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) ++ return files.filter { $0.pathExtension == "json" } ++ } ++ ++ private func encodeProfile(_ profile: Profile) throws -> Data { ++ return try JSONEncoder().encode(profile) ++ } ++ ++ private func decodeProfile(from data: Data) throws -> Profile { ++ return try JSONDecoder().decode(Profile.self, from: data) ++ } ++ ++ public func sanitizeProfileName(_ name: String) -> String { ++ // Replace problematic characters ++ var sanitized = name.replacingOccurrences(of: ".", with: "_") ++ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") ++ ++ // Limit the name to 32 characters ++ if sanitized.count > 32 { ++ sanitized = String(sanitized.prefix(32)) ++ } ++ ++ return sanitized ++ } ++ ++ public func saveProfile(withName name: String) { ++ let sanitizedName = sanitizeProfileName(name) ++ ++ do { ++ try createProfileDirectoryIfNotExists() ++ ++ if let existingProfile = getProfileReference(withName: sanitizedName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let profile = Profile( ++ name: sanitizedName, ++ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ carbRatioSchedule: therapySettings.carbRatioSchedule!, ++ basalRateSchedule: therapySettings.basalRateSchedule!, ++ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, ++ sortOrder: -1 ++ ) ++ ++ let jsonData = try encodeProfile(profile) ++ ++ let dateFormatter = DateFormatter() ++ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" ++ let fileName = dateFormatter.string(from: Date()) + ".json" ++ let fileURL = profilesDirectory.appendingPathComponent(fileName) ++ ++ try jsonData.write(to: fileURL) ++ setCurrentProfile(name: sanitizedName, syncChange: true) ++ ++ self.loadProfiles() ++ } catch { ++ print("An error occurred while saving profile: \(error)") ++ } ++ } ++ ++ public func renameProfile(oldName: String, newName: String) { ++ let sanitizedProfileName = sanitizeProfileName(newName) ++ ++ if currentProfileName == oldName { ++ setCurrentProfile(name: sanitizedProfileName, syncChange: true) ++ } ++ ++ do { ++ guard let profileReference = getProfileReference(withName: oldName) else { ++ print("Profile with old name not found.") ++ return ++ } ++ ++ let profile = try getProfile(from: profileReference) ++ ++ let newProfile = Profile( ++ name: sanitizedProfileName, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ ++ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { ++ removeProfile(profileReference: existingProfile) ++ } ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ ++ try jsonData.write(to: fileURL) ++ ++ self.loadProfiles() ++ triggerProfileSync() ++ } catch { ++ print("An error occurred while renaming profile: \(error)") ++ } ++ } ++ ++ public func updateProfilesOrder() { ++ for (index, profileReference) in profiles.enumerated() { ++ do { ++ var profile = try getProfile(from: profileReference) ++ let newProfile = Profile( ++ name: profile.name, ++ correctionRange: profile.correctionRange, ++ carbRatioSchedule: profile.carbRatioSchedule, ++ basalRateSchedule: profile.basalRateSchedule, ++ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, ++ sortOrder: index ++ ) ++ ++ let jsonData = try encodeProfile(newProfile) ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try jsonData.write(to: fileURL) ++ ++ } catch { ++ print("An error occurred while updating profile order: \(error)") ++ } ++ } ++ loadProfiles() ++ } ++ ++ public func loadProfiles() { ++ do { ++ let profileFiles = try getAllProfileFiles() ++ ++ var newProfiles = [ProfileReference]() ++ for fileURL in profileFiles { ++ let data = try Data(contentsOf: fileURL) ++ let profile = try decodeProfile(from: data) ++ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) ++ newProfiles.append(profileRef) ++ } ++ ++ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } ++ ++ self.profiles = newProfiles ++ } catch { ++ print("An error occurred while loading profiles: \(error)") ++ } ++ } ++ ++ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { ++ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && ++ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && ++ therapySettings.basalRateSchedule == profile.basalRateSchedule && ++ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { ++ self.setCurrentProfile(name: profile.name, syncChange: true) ++ completion(.success) ++ } ++ else ++ { ++ do { ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ switch result { ++ case .success(let syncedSchedule): ++ DispatchQueue.main.async { ++ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter ++ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) ++ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) ++ self?.saveBasalRates(basalRates: syncedSchedule) ++ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ print("New profile loaded") ++ self?.setCurrentProfile(name: profile.name, syncChange: false) ++ completion(.success) ++ } ++ case .failure(let error): ++ print("An error occurred while syncing basal rates: \(error)") ++ completion(.failure(error)) ++ } ++ }) ++ } ++ } ++ } ++ ++ func getProfileReference(withName name: String) -> ProfileReference? { ++ let sanitizedInputName = sanitizeProfileName(name) ++ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) ++ } ++ ++ public func removeProfile(profile: Profile) { ++ guard let profileReference = getProfileReference(withName: profile.name) else { ++ print("No ProfileReference found for given Profile") ++ return ++ } ++ ++ removeProfile(profileReference: profileReference) ++ if getCurrentProfileName() == profile.name { ++ clearCurrentProfile() ++ } else { ++ triggerProfileSync() ++ } ++ } ++ ++ public func removeProfile(profileReference: ProfileReference) { ++ do { ++ let fileManager = FileManager.default ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ try fileManager.removeItem(at: fileURL) ++ loadProfiles() ++ } catch { ++ print("An error occurred while removing profile: \(error)") ++ } ++ } ++ ++ func doesProfileExist(withName name: String) -> Bool { ++ return profiles.contains(where: { $0.name == name }) ++ } ++ ++ public func getProfile(from profileReference: ProfileReference) throws -> Profile { ++ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) ++ let data = try Data(contentsOf: fileURL) ++ let getProfile = try decodeProfile(from: data) ++ return getProfile ++ } ++ ++ /// The saved profile named `name`, or nil when there is none. ++ public func savedProfile(named name: String) throws -> Profile? { ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ return nil ++ } ++ return try getProfile(from: profileReference) ++ } ++ ++ public func validateProfile(_ profile: Profile) -> Result { ++ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { ++ return .failure(.fileLoadError) ++ } ++ ++ // Checking Correction Range Schedule ++ for item in profile.correctionRange.items { ++ let minValue = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) ++ let maxValue = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) ++ ++ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { ++ return .failure(.correctionRangeError) ++ } ++ } ++ ++ // Checking Insulin Sensitivity Schedule ++ for item in profile.insulinSensitivitySchedule.items { ++ let value = LoopQuantity(unit: .milligramsPerDeciliterPerInternationalUnit, doubleValue: item.value) ++ ++ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { ++ return .failure(.insulinSensitivityError) ++ } ++ } ++ ++ // Checking Carb Ratio Schedule ++ for item in profile.carbRatioSchedule.items { ++ let value = LoopQuantity(unit: .gramsPerUnit, doubleValue: item.value) ++ ++ if !Guardrail.carbRatio.absoluteBounds.contains(value) { ++ return .failure(.carbRatioError) ++ } ++ } ++ ++ // Checking Basal Rate Schedule ++ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { ++ for item in profile.basalRateSchedule.items { ++ let value = item.value ++ ++ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { ++ return .failure(.basalRateError) ++ } ++ } ++ } else { ++ return .failure(.maxBasalRateNotSet) ++ } ++ ++ return .success(()) ++ } ++ ++ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value.minValue - item2.value.minValue) > 0.01 || ++ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { ++ guard a.items.count == b.items.count else { ++ return false ++ } ++ ++ for (item1, item2) in zip(a.items, b.items) { ++ if item1.startTime != item2.startTime || ++ abs(item1.value - item2.value) > 0.01 { ++ return false ++ } ++ } ++ ++ return true ++ } ++ ++ public func isCurrentProfileOutOfSync() -> Bool { ++ guard let currentProfileName = self.currentProfileName, ++ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), ++ let profile = try? getProfile(from: currentProfileReference), ++ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: LoopUnit.milligramsPerDeciliter), ++ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: LoopUnit.milligramsPerDeciliter) else { ++ return false // No current profile set or profile not found or schedules not available ++ } ++ ++ var isOutOfSync = false ++ ++ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: LoopUnit.milligramsPerDeciliter)!) { ++ print("Glucose target range schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ print("Carb ratio schedule differs") ++ isOutOfSync = true ++ } ++ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ print("Basal rate schedule differs") ++ isOutOfSync = true ++ } ++ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: LoopUnit.milligramsPerDeciliter)!) { ++ print("Insulin sensitivity schedule differs") ++ isOutOfSync = true ++ } ++ ++ return isOutOfSync ++ } ++} ++ ++extension Profile { ++ /// Passes the schedules this profile activates to `check`. ++ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { ++ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +new file mode 100644 +index 00000000..a48fa667 +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift +@@ -0,0 +1,86 @@ ++// ++// ProfileViewModel.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import Combine ++import LoopKit ++import HealthKit ++import SwiftUI ++ ++public class ProfileViewModel: ObservableObject { ++ ++ @Published public var therapySettings: TherapySettings ++ @Published public var profiles = [ProfileReference]() ++ @Published public var currentProfileName: String? { ++ didSet { ++ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") ++ } ++ } ++ ++ internal weak var delegate: TherapySettingsViewModelDelegate? ++ ++ public init(therapySettings: TherapySettings, ++ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, ++ sensitivityOverridesEnabled: Bool = false, ++ adultChildInsulinModelSelectionEnabled: Bool = false, ++ prescription: Prescription? = nil, ++ delegate: TherapySettingsViewModelDelegate? = nil) { ++ self.therapySettings = therapySettings ++ self.delegate = delegate ++ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") ++ self.loadProfiles() ++ } ++ ++ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return delegate?.pumpSupportedIncrements() ++ } ++} ++ ++public struct Profile: Codable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ var sortOrder: Int? ++} ++ ++public struct ProfileReference: Codable, Equatable { ++ var name: String ++ var fileName: String ++ var sortOrder: Int? ++} ++ ++ ++// MARK: Saving ++extension ProfileViewModel { ++ ++ public func saveCorrectionRange(range: GlucoseRangeSchedule) { ++ therapySettings.glucoseTargetRangeSchedule = range ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveBasalRates(basalRates: BasalRateSchedule) { ++ therapySettings.basalRateSchedule = basalRates ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { ++ therapySettings.carbRatioSchedule = carbRatioSchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { ++ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule ++ delegate?.saveCompletion(therapySettings: therapySettings) ++ } ++ ++ public func triggerProfileSync() { ++ delegate?.updateCurrentProfileName() ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +index 81be1865..913c82ac 100644 +--- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift ++++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift +@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { + func syncDeliveryLimits(deliveryLimits: DeliveryLimits) async throws -> DeliveryLimits + func saveCompletion(therapySettings: TherapySettings) + func pumpSupportedIncrements() -> PumpSupportedIncrements? ++ func updateCurrentProfileName() + } + + @Observable +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +new file mode 100644 +index 00000000..446910e4 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift +@@ -0,0 +1,79 @@ ++// ++// NewProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-19. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct NewProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var newProfileName: String ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("New Profile") ++ .font(.headline) ++ }, ++ trailing: { addButton } ++ ) ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var addButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.saveProfile(withName: self.newProfileName) ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Add") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +new file mode 100644 +index 00000000..3566055d +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift +@@ -0,0 +1,334 @@ ++// ++// ProfilePreviewView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-05-23. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++import HealthKit ++import LoopKit ++import LoopAlgorithm ++ ++enum ActiveAlert { ++ case load ++ case delete ++ case error ++} ++ ++struct ProfilePreviewView: View { ++ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference ++ @ObservedObject var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @Environment(\.presentationMode) var presentationMode ++ var profile: Profile ++ ++ @State private var showingAlert = false ++ @State private var activeAlert: ActiveAlert = .load ++ @State private var errorText: String? ++ @State private var newProfileName: String = "" ++ @State private var isRenamingProfile = false ++ @State private var shouldDismissSelf: Bool = false ++ ++ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } ++ ++ var body: some View { ++ ZStack { ++ ScrollView { ++ VStack(alignment: .leading) { ++ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ .font(.body) ++ .foregroundColor(.primary) ++ .padding([.leading, .trailing]) ++ .fixedSize(horizontal: false, vertical: true) ++ .textCase(nil) ++ ++ profileCardStack ++ ++ Button(action: { ++ activeAlert = .load ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Load") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.blue) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ isRenamingProfile = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Rename") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.orange) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ Button(action: { ++ activeAlert = .delete ++ showingAlert = true ++ }) { ++ HStack { ++ Spacer() ++ Text("Delete") ++ .foregroundColor(.white) ++ .padding() ++ Spacer() ++ } ++ } ++ .background(Color.red) ++ .cornerRadius(10) ++ .padding(.horizontal) ++ ++ } ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .navigationTitle(Text(profile.name)) ++ .alert(isPresented: $showingAlert) { ++ switch activeAlert { ++ case .load: ++ return Alert( ++ title: Text("Load Profile"), ++ message: Text("Do you want to load the profile \(profile.name)?"), ++ primaryButton: .default(Text("Yes"), action: { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .delete: ++ return Alert( ++ title: Text("Delete Profile"), ++ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), ++ primaryButton: .destructive(Text("Delete"), action: { ++ viewModel.removeProfile(profile: profile) ++ self.presentationMode.wrappedValue.dismiss() ++ }), ++ secondaryButton: .cancel() ++ ) ++ case .error: ++ return Alert( ++ title: Text("Error loading profile"), ++ message: Text(errorText ?? "Unknown error"), ++ dismissButton: .default(Text("OK")) ++ ) ++ } ++ } ++ if isRenamingProfile { ++ // Some hacks here to mimic Alert behaviour. ++ // This function should be migrateed to Alert when iOS 16 is required. ++ Color.black.opacity(0.5) ++ .edgesIgnoringSafeArea(.all) ++ .onTapGesture { ++ isRenamingProfile = false ++ } ++ ++ RenameProfileEditor( ++ isPresented: $isRenamingProfile, ++ currentProfileName: profile.name, ++ viewModel: viewModel, ++ shouldDismissParent: $shouldDismissSelf ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ .offset(y: -22) ++ } ++ } ++ .onChange(of: shouldDismissSelf) { shouldDismiss in ++ if shouldDismiss { ++ self.presentationMode.wrappedValue.dismiss() ++ } ++ } ++ } ++ ++ private var profileCardStack: CardStack { ++ var cards: [Card] = [] ++ ++ cards.append(correctionRangeSection) ++ cards.append(carbRatioSection) ++ cards.append(basalRatesSection) ++ cards.append(insulinSensitivitiesSection) ++ ++ return CardStack(cards: cards) ++ } ++ ++ private var correctionRangeSection: Card { ++ card(for: .glucoseTargetRange) { ++ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleRangeItem(time: items[index].startTime, ++ range: items[index].value, ++ unit: glucoseUnit, ++ guardrail: .correctionRange) ++ } ++ } ++ } ++ } ++ ++ private var carbRatioSection: Card { ++ card(for: .carbRatio) { ++ SectionDivider() ++ let items = profile.carbRatioSchedule.items ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .gramsPerUnit, ++ guardrail: .carbRatio) ++ } ++ } ++ } ++ ++ private var basalRatesSection: Card { ++ card(for: .basalRate) { ++ let items = profile.basalRateSchedule.items ++ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates ++ { ++ SectionDivider() ++ let total = profile.basalRateSchedule.total() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: .internationalUnitsPerHour, ++ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) ++ } ++ HStack { ++ Text("Total") ++ .bold() ++ .foregroundColor(.primary) ++ Spacer() ++ Text(String(format: "%.2f ",total)) ++ .foregroundColor(.primary) + ++ Text("U/day") ++ .foregroundColor(.secondary) ++ } ++ } ++ } ++ } ++ ++ private var insulinSensitivitiesSection: Card { ++ card(for: .insulinSensitivity) { ++ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items ++ { ++ SectionDivider() ++ ForEach(items.indices, id: \.self) { index in ++ if index > 0 { ++ SettingsDivider() ++ } ++ ScheduleValueItem(time: items[index].startTime, ++ value: items[index].value, ++ unit: sensitivityUnit, ++ guardrail: .insulinSensitivity) ++ } ++ } ++ } ++ } ++} ++ ++ ++struct SectionWithContent: View where Content: View { ++ let title: String ++ let content: Content ++ ++ init(title: String, ++ content: () -> Content) ++ { ++ self.title = title ++ self.content = content() ++ } ++ ++ public var body: some View { ++ Section { ++ Text(title) ++ .bold() ++ .frame(maxWidth: .infinity, alignment: .leading) ++ content ++ } ++ } ++} ++ ++// MARK: Utilities ++extension ProfilePreviewView { ++ ++ private var glucoseUnit: LoopUnit { ++ displayGlucosePreference.unit ++ } ++ ++ private var sensitivityUnit: LoopUnit { ++ switch glucoseUnit { ++ case .milligramsPerDeciliter: ++ return .milligramsPerDeciliterPerInternationalUnit ++ case .millimolesPerLiter: ++ return .millimolesPerLiterPerInternationalUnit ++ default: ++ return .milligramsPerDeciliterPerInternationalUnit ++ } ++ } ++ ++ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { ++ Card { ++ SectionWithContent(title: therapySetting.title, ++ content: content) ++ } ++ } ++} ++ ++fileprivate struct SectionDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -16) ++ } ++} ++ ++fileprivate struct SettingsDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -8) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +new file mode 100644 +index 00000000..a1f35678 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift +@@ -0,0 +1,172 @@ ++// ++// ProfileView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-04-22. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import AVFoundation ++import HealthKit ++import LoopKit ++import SwiftUI ++ ++public struct ProfileView: View { ++ @ObservedObject public var viewModel: ProfileViewModel ++ @Environment(\.dismissAction) var dismissAction ++ @State private var newProfileName: String = "" ++ @State private var isAddingNewProfile = false ++ @State private var selectedProfileIndex: Int? = nil ++ @State private var refreshID = UUID() ++ ++ enum ActiveAlert: String, Identifiable { ++ case update, info ++ ++ var id: String { ++ return self.rawValue ++ } ++ } ++ @State private var activeAlert: ActiveAlert? ++ ++ public init(viewModel: ProfileViewModel) { ++ self.viewModel = viewModel ++ } ++ ++ private var dismissButton: some View { ++ Button(action: dismissAction) { ++ Text(LocalizedString("Done", comment: "Text for dismiss button")) ++ .bold() ++ } ++ } ++ ++ public var body: some View { ++ ZStack { ++ NavigationView { ++ ConfigurationPageScrollView( ++ content: { ++ VStack(alignment: .leading) { ++ ++ if !viewModel.profiles.isEmpty { ++ List { ++ ForEach(viewModel.profiles.indices, id: \.self) { index in ++ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { ++ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { ++ HStack { ++ if viewModel.currentProfileName == viewModel.profiles[index].name { ++ Image(systemName: "checkmark") ++ .foregroundColor(Color.blue) ++ } else { ++ // Add indentation to align with the checkmark for the active profile ++ Image(systemName: "checkmark") ++ .opacity(0) // Invisible ++ } ++ ++ Text(viewModel.profiles[index].name) ++ } ++ } ++ } ++ } ++ .onMove(perform: moveProfile) ++ }.id(refreshID) ++ ++ } else { ++ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") ++ .font(.subheadline) ++ .foregroundColor(.secondary) ++ .padding([.leading, .trailing]) ++ .multilineTextAlignment(.leading) ++ .lineLimit(nil) ++ .textCase(nil) ++ } ++ } ++ }, ++ actionArea: { EmptyView() } // no action area in this case ++ ) ++ .navigationBarItems( ++ leading: HStack { ++ Button(action: { withAnimation { isAddingNewProfile = true } }) { ++ Image(systemName: "plus") ++ } ++ ++ Button(action: { activeAlert = .info }) { ++ Image(systemName: "info.circle") ++ .resizable() ++ .frame(width: 24, height: 24) ++ } ++ }, ++ trailing: dismissButton ++ ) ++ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) ++ } ++ .navigationBarHidden(false) ++ .background(Color(.systemGroupedBackground)) ++ .onAppear { ++ if viewModel.isCurrentProfileOutOfSync() { ++ activeAlert = .update ++ } ++ } ++ ++ .alert(item: $activeAlert) { alertType in ++ switch alertType { ++ case .update: ++ return Alert( ++ title: Text("Profile Outdated"), ++ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), ++ primaryButton: .default(Text("Update")) { ++ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") ++ }, ++ secondaryButton: .cancel() ++ ) ++ case .info: ++ return Alert(title: Text("Information"), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ dismissButton: .default(Text("Got it!"))) ++ } ++ } ++ ++ if isAddingNewProfile { ++ DarkenedOverlay() ++ ++ NewProfileEditor( ++ isPresented: $isAddingNewProfile, ++ newProfileName: newProfileName, ++ viewModel: viewModel ++ ) ++ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) ++ } ++ } ++ } ++ ++ func moveProfile(from source: IndexSet, to destination: Int) { ++ viewModel.profiles.move(fromOffsets: source, toOffset: destination) ++ refreshID = UUID() ++ viewModel.updateProfilesOrder() ++ } ++} ++ ++struct ProfileView_Previews: PreviewProvider { ++ static let preview_glucoseScheduleItems = [ ++ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), ++ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), ++ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) ++ ] ++ ++ static let preview_therapySettings = TherapySettings( ++ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), ++ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), ++ unit: .milligramsPerDeciliter), ++ maximumBolus: 4, ++ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), ++ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: .milligramsPerDeciliterPerInternationalUnit, dailyItems: []), ++ carbRatioSchedule: nil, ++ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) ++ ++ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] ++ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] ++ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] ++ ++ static var previews: some View { ++ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +new file mode 100644 +index 00000000..8fd26665 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift +@@ -0,0 +1,92 @@ ++// ++// RenameProfileEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2023-09-07. ++// Copyright © 2023 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import SwiftUI ++ ++struct RenameProfileEditor: View { ++ @Binding var isPresented: Bool ++ @State var currentProfileName: String ++ @State var newProfileName: String = "" ++ var viewModel: ProfileViewModel ++ @State private var showAlert = false ++ @Binding var shouldDismissParent: Bool ++ ++ var body: some View { ++ VStack(spacing: 0) { ++ ModalHeaderButtonBar( ++ leading: { cancelButton }, ++ center: { ++ Text("Rename Profile") ++ .font(.subheadline) ++ ++ }, ++ trailing: { renameButton } ++ ) ++ .onAppear { ++ self.newProfileName = currentProfileName ++ } ++ ++ TextField("Profile Name", text: $newProfileName) ++ .textFieldStyle(RoundedBorderTextFieldStyle()) ++ .padding() ++ .background( ++ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) ++ .fill(Color(.secondarySystemGroupedBackground)) ++ ) ++ } ++ .padding(.horizontal) ++ .alert(isPresented: $showAlert) { ++ Alert( ++ title: Text("Overwrite Existing Profile"), ++ message: Text("A profile with this name already exists. Would you like to overwrite it?"), ++ primaryButton: .destructive(Text("Overwrite")) { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ }, ++ secondaryButton: .cancel() ++ ) ++ } ++ } ++ ++ var renameButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ guard newProfileName != currentProfileName else { ++ self.isPresented = false ++ return ++ } ++ ++ if viewModel.doesProfileExist(withName: self.newProfileName) { ++ self.showAlert = true ++ } else { ++ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) ++ self.shouldDismissParent = true ++ self.isPresented = false ++ } ++ } ++ }, label: { ++ Text("Rename") ++ } ++ ) ++ } ++ ++ var cancelButton: some View { ++ Button( ++ action: { ++ withAnimation { ++ self.isPresented = false ++ } ++ }, label: { ++ Text("Cancel") ++ } ++ ) ++ } ++} +Submodule LoopOnboarding contains modified content +diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +index 1ddc82b..54421a1 100644 +--- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift ++++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift +@@ -381,6 +381,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { + maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount + ) + } ++ ++ func updateCurrentProfileName() { ++ } + } + + +Submodule NightscoutService contains modified content +diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +index 00096e2..cf6883e 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -23,6 +23,10 @@ + A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; + A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; + A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; + A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; + A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; +@@ -156,6 +160,10 @@ + A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; + A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; + A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; + A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; +@@ -265,6 +273,7 @@ + A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, + A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, + A934B6172358F49300949C8B /* StoredSettings.swift */, ++ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, + A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, + A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, + A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, +@@ -321,6 +330,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -361,6 +371,7 @@ + A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, + A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, + A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, + ); + path = Notifications; + sourceTree = ""; +@@ -409,6 +420,7 @@ + A9AA6E5729EB06AA008FFA78 /* Actions */ = { + isa = PBXGroup; + children = ( ++ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, + A9AA6E5D29EB07D6008FFA78 /* Action.swift */, + A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, + A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, +@@ -740,6 +752,9 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, ++ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, + A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, + C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, + ); +@@ -753,6 +768,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, + A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +new file mode 100644 +index 0000000..588ded1 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,104 @@ ++// ++// SavedProfile.swift ++// NightscoutServiceKit ++// ++ ++import CryptoKit ++import Foundation ++import LoopAlgorithm ++import LoopKit ++import NightscoutKit ++ ++/// A profile saved with the Profiles settings screen, read from the file it was saved to. ++struct SavedProfile: Decodable { ++ let name: String ++ let correctionRange: GlucoseRangeSchedule ++ let carbRatioSchedule: CarbRatioSchedule ++ let basalRateSchedule: BasalRateSchedule ++ let insulinSensitivitySchedule: InsulinSensitivitySchedule ++ ++ private static var directory: URL { ++ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") ++ } ++ ++ static func loadAll() -> [SavedProfile] { ++ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] ++ return files ++ .filter { $0.pathExtension == "json" } ++ .compactMap { try? Data(contentsOf: $0) } ++ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } ++ } ++ ++ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. ++ static func nightscoutStore(unit: LoopUnit) -> [String: ProfileSet.Profile] { ++ var store: [String: ProfileSet.Profile] = [:] ++ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. ++ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { ++ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) ++ } ++ return store ++ } ++ ++ /// True when this profile uploads with this fingerprint, in either glucose unit. ++ func matches(fingerprint: String) -> Bool { ++ [LoopUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { ++ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint ++ } ++ } ++ ++ func nightscoutProfile(unit: LoopUnit) -> ProfileSet.Profile? { ++ guard let correctionRange = correctionRange.schedule(for: unit), ++ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) ++ else { ++ return nil ++ } ++ return ProfileSet.Profile( ++ timezone: basalRateSchedule.timeZone, ++ dia: .hours(6), ++ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, ++ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, ++ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, ++ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) ++ } ++} ++ ++extension ProfileSet.Profile { ++ /// Followers compute the same value from the uploaded profile and send it with a switch command, ++ /// so Loop only activates the values the follower previewed. ++ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` ++ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. ++ var remoteFingerprint: String { ++ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { ++ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") ++ } ++ let canonical = [ ++ "basal=" + items(basal), ++ "carbratio=" + items(carbratio), ++ "sens=" + items(sensitivity), ++ "target_low=" + items(targetLow), ++ "target_high=" + items(targetHigh), ++ ].joined(separator: ";") ++ let digest = SHA256.hash(data: Data(canonical.utf8)) ++ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +index 2f12b47..2dec600 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift +@@ -180,6 +180,7 @@ extension StoredDosingDecision { + enacted: automaticDoseDecision?.loopStatusEnacted, + failureReason: automaticDoseDecision?.loopStatusFailureReason, + failureCode: automaticDoseDecision?.errors.first?.id, ++ testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]], + algorithmExperiments: (automaticDoseDecision?.algorithmExperiments ?? algorithmExperiments)?.map { $0.jsonObject }), + overrideStatus: overrideStatus) + } +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +index 3142223..ae6fb9a 100644 +--- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift ++++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift +@@ -93,12 +93,21 @@ extension StoredSettings { + return nil + } + ++ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) ++ store["Default"] = profile ++ var defaultProfile = "Default" ++ ++ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { ++ defaultProfile = name ++ store[name] = profile ++ } ++ + return ProfileSet( + startDate: date, + units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), + enteredBy: "Loop", +- defaultProfile: "Default", +- store: ["Default": profile], ++ defaultProfile: defaultProfile, ++ store: store, + settings: loopSettings, + syncIdentifier: syncIdentifier.uuidString, + isAPNSProduction: Bundle.main.isAPNSProductionEnvironment, +diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +index c625152..ec997ef 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -473,6 +473,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { + success = true + message = "Override '\(overrideCommand.name)' enacted successfully" + ++ case .profileSwitch(let profileSwitchCommand): ++ commandType = .profileSwitch ++ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) ++ } ++ do { ++ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) ++ } ++ success = true ++ message = "Profile '\(profileSwitchCommand.name)' activated" ++ + case .cancelTemporaryOverride: + commandType = .cancelOverride + try await serviceDelegate.cancelRemoteOverride() +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +index a92b509..29313b6 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift +@@ -9,6 +9,7 @@ + import Foundation + + public enum Action: Codable { ++ case profileSwitch(ProfileSwitchAction) + case temporaryScheduleOverride(OverrideAction) + case cancelTemporaryOverride(OverrideCancelAction) + case bolusEntry(BolusAction) +@@ -28,6 +29,8 @@ public enum Action: Codable { + return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") + case .temporaryScheduleOverride: + return LocalizedString("Override", comment: "The remote action name for Override") ++ case .profileSwitch: ++ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") + } + } + +@@ -43,6 +46,8 @@ public enum Action: Codable { + return "" + case .temporaryScheduleOverride(let overrideAction): + return overrideAction.name ++ case .profileSwitch(let profileSwitchAction): ++ return profileSwitchAction.name + } + } + +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +new file mode 100644 +index 0000000..ce03ded +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift +@@ -0,0 +1,17 @@ ++// ++// ProfileSwitchAction.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++ ++public struct ProfileSwitchAction: Codable { ++ ++ public let name: String ++ public let fingerprint: String ++ ++ public init(name: String, fingerprint: String) { ++ self.name = name ++ self.fingerprint = fingerprint ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +index 95274e4..4457f80 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift +@@ -45,6 +45,7 @@ class RemoteNotificationResponseManager { + case carbs = "carbs" + case override = "override" + case cancelOverride = "cancel_override" ++ case profileSwitch = "profile_switch" + } + + func sendResponseNotification( +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +new file mode 100644 +index 0000000..ebcd6ef +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift +@@ -0,0 +1,50 @@ ++// ++// ProfileSwitchRemoteNotification.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++ ++/// Switches to a saved profile. `expiration` is required: decoding fails without it. ++public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ public let profileName: String ++ public let fingerprint: String ++ public let remoteAddress: String ++ public let expirationDate: Date ++ public let sentAt: Date ++ public let otp: String? ++ public let enteredBy: String? ++ public let encryptedReturnNotification: String? ++ ++ public var expiration: Date? { ++ return expirationDate ++ } ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expirationDate = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ func toRemoteAction() -> Action { ++ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) ++ } ++ ++ func otpValidationRequired() -> Bool { ++ return true ++ } ++ ++ public static func includedInNotification(_ notification: [String: Any]) -> Bool { ++ return notification[commandKey] != nil ++ } ++} +diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +index 24e63d7..a0f58a0 100644 +--- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift ++++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift +@@ -104,6 +104,8 @@ extension Dictionary { + return try OverrideRemoteNotification(dictionary: self) + } else if OverrideCancelRemoteNotification.includedInNotification(self) { + return try OverrideCancelRemoteNotification(dictionary: self) ++ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { ++ return try ProfileSwitchRemoteNotification(dictionary: self) + } else { + throw RemoteNotificationError.unhandledNotification(self) + } +diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +new file mode 100644 +index 0000000..2fcec94 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift +@@ -0,0 +1,71 @@ ++// ++// ProfileSwitchRemoteNotificationTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++ ++ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "sent-at": "2023-02-25T20:46:35.778Z", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ] ++ ++ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) ++ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ ++ XCTAssertEqual(profileSwitch.profileName, "Sport") ++ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertTrue(profileSwitch.otpValidationRequired()) ++ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { ++ return XCTFail("Expected a profile switch action") ++ } ++ XCTAssertEqual(action.name, "Sport") ++ } ++ ++ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { ++ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ let profile = ProfileSet.Profile( ++ timezone: TimeZone(secondsFromGMT: 3600)!, ++ dia: .hours(6), ++ sensitivity: items([(0, 45)]), ++ carbratio: items([(0, 10)]), ++ basal: items([(0, 0.85), (21600, 1.1)]), ++ targetLow: items([(0, 100)]), ++ targetHigh: items([(0, 110)]), ++ units: "mg/dL") ++ ++ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") ++ } ++} ++ ++private extension ISO8601DateFormatter { ++ static let fractional: ISO8601DateFormatter = { ++ let formatter = ISO8601DateFormatter() ++ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] ++ return formatter ++ }() ++} +diff --git a/profiles.loopcustomization b/profiles.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/remote_carb_edit/README.md b/remote_carb_edit/README.md index 2bbe8a4..902c045 100644 --- a/remote_carb_edit/README.md +++ b/remote_carb_edit/README.md @@ -39,11 +39,11 @@ Both orders produce the same tree. The selector script tries each folder's patch With remote_window, `sent-at` is required on every remote command (the notification is rejected without it). On `dev`, delete and edit commands also appear in the remote command history with the entry's `syncIdentifier`; the `next-dev` remote_window has no command history. -## Combining with remote_profile +## Combining with profiles -`remote_profile` also adds a remote command, so the two have variants for each other, also together with remote_window: +`profiles` also adds a remote command (profile switch), so the two have variants for each other, also together with remote_window: -- `_remote_carb_edit_remote_profile.patch` and `_remote_carb_edit_remote_profile_remote_window.patch`: this customization on top of remote_profile (and remote_window). -- `remote_profile/_remote_profile_remote_carb_edit.patch` and `..._remote_carb_edit_remote_window.patch`: the reverse. +- `_remote_carb_edit_profiles.patch` and `_remote_carb_edit_profiles_remote_window.patch`: this customization on top of profiles (and remote_window). +- `profiles/_profiles_remote_carb_edit.patch` and `..._remote_carb_edit_remote_window.patch`: the reverse. -With both applied, `remoteCommands` lists `carbs-delete`, `carbs-edit` and `profile-switch`. See `remote_profile/README.md`. +With both applied, `remoteCommands` lists `carbs-delete`, `carbs-edit` and `profile-switch`. See `profiles/README.md`. diff --git a/remote_carb_edit/dev_remote_carb_edit_remote_profile.patch b/remote_carb_edit/dev_remote_carb_edit_profiles.patch similarity index 100% rename from remote_carb_edit/dev_remote_carb_edit_remote_profile.patch rename to remote_carb_edit/dev_remote_carb_edit_profiles.patch diff --git a/remote_carb_edit/dev_remote_carb_edit_remote_profile_remote_window.patch b/remote_carb_edit/dev_remote_carb_edit_profiles_remote_window.patch similarity index 100% rename from remote_carb_edit/dev_remote_carb_edit_remote_profile_remote_window.patch rename to remote_carb_edit/dev_remote_carb_edit_profiles_remote_window.patch diff --git a/remote_carb_edit/nextdev_remote_carb_edit_remote_profile.patch b/remote_carb_edit/nextdev_remote_carb_edit_profiles.patch similarity index 100% rename from remote_carb_edit/nextdev_remote_carb_edit_remote_profile.patch rename to remote_carb_edit/nextdev_remote_carb_edit_profiles.patch diff --git a/remote_carb_edit/nextdev_remote_carb_edit_remote_profile_remote_window.patch b/remote_carb_edit/nextdev_remote_carb_edit_profiles_remote_window.patch similarity index 100% rename from remote_carb_edit/nextdev_remote_carb_edit_remote_profile_remote_window.patch rename to remote_carb_edit/nextdev_remote_carb_edit_profiles_remote_window.patch diff --git a/remote_profile/dev_remote_profile.patch b/remote_profile/dev_remote_profile.patch deleted file mode 100644 index 2355d7a..0000000 --- a/remote_profile/dev_remote_profile.patch +++ /dev/null @@ -1,651 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index e7316b68..3b5f1de2 100644 ---- a/Loop/Loop/Managers/DeviceDataManager.swift -+++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -433,6 +433,7 @@ final class DeviceDataManager { - ) - - statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) -+ servicesManager.deviceDataManager = self - - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] - criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 996d1470..17cff75d 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -263,6 +263,38 @@ extension NotificationManager { - } - } - -+ static func sendRemoteProfileNotification(name: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfile", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteProfileFailureNotification(name: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfileFailure", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ private static func remoteProfileNotificationBody(name: String) -> String { -+ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) -+ } -+ - private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { - return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) - } -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 3fb788f9..c9d3e04f 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -28,6 +28,7 @@ class ServicesManager { - - weak var servicesManagerDelegate: ServicesManagerDelegate? - weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? -+ weak var deviceDataManager: DeviceDataManager? - - private var services = [Service]() - -@@ -412,3 +413,59 @@ extension Service { - ] - } - } -+ -+// MARK: - RemoteProfileActionDelegate -+ -+extension ServicesManager: RemoteProfileActionDelegate { -+ @MainActor -+ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { -+ do { -+ guard let deviceDataManager else { -+ throw RemoteProfileActionError.unavailable -+ } -+ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) -+ guard deviceDataManager.pumpSupportedIncrements() != nil else { -+ throw RemoteProfileActionError.noPump -+ } -+ guard let profile = try viewModel.savedProfile(named: name) else { -+ throw RemoteProfileActionError.profileNotFound(name) -+ } -+ try profile.verify(with: verify) -+ if case .failure(let error) = viewModel.validateProfile(profile) { -+ throw error -+ } -+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ continuation.resume() -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ viewModel.triggerProfileSync() -+ NotificationManager.sendRemoteProfileNotification(name: name) -+ } catch { -+ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) -+ throw error -+ } -+ } -+ -+ enum RemoteProfileActionError: LocalizedError { -+ case unavailable -+ case noPump -+ case profileNotFound(String) -+ -+ var errorDescription: String? { -+ switch self { -+ case .unavailable: -+ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") -+ case .noPump: -+ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") -+ case .profileNotFound(let name): -+ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) -+ } -+ } -+ } -+} -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 7fdc53ad..00fe25ab 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -8,6 +8,12 @@ - - import Foundation - -+/// Switches to a saved therapy profile on a remote command. -+public protocol RemoteProfileActionDelegate: AnyObject { -+ /// `verify` receives the schedules about to be activated and throws to reject them. -+ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws -+} -+ - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws - func cancelRemoteOverride() async throws -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -index 74724fb7..f48848cd 100644 ---- a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -174,6 +174,7 @@ extension ProfileViewModel { - try jsonData.write(to: fileURL) - - self.loadProfiles() -+ triggerProfileSync() - } catch { - print("An error occurred while renaming profile: \(error)") - } -@@ -270,6 +271,8 @@ extension ProfileViewModel { - removeProfile(profileReference: profileReference) - if getCurrentProfileName() == profile.name { - clearCurrentProfile() -+ } else { -+ triggerProfileSync() - } - } - -@@ -295,6 +298,14 @@ extension ProfileViewModel { - return getProfile - } - -+ /// The saved profile named `name`, or nil when there is none. -+ public func savedProfile(named name: String) throws -> Profile? { -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ return nil -+ } -+ return try getProfile(from: profileReference) -+ } -+ - public func validateProfile(_ profile: Profile) -> Result { - guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { - return .failure(.fileLoadError) -@@ -406,3 +417,10 @@ extension ProfileViewModel { - return isOutOfSync - } - } -+ -+extension Profile { -+ /// Passes the schedules this profile activates to `check`. -+ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { -+ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) -+ } -+} -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index 69f7a26..14a7691 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -24,6 +24,10 @@ - A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; -@@ -158,6 +162,10 @@ - A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; - A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; -@@ -268,6 +276,7 @@ - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, - A934B6172358F49300949C8B /* StoredSettings.swift */, -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, - A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, - A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, -@@ -324,6 +333,7 @@ - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, - A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, - ); - path = NightscoutServiceKitTests; -@@ -364,6 +374,7 @@ - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, - ); - path = Notifications; - sourceTree = ""; -@@ -412,6 +423,7 @@ - A9AA6E5729EB06AA008FFA78 /* Actions */ = { - isa = PBXGroup; - children = ( -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -@@ -744,6 +756,9 @@ - C1398D2227C434FB00416AD6 /* Data.swift in Sources */, - A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, - A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, - C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, - ); -@@ -757,6 +772,7 @@ - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, - A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -new file mode 100644 -index 0000000..de73ca9 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -@@ -0,0 +1,104 @@ -+// -+// SavedProfile.swift -+// NightscoutServiceKit -+// -+ -+import CryptoKit -+import Foundation -+import HealthKit -+import LoopKit -+import NightscoutKit -+ -+/// A profile saved with the Profiles settings screen, read from the file it was saved to. -+struct SavedProfile: Decodable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ -+ private static var directory: URL { -+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") -+ } -+ -+ static func loadAll() -> [SavedProfile] { -+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] -+ return files -+ .filter { $0.pathExtension == "json" } -+ .compactMap { try? Data(contentsOf: $0) } -+ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } -+ } -+ -+ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. -+ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { -+ var store: [String: ProfileSet.Profile] = [:] -+ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. -+ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { -+ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) -+ } -+ return store -+ } -+ -+ /// True when this profile uploads with this fingerprint, in either glucose unit. -+ func matches(fingerprint: String) -> Bool { -+ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { -+ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint -+ } -+ } -+ -+ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { -+ guard let correctionRange = correctionRange.schedule(for: unit), -+ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) -+ else { -+ return nil -+ } -+ return ProfileSet.Profile( -+ timezone: basalRateSchedule.timeZone, -+ dia: .hours(6), -+ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, -+ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, -+ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) -+ } -+} -+ -+extension ProfileSet.Profile { -+ /// Followers compute the same value from the uploaded profile and send it with a switch command, -+ /// so Loop only activates the values the follower previewed. -+ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` -+ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. -+ var remoteFingerprint: String { -+ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { -+ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") -+ } -+ let canonical = [ -+ "basal=" + items(basal), -+ "carbratio=" + items(carbratio), -+ "sens=" + items(sensitivity), -+ "target_low=" + items(targetLow), -+ "target_high=" + items(targetHigh), -+ ].joined(separator: ";") -+ let digest = SHA256.hash(data: Data(canonical.utf8)) -+ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) -+ } -+} -+ -+enum RemoteProfileSwitchError: LocalizedError { -+ case profileChanged -+ case unavailable -+ case notActivated(String, Error) -+ -+ /// Followers read the profile name from this text to match the reply to their command. -+ var errorDescription: String? { -+ switch self { -+ case .profileChanged: -+ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") -+ case .unavailable: -+ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") -+ case .notActivated(let name, let error): -+ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index 4e1d49e..b66fb97 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -159,7 +159,8 @@ extension StoredDosingDecision { - automaticDoseRecommendation: loopStatusAutomaticDoseRecommendation, - recommendedBolus: loopStatusRecommendedBolus, - enacted: automaticDoseDecision?.loopStatusEnacted, -- failureReason: automaticDoseDecision?.loopStatusFailureReason), -+ failureReason: automaticDoseDecision?.loopStatusFailureReason, -+ testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]]), - overrideStatus: overrideStatus) - } - -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -index a1769d8..dd7ac28 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -@@ -94,7 +94,8 @@ extension StoredSettings { - return nil - } - -- var store = ["Default": profile] -+ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) -+ store["Default"] = profile - var defaultProfile = "Default" - - if let name = UserDefaults.standard.string(forKey: "currentProfileName") { -diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index 4626630..ef69236 100644 ---- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift -+++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -436,6 +436,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - success = true - message = "Override '\(overrideCommand.name)' enacted successfully" - -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ success = true -+ message = "Profile '\(profileSwitchCommand.name)' activated" -+ - case .cancelTemporaryOverride: - commandType = .cancelOverride - try await self.serviceDelegate?.cancelRemoteOverride() -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index a92b509..29313b6 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -9,6 +9,7 @@ - import Foundation - - public enum Action: Codable { -+ case profileSwitch(ProfileSwitchAction) - case temporaryScheduleOverride(OverrideAction) - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) -@@ -28,6 +29,8 @@ public enum Action: Codable { - return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") - case .temporaryScheduleOverride: - return LocalizedString("Override", comment: "The remote action name for Override") -+ case .profileSwitch: -+ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") - } - } - -@@ -43,6 +46,8 @@ public enum Action: Codable { - return "" - case .temporaryScheduleOverride(let overrideAction): - return overrideAction.name -+ case .profileSwitch(let profileSwitchAction): -+ return profileSwitchAction.name - } - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -new file mode 100644 -index 0000000..ce03ded ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -@@ -0,0 +1,17 @@ -+// -+// ProfileSwitchAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct ProfileSwitchAction: Codable { -+ -+ public let name: String -+ public let fingerprint: String -+ -+ public init(name: String, fingerprint: String) { -+ self.name = name -+ self.fingerprint = fingerprint -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 95274e4..4457f80 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -45,6 +45,7 @@ class RemoteNotificationResponseManager { - case carbs = "carbs" - case override = "override" - case cancelOverride = "cancel_override" -+ case profileSwitch = "profile_switch" - } - - func sendResponseNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -new file mode 100644 -index 0000000..487c620 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -0,0 +1,50 @@ -+// -+// ProfileSwitchRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Switches to a saved profile. `expiration` is required: decoding fails without it. -+public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { -+ -+ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. -+ static let commandKey = "profile-switch" -+ -+ public let profileName: String -+ public let fingerprint: String -+ public let remoteAddress: String -+ public let expirationDate: Date -+ public let sentAt: Date? -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ public var expiration: Date? { -+ return expirationDate -+ } -+ -+ enum CodingKeys: String, CodingKey { -+ case profileName = "profile-switch" -+ case fingerprint = "profile-switch-fingerprint" -+ case remoteAddress = "remote-address" -+ case expirationDate = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification[commandKey] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index 0c5f8e3..3362bf7 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -108,6 +108,8 @@ extension Dictionary { - return try OverrideRemoteNotification(dictionary: self) - } else if OverrideCancelRemoteNotification.includedInNotification(self) { - return try OverrideCancelRemoteNotification(dictionary: self) -+ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { -+ return try ProfileSwitchRemoteNotification(dictionary: self) - } else { - throw RemoteNotificationError.unhandledNotification(self) - } -diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..2fcec94 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -@@ -0,0 +1,71 @@ -+// -+// ProfileSwitchRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+import LoopKit -+import NightscoutKit -+@testable import NightscoutServiceKit -+ -+final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "sent-at": "2023-02-25T20:46:35.778Z", -+ "expiration": "2023-02-25T20:51:35.778Z", -+ "otp": "123456" -+ ] -+ -+ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) -+ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) -+ -+ XCTAssertEqual(profileSwitch.profileName, "Sport") -+ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") -+ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) -+ XCTAssertTrue(profileSwitch.otpValidationRequired()) -+ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { -+ return XCTFail("Expected a profile switch action") -+ } -+ XCTAssertEqual(action.name, "Sport") -+ } -+ -+ func testParseProfileSwitchNotification_MissingExpiration_Fails() { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "otp": "123456" -+ ] -+ -+ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) -+ } -+ -+ func testFingerprint_MatchesFollowerVector() { -+ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { -+ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } -+ } -+ let profile = ProfileSet.Profile( -+ timezone: TimeZone(secondsFromGMT: 3600)!, -+ dia: .hours(6), -+ sensitivity: items([(0, 45)]), -+ carbratio: items([(0, 10)]), -+ basal: items([(0, 0.85), (21600, 1.1)]), -+ targetLow: items([(0, 100)]), -+ targetHigh: items([(0, 110)]), -+ units: "mg/dL") -+ -+ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") -+ } -+} -+ -+private extension ISO8601DateFormatter { -+ static let fractional: ISO8601DateFormatter = { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ }() -+} -diff --git a/remote_profile.loopcustomization b/remote_profile.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/remote_profile/dev_remote_profile_remote_carb_edit.patch b/remote_profile/dev_remote_profile_remote_carb_edit.patch deleted file mode 100644 index 556dacf..0000000 --- a/remote_profile/dev_remote_profile_remote_carb_edit.patch +++ /dev/null @@ -1,650 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index e7316b68..3b5f1de2 100644 ---- a/Loop/Loop/Managers/DeviceDataManager.swift -+++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -433,6 +433,7 @@ final class DeviceDataManager { - ) - - statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) -+ servicesManager.deviceDataManager = self - - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] - criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 3eb3154b..6cefc092 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -293,6 +293,38 @@ extension NotificationManager { - } - } - -+ static func sendRemoteProfileNotification(name: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfile", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteProfileFailureNotification(name: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfileFailure", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ private static func remoteProfileNotificationBody(name: String) -> String { -+ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) -+ } -+ - private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { - return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) - } -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 3b74fd9d..5bf1ec36 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -28,6 +28,7 @@ class ServicesManager { - - weak var servicesManagerDelegate: ServicesManagerDelegate? - weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? -+ weak var deviceDataManager: DeviceDataManager? - - private var services = [Service]() - -@@ -456,3 +457,59 @@ extension Service { - ] - } - } -+ -+// MARK: - RemoteProfileActionDelegate -+ -+extension ServicesManager: RemoteProfileActionDelegate { -+ @MainActor -+ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { -+ do { -+ guard let deviceDataManager else { -+ throw RemoteProfileActionError.unavailable -+ } -+ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) -+ guard deviceDataManager.pumpSupportedIncrements() != nil else { -+ throw RemoteProfileActionError.noPump -+ } -+ guard let profile = try viewModel.savedProfile(named: name) else { -+ throw RemoteProfileActionError.profileNotFound(name) -+ } -+ try profile.verify(with: verify) -+ if case .failure(let error) = viewModel.validateProfile(profile) { -+ throw error -+ } -+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ continuation.resume() -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ viewModel.triggerProfileSync() -+ NotificationManager.sendRemoteProfileNotification(name: name) -+ } catch { -+ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) -+ throw error -+ } -+ } -+ -+ enum RemoteProfileActionError: LocalizedError { -+ case unavailable -+ case noPump -+ case profileNotFound(String) -+ -+ var errorDescription: String? { -+ switch self { -+ case .unavailable: -+ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") -+ case .noPump: -+ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") -+ case .profileNotFound(let name): -+ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) -+ } -+ } -+ } -+} -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 4b2cecf3..56742c96 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -8,6 +8,12 @@ - - import Foundation - -+/// Switches to a saved therapy profile on a remote command. -+public protocol RemoteProfileActionDelegate: AnyObject { -+ /// `verify` receives the schedules about to be activated and throws to reject them. -+ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws -+} -+ - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws - func cancelRemoteOverride() async throws -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -index 74724fb7..f48848cd 100644 ---- a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -174,6 +174,7 @@ extension ProfileViewModel { - try jsonData.write(to: fileURL) - - self.loadProfiles() -+ triggerProfileSync() - } catch { - print("An error occurred while renaming profile: \(error)") - } -@@ -270,6 +271,8 @@ extension ProfileViewModel { - removeProfile(profileReference: profileReference) - if getCurrentProfileName() == profile.name { - clearCurrentProfile() -+ } else { -+ triggerProfileSync() - } - } - -@@ -295,6 +298,14 @@ extension ProfileViewModel { - return getProfile - } - -+ /// The saved profile named `name`, or nil when there is none. -+ public func savedProfile(named name: String) throws -> Profile? { -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ return nil -+ } -+ return try getProfile(from: profileReference) -+ } -+ - public func validateProfile(_ profile: Profile) -> Result { - guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { - return .failure(.fileLoadError) -@@ -406,3 +417,10 @@ extension ProfileViewModel { - return isOutOfSync - } - } -+ -+extension Profile { -+ /// Passes the schedules this profile activates to `check`. -+ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { -+ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) -+ } -+} -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index 9f75373..18143ab 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -24,6 +24,10 @@ - A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; -@@ -165,6 +169,10 @@ - A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; - A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; -@@ -282,6 +290,7 @@ - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, - A934B6172358F49300949C8B /* StoredSettings.swift */, -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, - A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, - A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, -@@ -341,6 +350,7 @@ - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, - A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, - ); - path = NightscoutServiceKitTests; -@@ -383,6 +393,7 @@ - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, - ); - path = Notifications; - sourceTree = ""; -@@ -431,6 +442,7 @@ - A9AA6E5729EB06AA008FFA78 /* Actions */ = { - isa = PBXGroup; - children = ( -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -@@ -769,6 +781,9 @@ - C1398D2227C434FB00416AD6 /* Data.swift in Sources */, - A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, - A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, - C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, - ); -@@ -785,6 +800,7 @@ - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, - A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -new file mode 100644 -index 0000000..de73ca9 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -@@ -0,0 +1,104 @@ -+// -+// SavedProfile.swift -+// NightscoutServiceKit -+// -+ -+import CryptoKit -+import Foundation -+import HealthKit -+import LoopKit -+import NightscoutKit -+ -+/// A profile saved with the Profiles settings screen, read from the file it was saved to. -+struct SavedProfile: Decodable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ -+ private static var directory: URL { -+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") -+ } -+ -+ static func loadAll() -> [SavedProfile] { -+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] -+ return files -+ .filter { $0.pathExtension == "json" } -+ .compactMap { try? Data(contentsOf: $0) } -+ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } -+ } -+ -+ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. -+ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { -+ var store: [String: ProfileSet.Profile] = [:] -+ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. -+ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { -+ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) -+ } -+ return store -+ } -+ -+ /// True when this profile uploads with this fingerprint, in either glucose unit. -+ func matches(fingerprint: String) -> Bool { -+ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { -+ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint -+ } -+ } -+ -+ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { -+ guard let correctionRange = correctionRange.schedule(for: unit), -+ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) -+ else { -+ return nil -+ } -+ return ProfileSet.Profile( -+ timezone: basalRateSchedule.timeZone, -+ dia: .hours(6), -+ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, -+ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, -+ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) -+ } -+} -+ -+extension ProfileSet.Profile { -+ /// Followers compute the same value from the uploaded profile and send it with a switch command, -+ /// so Loop only activates the values the follower previewed. -+ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` -+ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. -+ var remoteFingerprint: String { -+ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { -+ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") -+ } -+ let canonical = [ -+ "basal=" + items(basal), -+ "carbratio=" + items(carbratio), -+ "sens=" + items(sensitivity), -+ "target_low=" + items(targetLow), -+ "target_high=" + items(targetHigh), -+ ].joined(separator: ";") -+ let digest = SHA256.hash(data: Data(canonical.utf8)) -+ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) -+ } -+} -+ -+enum RemoteProfileSwitchError: LocalizedError { -+ case profileChanged -+ case unavailable -+ case notActivated(String, Error) -+ -+ /// Followers read the profile name from this text to match the reply to their command. -+ var errorDescription: String? { -+ switch self { -+ case .profileChanged: -+ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") -+ case .unavailable: -+ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") -+ case .notActivated(let name, let error): -+ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index 637988b..7c780ea 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -160,7 +160,7 @@ extension StoredDosingDecision { - recommendedBolus: loopStatusRecommendedBolus, - enacted: automaticDoseDecision?.loopStatusEnacted, - failureReason: automaticDoseDecision?.loopStatusFailureReason, -- testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported]), -+ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]]), - overrideStatus: overrideStatus) - } - -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -index a1769d8..dd7ac28 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -@@ -94,7 +94,8 @@ extension StoredSettings { - return nil - } - -- var store = ["Default": profile] -+ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) -+ store["Default"] = profile - var defaultProfile = "Default" - - if let name = UserDefaults.standard.string(forKey: "currentProfileName") { -diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index 169fb86..18ac515 100644 ---- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift -+++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -437,6 +437,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - success = true - message = "Override '\(overrideCommand.name)' enacted successfully" - -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ success = true -+ message = "Profile '\(profileSwitchCommand.name)' activated" -+ - case .cancelTemporaryOverride: - commandType = .cancelOverride - try await self.serviceDelegate?.cancelRemoteOverride() -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index 91ad56c..29d3563 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -9,6 +9,7 @@ - import Foundation - - public enum Action: Codable { -+ case profileSwitch(ProfileSwitchAction) - case temporaryScheduleOverride(OverrideAction) - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) -@@ -34,6 +35,8 @@ public enum Action: Codable { - return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") - case .temporaryScheduleOverride: - return LocalizedString("Override", comment: "The remote action name for Override") -+ case .profileSwitch: -+ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") - } - } - -@@ -54,6 +57,8 @@ public enum Action: Codable { - return "" - case .temporaryScheduleOverride(let overrideAction): - return overrideAction.name -+ case .profileSwitch(let profileSwitchAction): -+ return profileSwitchAction.name - } - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -new file mode 100644 -index 0000000..ce03ded ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -@@ -0,0 +1,17 @@ -+// -+// ProfileSwitchAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct ProfileSwitchAction: Codable { -+ -+ public let name: String -+ public let fingerprint: String -+ -+ public init(name: String, fingerprint: String) { -+ self.name = name -+ self.fingerprint = fingerprint -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 6092bc9..aabb47f 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -49,6 +49,7 @@ class RemoteNotificationResponseManager { - case carbsEdit = "carbs_edit" - case override = "override" - case cancelOverride = "cancel_override" -+ case profileSwitch = "profile_switch" - } - - func sendResponseNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -new file mode 100644 -index 0000000..487c620 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -0,0 +1,50 @@ -+// -+// ProfileSwitchRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Switches to a saved profile. `expiration` is required: decoding fails without it. -+public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { -+ -+ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. -+ static let commandKey = "profile-switch" -+ -+ public let profileName: String -+ public let fingerprint: String -+ public let remoteAddress: String -+ public let expirationDate: Date -+ public let sentAt: Date? -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ public var expiration: Date? { -+ return expirationDate -+ } -+ -+ enum CodingKeys: String, CodingKey { -+ case profileName = "profile-switch" -+ case fingerprint = "profile-switch-fingerprint" -+ case remoteAddress = "remote-address" -+ case expirationDate = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification[commandKey] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index 6f0524d..87f1e92 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -122,6 +122,8 @@ extension Dictionary { - return try OverrideRemoteNotification(dictionary: self) - } else if OverrideCancelRemoteNotification.includedInNotification(self) { - return try OverrideCancelRemoteNotification(dictionary: self) -+ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { -+ return try ProfileSwitchRemoteNotification(dictionary: self) - } else { - throw RemoteNotificationError.unhandledNotification(self) - } -diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..2fcec94 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -@@ -0,0 +1,71 @@ -+// -+// ProfileSwitchRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+import LoopKit -+import NightscoutKit -+@testable import NightscoutServiceKit -+ -+final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "sent-at": "2023-02-25T20:46:35.778Z", -+ "expiration": "2023-02-25T20:51:35.778Z", -+ "otp": "123456" -+ ] -+ -+ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) -+ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) -+ -+ XCTAssertEqual(profileSwitch.profileName, "Sport") -+ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") -+ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) -+ XCTAssertTrue(profileSwitch.otpValidationRequired()) -+ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { -+ return XCTFail("Expected a profile switch action") -+ } -+ XCTAssertEqual(action.name, "Sport") -+ } -+ -+ func testParseProfileSwitchNotification_MissingExpiration_Fails() { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "otp": "123456" -+ ] -+ -+ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) -+ } -+ -+ func testFingerprint_MatchesFollowerVector() { -+ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { -+ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } -+ } -+ let profile = ProfileSet.Profile( -+ timezone: TimeZone(secondsFromGMT: 3600)!, -+ dia: .hours(6), -+ sensitivity: items([(0, 45)]), -+ carbratio: items([(0, 10)]), -+ basal: items([(0, 0.85), (21600, 1.1)]), -+ targetLow: items([(0, 100)]), -+ targetHigh: items([(0, 110)]), -+ units: "mg/dL") -+ -+ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") -+ } -+} -+ -+private extension ISO8601DateFormatter { -+ static let fractional: ISO8601DateFormatter = { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ }() -+} -diff --git a/remote_profile.loopcustomization b/remote_profile.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/remote_profile/dev_remote_profile_remote_carb_edit_remote_window.patch b/remote_profile/dev_remote_profile_remote_carb_edit_remote_window.patch deleted file mode 100644 index 29eaf5c..0000000 --- a/remote_profile/dev_remote_profile_remote_carb_edit_remote_window.patch +++ /dev/null @@ -1,713 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index 93d1204d..c253e88e 100644 ---- a/Loop/Loop/Managers/DeviceDataManager.swift -+++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -433,6 +433,7 @@ final class DeviceDataManager { - ) - - statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) -+ servicesManager.deviceDataManager = self - - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] - criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 3eb3154b..6cefc092 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -293,6 +293,38 @@ extension NotificationManager { - } - } - -+ static func sendRemoteProfileNotification(name: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfile", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteProfileFailureNotification(name: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfileFailure", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ private static func remoteProfileNotificationBody(name: String) -> String { -+ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) -+ } -+ - private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { - return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) - } -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 1e272bb2..161a42e3 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -28,6 +28,7 @@ class ServicesManager { - - weak var servicesManagerDelegate: ServicesManagerDelegate? - weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? -+ weak var deviceDataManager: DeviceDataManager? - - private var services = [Service]() - -@@ -482,3 +483,59 @@ extension Service { - ] - } - } -+ -+// MARK: - RemoteProfileActionDelegate -+ -+extension ServicesManager: RemoteProfileActionDelegate { -+ @MainActor -+ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { -+ do { -+ guard let deviceDataManager else { -+ throw RemoteProfileActionError.unavailable -+ } -+ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) -+ guard deviceDataManager.pumpSupportedIncrements() != nil else { -+ throw RemoteProfileActionError.noPump -+ } -+ guard let profile = try viewModel.savedProfile(named: name) else { -+ throw RemoteProfileActionError.profileNotFound(name) -+ } -+ try profile.verify(with: verify) -+ if case .failure(let error) = viewModel.validateProfile(profile) { -+ throw error -+ } -+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ continuation.resume() -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ viewModel.triggerProfileSync() -+ NotificationManager.sendRemoteProfileNotification(name: name) -+ } catch { -+ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) -+ throw error -+ } -+ } -+ -+ enum RemoteProfileActionError: LocalizedError { -+ case unavailable -+ case noPump -+ case profileNotFound(String) -+ -+ var errorDescription: String? { -+ switch self { -+ case .unavailable: -+ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") -+ case .noPump: -+ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") -+ case .profileNotFound(let name): -+ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) -+ } -+ } -+ } -+} -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 8fb254b6..858a5621 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -8,6 +8,12 @@ - - import Foundation - -+/// Switches to a saved therapy profile on a remote command. -+public protocol RemoteProfileActionDelegate: AnyObject { -+ /// `verify` receives the schedules about to be activated and throws to reject them. -+ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws -+} -+ - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride - func cancelRemoteOverride() async throws -> TemporaryScheduleOverride -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -index 74724fb7..f48848cd 100644 ---- a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -174,6 +174,7 @@ extension ProfileViewModel { - try jsonData.write(to: fileURL) - - self.loadProfiles() -+ triggerProfileSync() - } catch { - print("An error occurred while renaming profile: \(error)") - } -@@ -270,6 +271,8 @@ extension ProfileViewModel { - removeProfile(profileReference: profileReference) - if getCurrentProfileName() == profile.name { - clearCurrentProfile() -+ } else { -+ triggerProfileSync() - } - } - -@@ -295,6 +298,14 @@ extension ProfileViewModel { - return getProfile - } - -+ /// The saved profile named `name`, or nil when there is none. -+ public func savedProfile(named name: String) throws -> Profile? { -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ return nil -+ } -+ return try getProfile(from: profileReference) -+ } -+ - public func validateProfile(_ profile: Profile) -> Result { - guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { - return .failure(.fileLoadError) -@@ -406,3 +417,10 @@ extension ProfileViewModel { - return isOutOfSync - } - } -+ -+extension Profile { -+ /// Passes the schedules this profile activates to `check`. -+ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { -+ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) -+ } -+} -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index 9f75373..18143ab 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -24,6 +24,10 @@ - A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; -@@ -165,6 +169,10 @@ - A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; - A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; -@@ -282,6 +290,7 @@ - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, - A934B6172358F49300949C8B /* StoredSettings.swift */, -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, - A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, - A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, -@@ -341,6 +350,7 @@ - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, - A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, - ); - path = NightscoutServiceKitTests; -@@ -383,6 +393,7 @@ - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, - ); - path = Notifications; - sourceTree = ""; -@@ -431,6 +442,7 @@ - A9AA6E5729EB06AA008FFA78 /* Actions */ = { - isa = PBXGroup; - children = ( -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -@@ -769,6 +781,9 @@ - C1398D2227C434FB00416AD6 /* Data.swift in Sources */, - A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, - A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, - C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, - ); -@@ -785,6 +800,7 @@ - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, - A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -new file mode 100644 -index 0000000..de73ca9 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -@@ -0,0 +1,104 @@ -+// -+// SavedProfile.swift -+// NightscoutServiceKit -+// -+ -+import CryptoKit -+import Foundation -+import HealthKit -+import LoopKit -+import NightscoutKit -+ -+/// A profile saved with the Profiles settings screen, read from the file it was saved to. -+struct SavedProfile: Decodable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ -+ private static var directory: URL { -+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") -+ } -+ -+ static func loadAll() -> [SavedProfile] { -+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] -+ return files -+ .filter { $0.pathExtension == "json" } -+ .compactMap { try? Data(contentsOf: $0) } -+ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } -+ } -+ -+ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. -+ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { -+ var store: [String: ProfileSet.Profile] = [:] -+ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. -+ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { -+ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) -+ } -+ return store -+ } -+ -+ /// True when this profile uploads with this fingerprint, in either glucose unit. -+ func matches(fingerprint: String) -> Bool { -+ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { -+ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint -+ } -+ } -+ -+ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { -+ guard let correctionRange = correctionRange.schedule(for: unit), -+ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) -+ else { -+ return nil -+ } -+ return ProfileSet.Profile( -+ timezone: basalRateSchedule.timeZone, -+ dia: .hours(6), -+ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, -+ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, -+ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) -+ } -+} -+ -+extension ProfileSet.Profile { -+ /// Followers compute the same value from the uploaded profile and send it with a switch command, -+ /// so Loop only activates the values the follower previewed. -+ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` -+ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. -+ var remoteFingerprint: String { -+ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { -+ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") -+ } -+ let canonical = [ -+ "basal=" + items(basal), -+ "carbratio=" + items(carbratio), -+ "sens=" + items(sensitivity), -+ "target_low=" + items(targetLow), -+ "target_high=" + items(targetHigh), -+ ].joined(separator: ";") -+ let digest = SHA256.hash(data: Data(canonical.utf8)) -+ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) -+ } -+} -+ -+enum RemoteProfileSwitchError: LocalizedError { -+ case profileChanged -+ case unavailable -+ case notActivated(String, Error) -+ -+ /// Followers read the profile name from this text to match the reply to their command. -+ var errorDescription: String? { -+ switch self { -+ case .profileChanged: -+ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") -+ case .unavailable: -+ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") -+ case .notActivated(let name, let error): -+ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index 637988b..7c780ea 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -160,7 +160,7 @@ extension StoredDosingDecision { - recommendedBolus: loopStatusRecommendedBolus, - enacted: automaticDoseDecision?.loopStatusEnacted, - failureReason: automaticDoseDecision?.loopStatusFailureReason, -- testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported]), -+ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]]), - overrideStatus: overrideStatus) - } - -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -index a1769d8..dd7ac28 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -@@ -94,7 +94,8 @@ extension StoredSettings { - return nil - } - -- var store = ["Default": profile] -+ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) -+ store["Default"] = profile - var defaultProfile = "Default" - - if let name = UserDefaults.standard.string(forKey: "currentProfileName") { -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index 91ad56c..29d3563 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -9,6 +9,7 @@ - import Foundation - - public enum Action: Codable { -+ case profileSwitch(ProfileSwitchAction) - case temporaryScheduleOverride(OverrideAction) - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) -@@ -34,6 +35,8 @@ public enum Action: Codable { - return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") - case .temporaryScheduleOverride: - return LocalizedString("Override", comment: "The remote action name for Override") -+ case .profileSwitch: -+ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") - } - } - -@@ -54,6 +57,8 @@ public enum Action: Codable { - return "" - case .temporaryScheduleOverride(let overrideAction): - return overrideAction.name -+ case .profileSwitch(let profileSwitchAction): -+ return profileSwitchAction.name - } - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -new file mode 100644 -index 0000000..ce03ded ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -@@ -0,0 +1,17 @@ -+// -+// ProfileSwitchAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct ProfileSwitchAction: Codable { -+ -+ public let name: String -+ public let fingerprint: String -+ -+ public init(name: String, fingerprint: String) { -+ self.name = name -+ self.fingerprint = fingerprint -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 6092bc9..aabb47f 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -49,6 +49,7 @@ class RemoteNotificationResponseManager { - case carbsEdit = "carbs_edit" - case override = "override" - case cancelOverride = "cancel_override" -+ case profileSwitch = "profile_switch" - } - - func sendResponseNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -new file mode 100644 -index 0000000..ebcd6ef ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -0,0 +1,50 @@ -+// -+// ProfileSwitchRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Switches to a saved profile. `expiration` is required: decoding fails without it. -+public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { -+ -+ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. -+ static let commandKey = "profile-switch" -+ -+ public let profileName: String -+ public let fingerprint: String -+ public let remoteAddress: String -+ public let expirationDate: Date -+ public let sentAt: Date -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ public var expiration: Date? { -+ return expirationDate -+ } -+ -+ enum CodingKeys: String, CodingKey { -+ case profileName = "profile-switch" -+ case fingerprint = "profile-switch-fingerprint" -+ case remoteAddress = "remote-address" -+ case expirationDate = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification[commandKey] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index be43210..1ea8b96 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -118,6 +118,8 @@ extension Dictionary { - return try OverrideRemoteNotification(dictionary: self) - } else if OverrideCancelRemoteNotification.includedInNotification(self) { - return try OverrideCancelRemoteNotification(dictionary: self) -+ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { -+ return try ProfileSwitchRemoteNotification(dictionary: self) - } else { - throw RemoteNotificationError.unhandledNotification(self) - } -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -index 331ed5c..bff69c8 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -@@ -123,6 +123,27 @@ class RemoteCommandSourceV1: RemoteCommandSource { - status: .success(date: Date(), syncIdentifier: replacementEntry.syncIdentifier ?? "", completionMessage: nil), - notificationJSON: notification - ) -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ successMessage = "Profile '\(profileSwitchCommand.name)' activated" -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .success(date: Date(), syncIdentifier: "", completionMessage: nil), -+ notificationJSON: notification -+ ) - case .temporaryScheduleOverride(let overrideCommand): - commandType = .override - let override = try await serviceDelegate.enactRemoteOverride( -@@ -260,6 +281,10 @@ public class StoredRemoteNotification: NSObject, Codable { - self.init(notificationType: .carbsEdit(carbEditNotification), notificationJSONData: notificationJSONData) - } - -+ convenience init(profileSwitchNotification: ProfileSwitchRemoteNotification, notificationJSONData: Data) { -+ self.init(notificationType: .profileSwitch(profileSwitchNotification), notificationJSONData: notificationJSONData) -+ } -+ - convenience init(overrideNotification: OverrideRemoteNotification, notificationJSONData: Data) { - self.init(notificationType: .override(overrideNotification), notificationJSONData: notificationJSONData) - } -@@ -347,6 +372,8 @@ public class StoredRemoteNotification: NSObject, Codable { - return carbDeleteNotification - case let .carbsEdit(carbEditNotification): - return carbEditNotification -+ case let .profileSwitch(profileSwitchNotification): -+ return profileSwitchNotification - case let .override(overrideNotification): - return overrideNotification - case let .overrideCancel(overrideCancelNotification): -@@ -377,6 +404,7 @@ public class StoredRemoteNotification: NSObject, Codable { - case carbsEdit(CarbEditRemoteNotification) - case override(OverrideRemoteNotification) - case overrideCancel(OverrideCancelRemoteNotification) -+ case profileSwitch(ProfileSwitchRemoteNotification) - } - } - -@@ -433,6 +461,9 @@ actor RecentNotifications { - } else if let carbEditNotification = remoteNotification as? CarbEditRemoteNotification { - let storedNotification = StoredRemoteNotification(carbEditNotification: carbEditNotification, notificationJSONData: data) - try storeNotification(storedNotification) -+ } else if let profileSwitchNotification = remoteNotification as? ProfileSwitchRemoteNotification { -+ let storedNotification = StoredRemoteNotification(profileSwitchNotification: profileSwitchNotification, notificationJSONData: data) -+ try storeNotification(storedNotification) - } else if let overrideNotification = remoteNotification as? OverrideRemoteNotification { - let storedNotification = StoredRemoteNotification(overrideNotification: overrideNotification, notificationJSONData: data) - try storeNotification(storedNotification) -diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..2fcec94 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -@@ -0,0 +1,71 @@ -+// -+// ProfileSwitchRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+import LoopKit -+import NightscoutKit -+@testable import NightscoutServiceKit -+ -+final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "sent-at": "2023-02-25T20:46:35.778Z", -+ "expiration": "2023-02-25T20:51:35.778Z", -+ "otp": "123456" -+ ] -+ -+ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) -+ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) -+ -+ XCTAssertEqual(profileSwitch.profileName, "Sport") -+ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") -+ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) -+ XCTAssertTrue(profileSwitch.otpValidationRequired()) -+ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { -+ return XCTFail("Expected a profile switch action") -+ } -+ XCTAssertEqual(action.name, "Sport") -+ } -+ -+ func testParseProfileSwitchNotification_MissingExpiration_Fails() { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "otp": "123456" -+ ] -+ -+ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) -+ } -+ -+ func testFingerprint_MatchesFollowerVector() { -+ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { -+ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } -+ } -+ let profile = ProfileSet.Profile( -+ timezone: TimeZone(secondsFromGMT: 3600)!, -+ dia: .hours(6), -+ sensitivity: items([(0, 45)]), -+ carbratio: items([(0, 10)]), -+ basal: items([(0, 0.85), (21600, 1.1)]), -+ targetLow: items([(0, 100)]), -+ targetHigh: items([(0, 110)]), -+ units: "mg/dL") -+ -+ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") -+ } -+} -+ -+private extension ISO8601DateFormatter { -+ static let fractional: ISO8601DateFormatter = { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ }() -+} -diff --git a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -index 0bfd600..bb9423b 100644 ---- a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -+++ b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -@@ -60,6 +60,8 @@ extension StoredRemoteNotification { - return "Edit Carbs" - case .override: - return "Override" -+ case .profileSwitch: -+ return "Profile Switch" - case .overrideCancel: - return "Cancel Override" - } -@@ -84,6 +86,8 @@ extension StoredRemoteNotification { - return String(format: "%.0f g", notification.amount) - case .override(let notification): - return notification.name -+ case .profileSwitch(let notification): -+ return notification.profileName - case .overrideCancel: - return "" - } -diff --git a/remote_profile.loopcustomization b/remote_profile.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/remote_profile/dev_remote_profile_remote_window.patch b/remote_profile/dev_remote_profile_remote_window.patch deleted file mode 100644 index 2dd8db5..0000000 --- a/remote_profile/dev_remote_profile_remote_window.patch +++ /dev/null @@ -1,714 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index 93d1204d..c253e88e 100644 ---- a/Loop/Loop/Managers/DeviceDataManager.swift -+++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -433,6 +433,7 @@ final class DeviceDataManager { - ) - - statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) -+ servicesManager.deviceDataManager = self - - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] - criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 996d1470..17cff75d 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -263,6 +263,38 @@ extension NotificationManager { - } - } - -+ static func sendRemoteProfileNotification(name: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfile", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteProfileFailureNotification(name: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfileFailure", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ private static func remoteProfileNotificationBody(name: String) -> String { -+ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) -+ } -+ - private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { - return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) - } -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 0b498420..f5f0d3c5 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -28,6 +28,7 @@ class ServicesManager { - - weak var servicesManagerDelegate: ServicesManagerDelegate? - weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? -+ weak var deviceDataManager: DeviceDataManager? - - private var services = [Service]() - -@@ -447,3 +448,59 @@ extension Service { - ] - } - } -+ -+// MARK: - RemoteProfileActionDelegate -+ -+extension ServicesManager: RemoteProfileActionDelegate { -+ @MainActor -+ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { -+ do { -+ guard let deviceDataManager else { -+ throw RemoteProfileActionError.unavailable -+ } -+ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) -+ guard deviceDataManager.pumpSupportedIncrements() != nil else { -+ throw RemoteProfileActionError.noPump -+ } -+ guard let profile = try viewModel.savedProfile(named: name) else { -+ throw RemoteProfileActionError.profileNotFound(name) -+ } -+ try profile.verify(with: verify) -+ if case .failure(let error) = viewModel.validateProfile(profile) { -+ throw error -+ } -+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ continuation.resume() -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ viewModel.triggerProfileSync() -+ NotificationManager.sendRemoteProfileNotification(name: name) -+ } catch { -+ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) -+ throw error -+ } -+ } -+ -+ enum RemoteProfileActionError: LocalizedError { -+ case unavailable -+ case noPump -+ case profileNotFound(String) -+ -+ var errorDescription: String? { -+ switch self { -+ case .unavailable: -+ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") -+ case .noPump: -+ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") -+ case .profileNotFound(let name): -+ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) -+ } -+ } -+ } -+} -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index e4279746..6f691598 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -8,6 +8,12 @@ - - import Foundation - -+/// Switches to a saved therapy profile on a remote command. -+public protocol RemoteProfileActionDelegate: AnyObject { -+ /// `verify` receives the schedules about to be activated and throws to reject them. -+ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws -+} -+ - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride - func cancelRemoteOverride() async throws -> TemporaryScheduleOverride -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -index 74724fb7..f48848cd 100644 ---- a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -174,6 +174,7 @@ extension ProfileViewModel { - try jsonData.write(to: fileURL) - - self.loadProfiles() -+ triggerProfileSync() - } catch { - print("An error occurred while renaming profile: \(error)") - } -@@ -270,6 +271,8 @@ extension ProfileViewModel { - removeProfile(profileReference: profileReference) - if getCurrentProfileName() == profile.name { - clearCurrentProfile() -+ } else { -+ triggerProfileSync() - } - } - -@@ -295,6 +298,14 @@ extension ProfileViewModel { - return getProfile - } - -+ /// The saved profile named `name`, or nil when there is none. -+ public func savedProfile(named name: String) throws -> Profile? { -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ return nil -+ } -+ return try getProfile(from: profileReference) -+ } -+ - public func validateProfile(_ profile: Profile) -> Result { - guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { - return .failure(.fileLoadError) -@@ -406,3 +417,10 @@ extension ProfileViewModel { - return isOutOfSync - } - } -+ -+extension Profile { -+ /// Passes the schedules this profile activates to `check`. -+ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { -+ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) -+ } -+} -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index 69f7a26..14a7691 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -24,6 +24,10 @@ - A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; -@@ -158,6 +162,10 @@ - A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; - A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; -@@ -268,6 +276,7 @@ - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, - A934B6172358F49300949C8B /* StoredSettings.swift */, -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, - A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, - A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, -@@ -324,6 +333,7 @@ - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, - A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, - ); - path = NightscoutServiceKitTests; -@@ -364,6 +374,7 @@ - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, - ); - path = Notifications; - sourceTree = ""; -@@ -412,6 +423,7 @@ - A9AA6E5729EB06AA008FFA78 /* Actions */ = { - isa = PBXGroup; - children = ( -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -@@ -744,6 +756,9 @@ - C1398D2227C434FB00416AD6 /* Data.swift in Sources */, - A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, - A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, - C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, - ); -@@ -757,6 +772,7 @@ - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, - A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -new file mode 100644 -index 0000000..de73ca9 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -@@ -0,0 +1,104 @@ -+// -+// SavedProfile.swift -+// NightscoutServiceKit -+// -+ -+import CryptoKit -+import Foundation -+import HealthKit -+import LoopKit -+import NightscoutKit -+ -+/// A profile saved with the Profiles settings screen, read from the file it was saved to. -+struct SavedProfile: Decodable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ -+ private static var directory: URL { -+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") -+ } -+ -+ static func loadAll() -> [SavedProfile] { -+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] -+ return files -+ .filter { $0.pathExtension == "json" } -+ .compactMap { try? Data(contentsOf: $0) } -+ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } -+ } -+ -+ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. -+ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { -+ var store: [String: ProfileSet.Profile] = [:] -+ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. -+ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { -+ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) -+ } -+ return store -+ } -+ -+ /// True when this profile uploads with this fingerprint, in either glucose unit. -+ func matches(fingerprint: String) -> Bool { -+ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { -+ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint -+ } -+ } -+ -+ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { -+ guard let correctionRange = correctionRange.schedule(for: unit), -+ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) -+ else { -+ return nil -+ } -+ return ProfileSet.Profile( -+ timezone: basalRateSchedule.timeZone, -+ dia: .hours(6), -+ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, -+ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, -+ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) -+ } -+} -+ -+extension ProfileSet.Profile { -+ /// Followers compute the same value from the uploaded profile and send it with a switch command, -+ /// so Loop only activates the values the follower previewed. -+ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` -+ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. -+ var remoteFingerprint: String { -+ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { -+ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") -+ } -+ let canonical = [ -+ "basal=" + items(basal), -+ "carbratio=" + items(carbratio), -+ "sens=" + items(sensitivity), -+ "target_low=" + items(targetLow), -+ "target_high=" + items(targetHigh), -+ ].joined(separator: ";") -+ let digest = SHA256.hash(data: Data(canonical.utf8)) -+ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) -+ } -+} -+ -+enum RemoteProfileSwitchError: LocalizedError { -+ case profileChanged -+ case unavailable -+ case notActivated(String, Error) -+ -+ /// Followers read the profile name from this text to match the reply to their command. -+ var errorDescription: String? { -+ switch self { -+ case .profileChanged: -+ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") -+ case .unavailable: -+ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") -+ case .notActivated(let name, let error): -+ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index 4e1d49e..b66fb97 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -159,7 +159,8 @@ extension StoredDosingDecision { - automaticDoseRecommendation: loopStatusAutomaticDoseRecommendation, - recommendedBolus: loopStatusRecommendedBolus, - enacted: automaticDoseDecision?.loopStatusEnacted, -- failureReason: automaticDoseDecision?.loopStatusFailureReason), -+ failureReason: automaticDoseDecision?.loopStatusFailureReason, -+ testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]]), - overrideStatus: overrideStatus) - } - -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -index a1769d8..dd7ac28 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -@@ -94,7 +94,8 @@ extension StoredSettings { - return nil - } - -- var store = ["Default": profile] -+ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) -+ store["Default"] = profile - var defaultProfile = "Default" - - if let name = UserDefaults.standard.string(forKey: "currentProfileName") { -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index a92b509..29313b6 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -9,6 +9,7 @@ - import Foundation - - public enum Action: Codable { -+ case profileSwitch(ProfileSwitchAction) - case temporaryScheduleOverride(OverrideAction) - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) -@@ -28,6 +29,8 @@ public enum Action: Codable { - return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") - case .temporaryScheduleOverride: - return LocalizedString("Override", comment: "The remote action name for Override") -+ case .profileSwitch: -+ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") - } - } - -@@ -43,6 +46,8 @@ public enum Action: Codable { - return "" - case .temporaryScheduleOverride(let overrideAction): - return overrideAction.name -+ case .profileSwitch(let profileSwitchAction): -+ return profileSwitchAction.name - } - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -new file mode 100644 -index 0000000..ce03ded ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -@@ -0,0 +1,17 @@ -+// -+// ProfileSwitchAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct ProfileSwitchAction: Codable { -+ -+ public let name: String -+ public let fingerprint: String -+ -+ public init(name: String, fingerprint: String) { -+ self.name = name -+ self.fingerprint = fingerprint -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 95274e4..4457f80 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -45,6 +45,7 @@ class RemoteNotificationResponseManager { - case carbs = "carbs" - case override = "override" - case cancelOverride = "cancel_override" -+ case profileSwitch = "profile_switch" - } - - func sendResponseNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -new file mode 100644 -index 0000000..ebcd6ef ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -0,0 +1,50 @@ -+// -+// ProfileSwitchRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Switches to a saved profile. `expiration` is required: decoding fails without it. -+public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { -+ -+ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. -+ static let commandKey = "profile-switch" -+ -+ public let profileName: String -+ public let fingerprint: String -+ public let remoteAddress: String -+ public let expirationDate: Date -+ public let sentAt: Date -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ public var expiration: Date? { -+ return expirationDate -+ } -+ -+ enum CodingKeys: String, CodingKey { -+ case profileName = "profile-switch" -+ case fingerprint = "profile-switch-fingerprint" -+ case remoteAddress = "remote-address" -+ case expirationDate = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification[commandKey] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index 24e63d7..a0f58a0 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -104,6 +104,8 @@ extension Dictionary { - return try OverrideRemoteNotification(dictionary: self) - } else if OverrideCancelRemoteNotification.includedInNotification(self) { - return try OverrideCancelRemoteNotification(dictionary: self) -+ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { -+ return try ProfileSwitchRemoteNotification(dictionary: self) - } else { - throw RemoteNotificationError.unhandledNotification(self) - } -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -index 84ff118..0912feb 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -@@ -96,6 +96,27 @@ class RemoteCommandSourceV1: RemoteCommandSource { - status: .success(date: Date(), syncIdentifier: carbEntry.syncIdentifier ?? "", completionMessage: nil), - notificationJSON: notification - ) -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ successMessage = "Profile '\(profileSwitchCommand.name)' activated" -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .success(date: Date(), syncIdentifier: "", completionMessage: nil), -+ notificationJSON: notification -+ ) - case .temporaryScheduleOverride(let overrideCommand): - commandType = .override - let override = try await serviceDelegate.enactRemoteOverride( -@@ -223,6 +244,10 @@ public class StoredRemoteNotification: NSObject, Codable { - self.init(notificationType: .carbs(carbNotification), notificationJSONData: notificationJSONData) - } - -+ convenience init(profileSwitchNotification: ProfileSwitchRemoteNotification, notificationJSONData: Data) { -+ self.init(notificationType: .profileSwitch(profileSwitchNotification), notificationJSONData: notificationJSONData) -+ } -+ - convenience init(overrideNotification: OverrideRemoteNotification, notificationJSONData: Data) { - self.init(notificationType: .override(overrideNotification), notificationJSONData: notificationJSONData) - } -@@ -306,6 +331,8 @@ public class StoredRemoteNotification: NSObject, Codable { - return bolusNotification - case let .carbs(carbNotification): - return carbNotification -+ case let .profileSwitch(profileSwitchNotification): -+ return profileSwitchNotification - case let .override(overrideNotification): - return overrideNotification - case let .overrideCancel(overrideCancelNotification): -@@ -334,6 +361,7 @@ public class StoredRemoteNotification: NSObject, Codable { - case carbs(CarbRemoteNotification) - case override(OverrideRemoteNotification) - case overrideCancel(OverrideCancelRemoteNotification) -+ case profileSwitch(ProfileSwitchRemoteNotification) - } - } - -@@ -384,6 +412,9 @@ actor RecentNotifications { - } else if let carbNotification = remoteNotification as? CarbRemoteNotification { - let storedNotification = StoredRemoteNotification(carbNotification: carbNotification, notificationJSONData: data) - try storeNotification(storedNotification) -+ } else if let profileSwitchNotification = remoteNotification as? ProfileSwitchRemoteNotification { -+ let storedNotification = StoredRemoteNotification(profileSwitchNotification: profileSwitchNotification, notificationJSONData: data) -+ try storeNotification(storedNotification) - } else if let overrideNotification = remoteNotification as? OverrideRemoteNotification { - let storedNotification = StoredRemoteNotification(overrideNotification: overrideNotification, notificationJSONData: data) - try storeNotification(storedNotification) -diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..2fcec94 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -@@ -0,0 +1,71 @@ -+// -+// ProfileSwitchRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+import LoopKit -+import NightscoutKit -+@testable import NightscoutServiceKit -+ -+final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "sent-at": "2023-02-25T20:46:35.778Z", -+ "expiration": "2023-02-25T20:51:35.778Z", -+ "otp": "123456" -+ ] -+ -+ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) -+ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) -+ -+ XCTAssertEqual(profileSwitch.profileName, "Sport") -+ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") -+ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) -+ XCTAssertTrue(profileSwitch.otpValidationRequired()) -+ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { -+ return XCTFail("Expected a profile switch action") -+ } -+ XCTAssertEqual(action.name, "Sport") -+ } -+ -+ func testParseProfileSwitchNotification_MissingExpiration_Fails() { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "otp": "123456" -+ ] -+ -+ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) -+ } -+ -+ func testFingerprint_MatchesFollowerVector() { -+ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { -+ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } -+ } -+ let profile = ProfileSet.Profile( -+ timezone: TimeZone(secondsFromGMT: 3600)!, -+ dia: .hours(6), -+ sensitivity: items([(0, 45)]), -+ carbratio: items([(0, 10)]), -+ basal: items([(0, 0.85), (21600, 1.1)]), -+ targetLow: items([(0, 100)]), -+ targetHigh: items([(0, 110)]), -+ units: "mg/dL") -+ -+ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") -+ } -+} -+ -+private extension ISO8601DateFormatter { -+ static let fractional: ISO8601DateFormatter = { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ }() -+} -diff --git a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -index 2a3fff3..4ce6ab7 100644 ---- a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -+++ b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -@@ -56,6 +56,8 @@ extension StoredRemoteNotification { - return "Carbs" - case .override: - return "Override" -+ case .profileSwitch: -+ return "Profile Switch" - case .overrideCancel: - return "Cancel Override" - } -@@ -76,6 +78,8 @@ extension StoredRemoteNotification { - return String(format: "%.0f g", notification.amount) - case .override(let notification): - return notification.name -+ case .profileSwitch(let notification): -+ return notification.profileName - case .overrideCancel: - return "" - } -diff --git a/remote_profile.loopcustomization b/remote_profile.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/remote_profile/nextdev_remote_profile.patch b/remote_profile/nextdev_remote_profile.patch deleted file mode 100644 index 0f65390..0000000 --- a/remote_profile/nextdev_remote_profile.patch +++ /dev/null @@ -1,660 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index 6b70853e..5591c9a5 100644 ---- a/Loop/Loop/Managers/DeviceDataManager.swift -+++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -1411,6 +1411,7 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { - NotificationCenter.default.post(name: .LoopDataUpdated, - object: nil, - userInfo: [LoopDataManager.LoopUpdateContextKey: LoopUpdateContext.preferences.rawValue]) -+ settingsManager.storeSettings() - } - - func saveCompletion(therapySettings: TherapySettings) { -diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift -index 6b263045..fe3c3e7f 100644 ---- a/Loop/Loop/Managers/LoopAppManager.swift -+++ b/Loop/Loop/Managers/LoopAppManager.swift -@@ -401,6 +401,7 @@ class LoopAppManager: NSObject { - - dosingDecisionStore.delegate = deviceDataManager - remoteDataServicesManager.delegate = deviceDataManager -+ servicesManager.therapySettingsViewModelDelegate = deviceDataManager - - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceDataManager.deviceLog, alertManager.alertStore] - criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 42eff19f..c4d0f32e 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -298,6 +298,38 @@ extension NotificationManager { - UNUserNotificationCenter.current().add(request) - } - -+ static func sendRemoteProfileNotification(name: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfile", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteProfileFailureNotification(name: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfileFailure", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ private static func remoteProfileNotificationBody(name: String) -> String { -+ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) -+ } -+ - private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { - return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) - } -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index b557f184..15602f08 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -30,6 +30,7 @@ class ServicesManager { - weak var servicesManagerDelegate: ServicesManagerDelegate? - weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? - weak var supportManager: SupportManager? -+ weak var therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? - - private var services = [Service]() - -@@ -423,3 +424,58 @@ extension Service { - ] - } - } -+ -+// MARK: - RemoteProfileActionDelegate -+ -+extension ServicesManager: RemoteProfileActionDelegate { -+ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { -+ do { -+ guard let therapySettingsViewModelDelegate else { -+ throw RemoteProfileActionError.unavailable -+ } -+ let viewModel = ProfileViewModel(therapySettings: settingsManager.therapySettings, delegate: therapySettingsViewModelDelegate) -+ guard therapySettingsViewModelDelegate.pumpSupportedIncrements() != nil else { -+ throw RemoteProfileActionError.noPump -+ } -+ guard let profile = try viewModel.savedProfile(named: name) else { -+ throw RemoteProfileActionError.profileNotFound(name) -+ } -+ try profile.verify(with: verify) -+ if case .failure(let error) = viewModel.validateProfile(profile) { -+ throw error -+ } -+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ continuation.resume() -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ viewModel.triggerProfileSync() -+ NotificationManager.sendRemoteProfileNotification(name: name) -+ } catch { -+ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) -+ throw error -+ } -+ } -+ -+ enum RemoteProfileActionError: LocalizedError { -+ case unavailable -+ case noPump -+ case profileNotFound(String) -+ -+ var errorDescription: String? { -+ switch self { -+ case .unavailable: -+ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: profile view model delegate missing.") -+ case .noPump: -+ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") -+ case .profileNotFound(let name): -+ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) -+ } -+ } -+ } -+} -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 8f824906..a7c9c653 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -8,6 +8,12 @@ - - import Foundation - -+/// Switches to a saved therapy profile on a remote command. -+public protocol RemoteProfileActionDelegate: AnyObject { -+ /// `verify` receives the schedules about to be activated and throws to reject them. -+ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws -+} -+ - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws - func cancelRemoteOverride() async throws -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -index cd8d48b8..0a287cac 100644 ---- a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -175,6 +175,7 @@ extension ProfileViewModel { - try jsonData.write(to: fileURL) - - self.loadProfiles() -+ triggerProfileSync() - } catch { - print("An error occurred while renaming profile: \(error)") - } -@@ -271,6 +272,8 @@ extension ProfileViewModel { - removeProfile(profileReference: profileReference) - if getCurrentProfileName() == profile.name { - clearCurrentProfile() -+ } else { -+ triggerProfileSync() - } - } - -@@ -296,6 +299,14 @@ extension ProfileViewModel { - return getProfile - } - -+ /// The saved profile named `name`, or nil when there is none. -+ public func savedProfile(named name: String) throws -> Profile? { -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ return nil -+ } -+ return try getProfile(from: profileReference) -+ } -+ - public func validateProfile(_ profile: Profile) -> Result { - guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { - return .failure(.fileLoadError) -@@ -407,3 +418,10 @@ extension ProfileViewModel { - return isOutOfSync - } - } -+ -+extension Profile { -+ /// Passes the schedules this profile activates to `check`. -+ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { -+ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) -+ } -+} -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index 00096e2..cf6883e 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -23,6 +23,10 @@ - A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; -@@ -156,6 +160,10 @@ - A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; - A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; -@@ -265,6 +273,7 @@ - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, - A934B6172358F49300949C8B /* StoredSettings.swift */, -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, - A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, - A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, -@@ -321,6 +330,7 @@ - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, - A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, - ); - path = NightscoutServiceKitTests; -@@ -361,6 +371,7 @@ - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, - ); - path = Notifications; - sourceTree = ""; -@@ -409,6 +420,7 @@ - A9AA6E5729EB06AA008FFA78 /* Actions */ = { - isa = PBXGroup; - children = ( -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -@@ -740,6 +752,9 @@ - C1398D2227C434FB00416AD6 /* Data.swift in Sources */, - A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, - A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, - C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, - ); -@@ -753,6 +768,7 @@ - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, - A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -new file mode 100644 -index 0000000..588ded1 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -@@ -0,0 +1,104 @@ -+// -+// SavedProfile.swift -+// NightscoutServiceKit -+// -+ -+import CryptoKit -+import Foundation -+import LoopAlgorithm -+import LoopKit -+import NightscoutKit -+ -+/// A profile saved with the Profiles settings screen, read from the file it was saved to. -+struct SavedProfile: Decodable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ -+ private static var directory: URL { -+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") -+ } -+ -+ static func loadAll() -> [SavedProfile] { -+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] -+ return files -+ .filter { $0.pathExtension == "json" } -+ .compactMap { try? Data(contentsOf: $0) } -+ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } -+ } -+ -+ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. -+ static func nightscoutStore(unit: LoopUnit) -> [String: ProfileSet.Profile] { -+ var store: [String: ProfileSet.Profile] = [:] -+ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. -+ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { -+ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) -+ } -+ return store -+ } -+ -+ /// True when this profile uploads with this fingerprint, in either glucose unit. -+ func matches(fingerprint: String) -> Bool { -+ [LoopUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { -+ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint -+ } -+ } -+ -+ func nightscoutProfile(unit: LoopUnit) -> ProfileSet.Profile? { -+ guard let correctionRange = correctionRange.schedule(for: unit), -+ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) -+ else { -+ return nil -+ } -+ return ProfileSet.Profile( -+ timezone: basalRateSchedule.timeZone, -+ dia: .hours(6), -+ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, -+ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, -+ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) -+ } -+} -+ -+extension ProfileSet.Profile { -+ /// Followers compute the same value from the uploaded profile and send it with a switch command, -+ /// so Loop only activates the values the follower previewed. -+ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` -+ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. -+ var remoteFingerprint: String { -+ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { -+ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") -+ } -+ let canonical = [ -+ "basal=" + items(basal), -+ "carbratio=" + items(carbratio), -+ "sens=" + items(sensitivity), -+ "target_low=" + items(targetLow), -+ "target_high=" + items(targetHigh), -+ ].joined(separator: ";") -+ let digest = SHA256.hash(data: Data(canonical.utf8)) -+ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) -+ } -+} -+ -+enum RemoteProfileSwitchError: LocalizedError { -+ case profileChanged -+ case unavailable -+ case notActivated(String, Error) -+ -+ /// Followers read the profile name from this text to match the reply to their command. -+ var errorDescription: String? { -+ switch self { -+ case .profileChanged: -+ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") -+ case .unavailable: -+ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") -+ case .notActivated(let name, let error): -+ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index 2f12b47..2dec600 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -180,6 +180,7 @@ extension StoredDosingDecision { - enacted: automaticDoseDecision?.loopStatusEnacted, - failureReason: automaticDoseDecision?.loopStatusFailureReason, - failureCode: automaticDoseDecision?.errors.first?.id, -+ testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]], - algorithmExperiments: (automaticDoseDecision?.algorithmExperiments ?? algorithmExperiments)?.map { $0.jsonObject }), - overrideStatus: overrideStatus) - } -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -index 790330a..ae6fb9a 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -@@ -93,7 +93,8 @@ extension StoredSettings { - return nil - } - -- var store = ["Default": profile] -+ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) -+ store["Default"] = profile - var defaultProfile = "Default" - - if let name = UserDefaults.standard.string(forKey: "currentProfileName") { -diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index cd6d2c6..73db786 100644 ---- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift -+++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -459,6 +459,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - success = true - message = "Override '\(overrideCommand.name)' enacted successfully" - -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ success = true -+ message = "Profile '\(profileSwitchCommand.name)' activated" -+ - case .cancelTemporaryOverride: - commandType = .cancelOverride - try await self.serviceDelegate?.cancelRemoteOverride() -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index a92b509..29313b6 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -9,6 +9,7 @@ - import Foundation - - public enum Action: Codable { -+ case profileSwitch(ProfileSwitchAction) - case temporaryScheduleOverride(OverrideAction) - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) -@@ -28,6 +29,8 @@ public enum Action: Codable { - return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") - case .temporaryScheduleOverride: - return LocalizedString("Override", comment: "The remote action name for Override") -+ case .profileSwitch: -+ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") - } - } - -@@ -43,6 +46,8 @@ public enum Action: Codable { - return "" - case .temporaryScheduleOverride(let overrideAction): - return overrideAction.name -+ case .profileSwitch(let profileSwitchAction): -+ return profileSwitchAction.name - } - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -new file mode 100644 -index 0000000..ce03ded ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -@@ -0,0 +1,17 @@ -+// -+// ProfileSwitchAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct ProfileSwitchAction: Codable { -+ -+ public let name: String -+ public let fingerprint: String -+ -+ public init(name: String, fingerprint: String) { -+ self.name = name -+ self.fingerprint = fingerprint -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 95274e4..4457f80 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -45,6 +45,7 @@ class RemoteNotificationResponseManager { - case carbs = "carbs" - case override = "override" - case cancelOverride = "cancel_override" -+ case profileSwitch = "profile_switch" - } - - func sendResponseNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -new file mode 100644 -index 0000000..487c620 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -0,0 +1,50 @@ -+// -+// ProfileSwitchRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Switches to a saved profile. `expiration` is required: decoding fails without it. -+public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { -+ -+ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. -+ static let commandKey = "profile-switch" -+ -+ public let profileName: String -+ public let fingerprint: String -+ public let remoteAddress: String -+ public let expirationDate: Date -+ public let sentAt: Date? -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ public var expiration: Date? { -+ return expirationDate -+ } -+ -+ enum CodingKeys: String, CodingKey { -+ case profileName = "profile-switch" -+ case fingerprint = "profile-switch-fingerprint" -+ case remoteAddress = "remote-address" -+ case expirationDate = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification[commandKey] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index 0c5f8e3..3362bf7 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -108,6 +108,8 @@ extension Dictionary { - return try OverrideRemoteNotification(dictionary: self) - } else if OverrideCancelRemoteNotification.includedInNotification(self) { - return try OverrideCancelRemoteNotification(dictionary: self) -+ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { -+ return try ProfileSwitchRemoteNotification(dictionary: self) - } else { - throw RemoteNotificationError.unhandledNotification(self) - } -diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..2fcec94 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -@@ -0,0 +1,71 @@ -+// -+// ProfileSwitchRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+import LoopKit -+import NightscoutKit -+@testable import NightscoutServiceKit -+ -+final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "sent-at": "2023-02-25T20:46:35.778Z", -+ "expiration": "2023-02-25T20:51:35.778Z", -+ "otp": "123456" -+ ] -+ -+ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) -+ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) -+ -+ XCTAssertEqual(profileSwitch.profileName, "Sport") -+ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") -+ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) -+ XCTAssertTrue(profileSwitch.otpValidationRequired()) -+ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { -+ return XCTFail("Expected a profile switch action") -+ } -+ XCTAssertEqual(action.name, "Sport") -+ } -+ -+ func testParseProfileSwitchNotification_MissingExpiration_Fails() { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "otp": "123456" -+ ] -+ -+ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) -+ } -+ -+ func testFingerprint_MatchesFollowerVector() { -+ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { -+ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } -+ } -+ let profile = ProfileSet.Profile( -+ timezone: TimeZone(secondsFromGMT: 3600)!, -+ dia: .hours(6), -+ sensitivity: items([(0, 45)]), -+ carbratio: items([(0, 10)]), -+ basal: items([(0, 0.85), (21600, 1.1)]), -+ targetLow: items([(0, 100)]), -+ targetHigh: items([(0, 110)]), -+ units: "mg/dL") -+ -+ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") -+ } -+} -+ -+private extension ISO8601DateFormatter { -+ static let fractional: ISO8601DateFormatter = { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ }() -+} -diff --git a/remote_profile.loopcustomization b/remote_profile.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/remote_profile/nextdev_remote_profile_remote_carb_edit.patch b/remote_profile/nextdev_remote_profile_remote_carb_edit.patch deleted file mode 100644 index bb9d0f5..0000000 --- a/remote_profile/nextdev_remote_profile_remote_carb_edit.patch +++ /dev/null @@ -1,661 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index 6b70853e..5591c9a5 100644 ---- a/Loop/Loop/Managers/DeviceDataManager.swift -+++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -1411,6 +1411,7 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { - NotificationCenter.default.post(name: .LoopDataUpdated, - object: nil, - userInfo: [LoopDataManager.LoopUpdateContextKey: LoopUpdateContext.preferences.rawValue]) -+ settingsManager.storeSettings() - } - - func saveCompletion(therapySettings: TherapySettings) { -diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift -index 6b263045..fe3c3e7f 100644 ---- a/Loop/Loop/Managers/LoopAppManager.swift -+++ b/Loop/Loop/Managers/LoopAppManager.swift -@@ -401,6 +401,7 @@ class LoopAppManager: NSObject { - - dosingDecisionStore.delegate = deviceDataManager - remoteDataServicesManager.delegate = deviceDataManager -+ servicesManager.therapySettingsViewModelDelegate = deviceDataManager - - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceDataManager.deviceLog, alertManager.alertStore] - criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 18975ce1..1cf442dd 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -326,6 +326,38 @@ extension NotificationManager { - UNUserNotificationCenter.current().add(request) - } - -+ static func sendRemoteProfileNotification(name: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfile", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteProfileFailureNotification(name: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfileFailure", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ private static func remoteProfileNotificationBody(name: String) -> String { -+ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) -+ } -+ - private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { - return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) - } -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 56cb3642..ffafea70 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -30,6 +30,7 @@ class ServicesManager { - weak var servicesManagerDelegate: ServicesManagerDelegate? - weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? - weak var supportManager: SupportManager? -+ weak var therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? - - private var services = [Service]() - -@@ -467,3 +468,58 @@ extension Service { - ] - } - } -+ -+// MARK: - RemoteProfileActionDelegate -+ -+extension ServicesManager: RemoteProfileActionDelegate { -+ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { -+ do { -+ guard let therapySettingsViewModelDelegate else { -+ throw RemoteProfileActionError.unavailable -+ } -+ let viewModel = ProfileViewModel(therapySettings: settingsManager.therapySettings, delegate: therapySettingsViewModelDelegate) -+ guard therapySettingsViewModelDelegate.pumpSupportedIncrements() != nil else { -+ throw RemoteProfileActionError.noPump -+ } -+ guard let profile = try viewModel.savedProfile(named: name) else { -+ throw RemoteProfileActionError.profileNotFound(name) -+ } -+ try profile.verify(with: verify) -+ if case .failure(let error) = viewModel.validateProfile(profile) { -+ throw error -+ } -+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ continuation.resume() -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ viewModel.triggerProfileSync() -+ NotificationManager.sendRemoteProfileNotification(name: name) -+ } catch { -+ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) -+ throw error -+ } -+ } -+ -+ enum RemoteProfileActionError: LocalizedError { -+ case unavailable -+ case noPump -+ case profileNotFound(String) -+ -+ var errorDescription: String? { -+ switch self { -+ case .unavailable: -+ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: profile view model delegate missing.") -+ case .noPump: -+ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") -+ case .profileNotFound(let name): -+ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) -+ } -+ } -+ } -+} -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 6d3f9db2..5f1f37f3 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -8,6 +8,12 @@ - - import Foundation - -+/// Switches to a saved therapy profile on a remote command. -+public protocol RemoteProfileActionDelegate: AnyObject { -+ /// `verify` receives the schedules about to be activated and throws to reject them. -+ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws -+} -+ - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws - func cancelRemoteOverride() async throws -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -index cd8d48b8..0a287cac 100644 ---- a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -175,6 +175,7 @@ extension ProfileViewModel { - try jsonData.write(to: fileURL) - - self.loadProfiles() -+ triggerProfileSync() - } catch { - print("An error occurred while renaming profile: \(error)") - } -@@ -271,6 +272,8 @@ extension ProfileViewModel { - removeProfile(profileReference: profileReference) - if getCurrentProfileName() == profile.name { - clearCurrentProfile() -+ } else { -+ triggerProfileSync() - } - } - -@@ -296,6 +299,14 @@ extension ProfileViewModel { - return getProfile - } - -+ /// The saved profile named `name`, or nil when there is none. -+ public func savedProfile(named name: String) throws -> Profile? { -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ return nil -+ } -+ return try getProfile(from: profileReference) -+ } -+ - public func validateProfile(_ profile: Profile) -> Result { - guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { - return .failure(.fileLoadError) -@@ -407,3 +418,10 @@ extension ProfileViewModel { - return isOutOfSync - } - } -+ -+extension Profile { -+ /// Passes the schedules this profile activates to `check`. -+ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { -+ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) -+ } -+} -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index ea85f5a..eb8f03d 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -23,6 +23,10 @@ - A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; -@@ -163,6 +167,10 @@ - A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; - A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; -@@ -279,6 +287,7 @@ - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, - A934B6172358F49300949C8B /* StoredSettings.swift */, -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, - A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, - A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, -@@ -338,6 +347,7 @@ - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, - A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, - ); - path = NightscoutServiceKitTests; -@@ -380,6 +390,7 @@ - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, - ); - path = Notifications; - sourceTree = ""; -@@ -428,6 +439,7 @@ - A9AA6E5729EB06AA008FFA78 /* Actions */ = { - isa = PBXGroup; - children = ( -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -@@ -765,6 +777,9 @@ - C1398D2227C434FB00416AD6 /* Data.swift in Sources */, - A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, - A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, - C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, - ); -@@ -781,6 +796,7 @@ - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, - A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -new file mode 100644 -index 0000000..588ded1 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -@@ -0,0 +1,104 @@ -+// -+// SavedProfile.swift -+// NightscoutServiceKit -+// -+ -+import CryptoKit -+import Foundation -+import LoopAlgorithm -+import LoopKit -+import NightscoutKit -+ -+/// A profile saved with the Profiles settings screen, read from the file it was saved to. -+struct SavedProfile: Decodable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ -+ private static var directory: URL { -+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") -+ } -+ -+ static func loadAll() -> [SavedProfile] { -+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] -+ return files -+ .filter { $0.pathExtension == "json" } -+ .compactMap { try? Data(contentsOf: $0) } -+ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } -+ } -+ -+ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. -+ static func nightscoutStore(unit: LoopUnit) -> [String: ProfileSet.Profile] { -+ var store: [String: ProfileSet.Profile] = [:] -+ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. -+ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { -+ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) -+ } -+ return store -+ } -+ -+ /// True when this profile uploads with this fingerprint, in either glucose unit. -+ func matches(fingerprint: String) -> Bool { -+ [LoopUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { -+ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint -+ } -+ } -+ -+ func nightscoutProfile(unit: LoopUnit) -> ProfileSet.Profile? { -+ guard let correctionRange = correctionRange.schedule(for: unit), -+ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) -+ else { -+ return nil -+ } -+ return ProfileSet.Profile( -+ timezone: basalRateSchedule.timeZone, -+ dia: .hours(6), -+ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, -+ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, -+ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) -+ } -+} -+ -+extension ProfileSet.Profile { -+ /// Followers compute the same value from the uploaded profile and send it with a switch command, -+ /// so Loop only activates the values the follower previewed. -+ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` -+ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. -+ var remoteFingerprint: String { -+ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { -+ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") -+ } -+ let canonical = [ -+ "basal=" + items(basal), -+ "carbratio=" + items(carbratio), -+ "sens=" + items(sensitivity), -+ "target_low=" + items(targetLow), -+ "target_high=" + items(targetHigh), -+ ].joined(separator: ";") -+ let digest = SHA256.hash(data: Data(canonical.utf8)) -+ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) -+ } -+} -+ -+enum RemoteProfileSwitchError: LocalizedError { -+ case profileChanged -+ case unavailable -+ case notActivated(String, Error) -+ -+ /// Followers read the profile name from this text to match the reply to their command. -+ var errorDescription: String? { -+ switch self { -+ case .profileChanged: -+ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") -+ case .unavailable: -+ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") -+ case .notActivated(let name, let error): -+ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index e7bb1ad..1b43f76 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -180,7 +180,7 @@ extension StoredDosingDecision { - enacted: automaticDoseDecision?.loopStatusEnacted, - failureReason: automaticDoseDecision?.loopStatusFailureReason, - failureCode: automaticDoseDecision?.errors.first?.id, -- testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported], -+ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]], - algorithmExperiments: (automaticDoseDecision?.algorithmExperiments ?? algorithmExperiments)?.map { $0.jsonObject }), - overrideStatus: overrideStatus) - } -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -index 790330a..ae6fb9a 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -@@ -93,7 +93,8 @@ extension StoredSettings { - return nil - } - -- var store = ["Default": profile] -+ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) -+ store["Default"] = profile - var defaultProfile = "Default" - - if let name = UserDefaults.standard.string(forKey: "currentProfileName") { -diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index fe3c774..3a513fb 100644 ---- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift -+++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -460,6 +460,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - success = true - message = "Override '\(overrideCommand.name)' enacted successfully" - -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ success = true -+ message = "Profile '\(profileSwitchCommand.name)' activated" -+ - case .cancelTemporaryOverride: - commandType = .cancelOverride - try await self.serviceDelegate?.cancelRemoteOverride() -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index 91ad56c..29d3563 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -9,6 +9,7 @@ - import Foundation - - public enum Action: Codable { -+ case profileSwitch(ProfileSwitchAction) - case temporaryScheduleOverride(OverrideAction) - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) -@@ -34,6 +35,8 @@ public enum Action: Codable { - return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") - case .temporaryScheduleOverride: - return LocalizedString("Override", comment: "The remote action name for Override") -+ case .profileSwitch: -+ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") - } - } - -@@ -54,6 +57,8 @@ public enum Action: Codable { - return "" - case .temporaryScheduleOverride(let overrideAction): - return overrideAction.name -+ case .profileSwitch(let profileSwitchAction): -+ return profileSwitchAction.name - } - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -new file mode 100644 -index 0000000..ce03ded ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -@@ -0,0 +1,17 @@ -+// -+// ProfileSwitchAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct ProfileSwitchAction: Codable { -+ -+ public let name: String -+ public let fingerprint: String -+ -+ public init(name: String, fingerprint: String) { -+ self.name = name -+ self.fingerprint = fingerprint -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 6092bc9..aabb47f 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -49,6 +49,7 @@ class RemoteNotificationResponseManager { - case carbsEdit = "carbs_edit" - case override = "override" - case cancelOverride = "cancel_override" -+ case profileSwitch = "profile_switch" - } - - func sendResponseNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -new file mode 100644 -index 0000000..487c620 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -0,0 +1,50 @@ -+// -+// ProfileSwitchRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Switches to a saved profile. `expiration` is required: decoding fails without it. -+public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { -+ -+ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. -+ static let commandKey = "profile-switch" -+ -+ public let profileName: String -+ public let fingerprint: String -+ public let remoteAddress: String -+ public let expirationDate: Date -+ public let sentAt: Date? -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ public var expiration: Date? { -+ return expirationDate -+ } -+ -+ enum CodingKeys: String, CodingKey { -+ case profileName = "profile-switch" -+ case fingerprint = "profile-switch-fingerprint" -+ case remoteAddress = "remote-address" -+ case expirationDate = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification[commandKey] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index 6f0524d..87f1e92 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -122,6 +122,8 @@ extension Dictionary { - return try OverrideRemoteNotification(dictionary: self) - } else if OverrideCancelRemoteNotification.includedInNotification(self) { - return try OverrideCancelRemoteNotification(dictionary: self) -+ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { -+ return try ProfileSwitchRemoteNotification(dictionary: self) - } else { - throw RemoteNotificationError.unhandledNotification(self) - } -diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..2fcec94 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -@@ -0,0 +1,71 @@ -+// -+// ProfileSwitchRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+import LoopKit -+import NightscoutKit -+@testable import NightscoutServiceKit -+ -+final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "sent-at": "2023-02-25T20:46:35.778Z", -+ "expiration": "2023-02-25T20:51:35.778Z", -+ "otp": "123456" -+ ] -+ -+ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) -+ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) -+ -+ XCTAssertEqual(profileSwitch.profileName, "Sport") -+ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") -+ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) -+ XCTAssertTrue(profileSwitch.otpValidationRequired()) -+ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { -+ return XCTFail("Expected a profile switch action") -+ } -+ XCTAssertEqual(action.name, "Sport") -+ } -+ -+ func testParseProfileSwitchNotification_MissingExpiration_Fails() { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "otp": "123456" -+ ] -+ -+ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) -+ } -+ -+ func testFingerprint_MatchesFollowerVector() { -+ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { -+ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } -+ } -+ let profile = ProfileSet.Profile( -+ timezone: TimeZone(secondsFromGMT: 3600)!, -+ dia: .hours(6), -+ sensitivity: items([(0, 45)]), -+ carbratio: items([(0, 10)]), -+ basal: items([(0, 0.85), (21600, 1.1)]), -+ targetLow: items([(0, 100)]), -+ targetHigh: items([(0, 110)]), -+ units: "mg/dL") -+ -+ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") -+ } -+} -+ -+private extension ISO8601DateFormatter { -+ static let fractional: ISO8601DateFormatter = { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ }() -+} -diff --git a/remote_profile.loopcustomization b/remote_profile.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/remote_profile/nextdev_remote_profile_remote_carb_edit_remote_window.patch b/remote_profile/nextdev_remote_profile_remote_carb_edit_remote_window.patch deleted file mode 100644 index 5b12373..0000000 --- a/remote_profile/nextdev_remote_profile_remote_carb_edit_remote_window.patch +++ /dev/null @@ -1,661 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index 6b70853e..5591c9a5 100644 ---- a/Loop/Loop/Managers/DeviceDataManager.swift -+++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -1411,6 +1411,7 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { - NotificationCenter.default.post(name: .LoopDataUpdated, - object: nil, - userInfo: [LoopDataManager.LoopUpdateContextKey: LoopUpdateContext.preferences.rawValue]) -+ settingsManager.storeSettings() - } - - func saveCompletion(therapySettings: TherapySettings) { -diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift -index dec6dff3..0b8248c2 100644 ---- a/Loop/Loop/Managers/LoopAppManager.swift -+++ b/Loop/Loop/Managers/LoopAppManager.swift -@@ -401,6 +401,7 @@ class LoopAppManager: NSObject { - - dosingDecisionStore.delegate = deviceDataManager - remoteDataServicesManager.delegate = deviceDataManager -+ servicesManager.therapySettingsViewModelDelegate = deviceDataManager - - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceDataManager.deviceLog, alertManager.alertStore] - criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 18975ce1..1cf442dd 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -326,6 +326,38 @@ extension NotificationManager { - UNUserNotificationCenter.current().add(request) - } - -+ static func sendRemoteProfileNotification(name: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfile", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteProfileFailureNotification(name: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfileFailure", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ private static func remoteProfileNotificationBody(name: String) -> String { -+ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) -+ } -+ - private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { - return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) - } -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 3aaf5212..65a47001 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -30,6 +30,7 @@ class ServicesManager { - weak var servicesManagerDelegate: ServicesManagerDelegate? - weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? - weak var supportManager: SupportManager? -+ weak var therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? - - private var services = [Service]() - -@@ -476,3 +477,58 @@ extension Service { - ] - } - } -+ -+// MARK: - RemoteProfileActionDelegate -+ -+extension ServicesManager: RemoteProfileActionDelegate { -+ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { -+ do { -+ guard let therapySettingsViewModelDelegate else { -+ throw RemoteProfileActionError.unavailable -+ } -+ let viewModel = ProfileViewModel(therapySettings: settingsManager.therapySettings, delegate: therapySettingsViewModelDelegate) -+ guard therapySettingsViewModelDelegate.pumpSupportedIncrements() != nil else { -+ throw RemoteProfileActionError.noPump -+ } -+ guard let profile = try viewModel.savedProfile(named: name) else { -+ throw RemoteProfileActionError.profileNotFound(name) -+ } -+ try profile.verify(with: verify) -+ if case .failure(let error) = viewModel.validateProfile(profile) { -+ throw error -+ } -+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ continuation.resume() -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ viewModel.triggerProfileSync() -+ NotificationManager.sendRemoteProfileNotification(name: name) -+ } catch { -+ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) -+ throw error -+ } -+ } -+ -+ enum RemoteProfileActionError: LocalizedError { -+ case unavailable -+ case noPump -+ case profileNotFound(String) -+ -+ var errorDescription: String? { -+ switch self { -+ case .unavailable: -+ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: profile view model delegate missing.") -+ case .noPump: -+ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") -+ case .profileNotFound(let name): -+ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) -+ } -+ } -+ } -+} -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 7da27b70..39c0eee9 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -8,6 +8,12 @@ - - import Foundation - -+/// Switches to a saved therapy profile on a remote command. -+public protocol RemoteProfileActionDelegate: AnyObject { -+ /// `verify` receives the schedules about to be activated and throws to reject them. -+ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws -+} -+ - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws - func cancelRemoteOverride() async throws -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -index cd8d48b8..0a287cac 100644 ---- a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -175,6 +175,7 @@ extension ProfileViewModel { - try jsonData.write(to: fileURL) - - self.loadProfiles() -+ triggerProfileSync() - } catch { - print("An error occurred while renaming profile: \(error)") - } -@@ -271,6 +272,8 @@ extension ProfileViewModel { - removeProfile(profileReference: profileReference) - if getCurrentProfileName() == profile.name { - clearCurrentProfile() -+ } else { -+ triggerProfileSync() - } - } - -@@ -296,6 +299,14 @@ extension ProfileViewModel { - return getProfile - } - -+ /// The saved profile named `name`, or nil when there is none. -+ public func savedProfile(named name: String) throws -> Profile? { -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ return nil -+ } -+ return try getProfile(from: profileReference) -+ } -+ - public func validateProfile(_ profile: Profile) -> Result { - guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { - return .failure(.fileLoadError) -@@ -407,3 +418,10 @@ extension ProfileViewModel { - return isOutOfSync - } - } -+ -+extension Profile { -+ /// Passes the schedules this profile activates to `check`. -+ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { -+ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) -+ } -+} -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index ea85f5a..eb8f03d 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -23,6 +23,10 @@ - A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; -@@ -163,6 +167,10 @@ - A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; - A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; -@@ -279,6 +287,7 @@ - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, - A934B6172358F49300949C8B /* StoredSettings.swift */, -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, - A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, - A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, -@@ -338,6 +347,7 @@ - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, - A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, - ); - path = NightscoutServiceKitTests; -@@ -380,6 +390,7 @@ - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, - ); - path = Notifications; - sourceTree = ""; -@@ -428,6 +439,7 @@ - A9AA6E5729EB06AA008FFA78 /* Actions */ = { - isa = PBXGroup; - children = ( -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -@@ -765,6 +777,9 @@ - C1398D2227C434FB00416AD6 /* Data.swift in Sources */, - A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, - A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, - C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, - ); -@@ -781,6 +796,7 @@ - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, - A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -new file mode 100644 -index 0000000..588ded1 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -@@ -0,0 +1,104 @@ -+// -+// SavedProfile.swift -+// NightscoutServiceKit -+// -+ -+import CryptoKit -+import Foundation -+import LoopAlgorithm -+import LoopKit -+import NightscoutKit -+ -+/// A profile saved with the Profiles settings screen, read from the file it was saved to. -+struct SavedProfile: Decodable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ -+ private static var directory: URL { -+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") -+ } -+ -+ static func loadAll() -> [SavedProfile] { -+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] -+ return files -+ .filter { $0.pathExtension == "json" } -+ .compactMap { try? Data(contentsOf: $0) } -+ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } -+ } -+ -+ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. -+ static func nightscoutStore(unit: LoopUnit) -> [String: ProfileSet.Profile] { -+ var store: [String: ProfileSet.Profile] = [:] -+ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. -+ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { -+ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) -+ } -+ return store -+ } -+ -+ /// True when this profile uploads with this fingerprint, in either glucose unit. -+ func matches(fingerprint: String) -> Bool { -+ [LoopUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { -+ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint -+ } -+ } -+ -+ func nightscoutProfile(unit: LoopUnit) -> ProfileSet.Profile? { -+ guard let correctionRange = correctionRange.schedule(for: unit), -+ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) -+ else { -+ return nil -+ } -+ return ProfileSet.Profile( -+ timezone: basalRateSchedule.timeZone, -+ dia: .hours(6), -+ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, -+ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, -+ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) -+ } -+} -+ -+extension ProfileSet.Profile { -+ /// Followers compute the same value from the uploaded profile and send it with a switch command, -+ /// so Loop only activates the values the follower previewed. -+ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` -+ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. -+ var remoteFingerprint: String { -+ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { -+ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") -+ } -+ let canonical = [ -+ "basal=" + items(basal), -+ "carbratio=" + items(carbratio), -+ "sens=" + items(sensitivity), -+ "target_low=" + items(targetLow), -+ "target_high=" + items(targetHigh), -+ ].joined(separator: ";") -+ let digest = SHA256.hash(data: Data(canonical.utf8)) -+ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) -+ } -+} -+ -+enum RemoteProfileSwitchError: LocalizedError { -+ case profileChanged -+ case unavailable -+ case notActivated(String, Error) -+ -+ /// Followers read the profile name from this text to match the reply to their command. -+ var errorDescription: String? { -+ switch self { -+ case .profileChanged: -+ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") -+ case .unavailable: -+ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") -+ case .notActivated(let name, let error): -+ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index e7bb1ad..1b43f76 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -180,7 +180,7 @@ extension StoredDosingDecision { - enacted: automaticDoseDecision?.loopStatusEnacted, - failureReason: automaticDoseDecision?.loopStatusFailureReason, - failureCode: automaticDoseDecision?.errors.first?.id, -- testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported], -+ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]], - algorithmExperiments: (automaticDoseDecision?.algorithmExperiments ?? algorithmExperiments)?.map { $0.jsonObject }), - overrideStatus: overrideStatus) - } -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -index 790330a..ae6fb9a 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -@@ -93,7 +93,8 @@ extension StoredSettings { - return nil - } - -- var store = ["Default": profile] -+ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) -+ store["Default"] = profile - var defaultProfile = "Default" - - if let name = UserDefaults.standard.string(forKey: "currentProfileName") { -diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index c3fc48b..64a668c 100644 ---- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift -+++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -474,6 +474,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - success = true - message = "Override '\(overrideCommand.name)' enacted successfully" - -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ success = true -+ message = "Profile '\(profileSwitchCommand.name)' activated" -+ - case .cancelTemporaryOverride: - commandType = .cancelOverride - try await serviceDelegate.cancelRemoteOverride() -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index 91ad56c..29d3563 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -9,6 +9,7 @@ - import Foundation - - public enum Action: Codable { -+ case profileSwitch(ProfileSwitchAction) - case temporaryScheduleOverride(OverrideAction) - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) -@@ -34,6 +35,8 @@ public enum Action: Codable { - return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") - case .temporaryScheduleOverride: - return LocalizedString("Override", comment: "The remote action name for Override") -+ case .profileSwitch: -+ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") - } - } - -@@ -54,6 +57,8 @@ public enum Action: Codable { - return "" - case .temporaryScheduleOverride(let overrideAction): - return overrideAction.name -+ case .profileSwitch(let profileSwitchAction): -+ return profileSwitchAction.name - } - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -new file mode 100644 -index 0000000..ce03ded ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -@@ -0,0 +1,17 @@ -+// -+// ProfileSwitchAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct ProfileSwitchAction: Codable { -+ -+ public let name: String -+ public let fingerprint: String -+ -+ public init(name: String, fingerprint: String) { -+ self.name = name -+ self.fingerprint = fingerprint -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 6092bc9..aabb47f 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -49,6 +49,7 @@ class RemoteNotificationResponseManager { - case carbsEdit = "carbs_edit" - case override = "override" - case cancelOverride = "cancel_override" -+ case profileSwitch = "profile_switch" - } - - func sendResponseNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -new file mode 100644 -index 0000000..ebcd6ef ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -0,0 +1,50 @@ -+// -+// ProfileSwitchRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Switches to a saved profile. `expiration` is required: decoding fails without it. -+public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { -+ -+ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. -+ static let commandKey = "profile-switch" -+ -+ public let profileName: String -+ public let fingerprint: String -+ public let remoteAddress: String -+ public let expirationDate: Date -+ public let sentAt: Date -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ public var expiration: Date? { -+ return expirationDate -+ } -+ -+ enum CodingKeys: String, CodingKey { -+ case profileName = "profile-switch" -+ case fingerprint = "profile-switch-fingerprint" -+ case remoteAddress = "remote-address" -+ case expirationDate = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification[commandKey] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index be43210..1ea8b96 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -118,6 +118,8 @@ extension Dictionary { - return try OverrideRemoteNotification(dictionary: self) - } else if OverrideCancelRemoteNotification.includedInNotification(self) { - return try OverrideCancelRemoteNotification(dictionary: self) -+ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { -+ return try ProfileSwitchRemoteNotification(dictionary: self) - } else { - throw RemoteNotificationError.unhandledNotification(self) - } -diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..2fcec94 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -@@ -0,0 +1,71 @@ -+// -+// ProfileSwitchRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+import LoopKit -+import NightscoutKit -+@testable import NightscoutServiceKit -+ -+final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "sent-at": "2023-02-25T20:46:35.778Z", -+ "expiration": "2023-02-25T20:51:35.778Z", -+ "otp": "123456" -+ ] -+ -+ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) -+ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) -+ -+ XCTAssertEqual(profileSwitch.profileName, "Sport") -+ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") -+ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) -+ XCTAssertTrue(profileSwitch.otpValidationRequired()) -+ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { -+ return XCTFail("Expected a profile switch action") -+ } -+ XCTAssertEqual(action.name, "Sport") -+ } -+ -+ func testParseProfileSwitchNotification_MissingExpiration_Fails() { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "otp": "123456" -+ ] -+ -+ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) -+ } -+ -+ func testFingerprint_MatchesFollowerVector() { -+ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { -+ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } -+ } -+ let profile = ProfileSet.Profile( -+ timezone: TimeZone(secondsFromGMT: 3600)!, -+ dia: .hours(6), -+ sensitivity: items([(0, 45)]), -+ carbratio: items([(0, 10)]), -+ basal: items([(0, 0.85), (21600, 1.1)]), -+ targetLow: items([(0, 100)]), -+ targetHigh: items([(0, 110)]), -+ units: "mg/dL") -+ -+ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") -+ } -+} -+ -+private extension ISO8601DateFormatter { -+ static let fractional: ISO8601DateFormatter = { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ }() -+} -diff --git a/remote_profile.loopcustomization b/remote_profile.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/remote_profile/nextdev_remote_profile_remote_window.patch b/remote_profile/nextdev_remote_profile_remote_window.patch deleted file mode 100644 index 424c434..0000000 --- a/remote_profile/nextdev_remote_profile_remote_window.patch +++ /dev/null @@ -1,660 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index 6b70853e..5591c9a5 100644 ---- a/Loop/Loop/Managers/DeviceDataManager.swift -+++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -1411,6 +1411,7 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { - NotificationCenter.default.post(name: .LoopDataUpdated, - object: nil, - userInfo: [LoopDataManager.LoopUpdateContextKey: LoopUpdateContext.preferences.rawValue]) -+ settingsManager.storeSettings() - } - - func saveCompletion(therapySettings: TherapySettings) { -diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift -index dec6dff3..0b8248c2 100644 ---- a/Loop/Loop/Managers/LoopAppManager.swift -+++ b/Loop/Loop/Managers/LoopAppManager.swift -@@ -401,6 +401,7 @@ class LoopAppManager: NSObject { - - dosingDecisionStore.delegate = deviceDataManager - remoteDataServicesManager.delegate = deviceDataManager -+ servicesManager.therapySettingsViewModelDelegate = deviceDataManager - - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceDataManager.deviceLog, alertManager.alertStore] - criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 42eff19f..c4d0f32e 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -298,6 +298,38 @@ extension NotificationManager { - UNUserNotificationCenter.current().add(request) - } - -+ static func sendRemoteProfileNotification(name: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfile", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteProfileFailureNotification(name: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfileFailure", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ private static func remoteProfileNotificationBody(name: String) -> String { -+ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) -+ } -+ - private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { - return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) - } -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index e81a230b..8e7c86ef 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -30,6 +30,7 @@ class ServicesManager { - weak var servicesManagerDelegate: ServicesManagerDelegate? - weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? - weak var supportManager: SupportManager? -+ weak var therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? - - private var services = [Service]() - -@@ -432,3 +433,58 @@ extension Service { - ] - } - } -+ -+// MARK: - RemoteProfileActionDelegate -+ -+extension ServicesManager: RemoteProfileActionDelegate { -+ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { -+ do { -+ guard let therapySettingsViewModelDelegate else { -+ throw RemoteProfileActionError.unavailable -+ } -+ let viewModel = ProfileViewModel(therapySettings: settingsManager.therapySettings, delegate: therapySettingsViewModelDelegate) -+ guard therapySettingsViewModelDelegate.pumpSupportedIncrements() != nil else { -+ throw RemoteProfileActionError.noPump -+ } -+ guard let profile = try viewModel.savedProfile(named: name) else { -+ throw RemoteProfileActionError.profileNotFound(name) -+ } -+ try profile.verify(with: verify) -+ if case .failure(let error) = viewModel.validateProfile(profile) { -+ throw error -+ } -+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ continuation.resume() -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ viewModel.triggerProfileSync() -+ NotificationManager.sendRemoteProfileNotification(name: name) -+ } catch { -+ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) -+ throw error -+ } -+ } -+ -+ enum RemoteProfileActionError: LocalizedError { -+ case unavailable -+ case noPump -+ case profileNotFound(String) -+ -+ var errorDescription: String? { -+ switch self { -+ case .unavailable: -+ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: profile view model delegate missing.") -+ case .noPump: -+ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") -+ case .profileNotFound(let name): -+ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) -+ } -+ } -+ } -+} -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 90d64a0f..b82f71ed 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -8,6 +8,12 @@ - - import Foundation - -+/// Switches to a saved therapy profile on a remote command. -+public protocol RemoteProfileActionDelegate: AnyObject { -+ /// `verify` receives the schedules about to be activated and throws to reject them. -+ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws -+} -+ - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws - func cancelRemoteOverride() async throws -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -index cd8d48b8..0a287cac 100644 ---- a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -175,6 +175,7 @@ extension ProfileViewModel { - try jsonData.write(to: fileURL) - - self.loadProfiles() -+ triggerProfileSync() - } catch { - print("An error occurred while renaming profile: \(error)") - } -@@ -271,6 +272,8 @@ extension ProfileViewModel { - removeProfile(profileReference: profileReference) - if getCurrentProfileName() == profile.name { - clearCurrentProfile() -+ } else { -+ triggerProfileSync() - } - } - -@@ -296,6 +299,14 @@ extension ProfileViewModel { - return getProfile - } - -+ /// The saved profile named `name`, or nil when there is none. -+ public func savedProfile(named name: String) throws -> Profile? { -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ return nil -+ } -+ return try getProfile(from: profileReference) -+ } -+ - public func validateProfile(_ profile: Profile) -> Result { - guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { - return .failure(.fileLoadError) -@@ -407,3 +418,10 @@ extension ProfileViewModel { - return isOutOfSync - } - } -+ -+extension Profile { -+ /// Passes the schedules this profile activates to `check`. -+ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { -+ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) -+ } -+} -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index 00096e2..cf6883e 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -23,6 +23,10 @@ - A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; -@@ -156,6 +160,10 @@ - A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; - A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; -@@ -265,6 +273,7 @@ - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, - A934B6172358F49300949C8B /* StoredSettings.swift */, -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, - A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, - A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, -@@ -321,6 +330,7 @@ - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, - A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, - ); - path = NightscoutServiceKitTests; -@@ -361,6 +371,7 @@ - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, - ); - path = Notifications; - sourceTree = ""; -@@ -409,6 +420,7 @@ - A9AA6E5729EB06AA008FFA78 /* Actions */ = { - isa = PBXGroup; - children = ( -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -@@ -740,6 +752,9 @@ - C1398D2227C434FB00416AD6 /* Data.swift in Sources */, - A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, - A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, - C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, - ); -@@ -753,6 +768,7 @@ - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, - A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -new file mode 100644 -index 0000000..588ded1 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -@@ -0,0 +1,104 @@ -+// -+// SavedProfile.swift -+// NightscoutServiceKit -+// -+ -+import CryptoKit -+import Foundation -+import LoopAlgorithm -+import LoopKit -+import NightscoutKit -+ -+/// A profile saved with the Profiles settings screen, read from the file it was saved to. -+struct SavedProfile: Decodable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ -+ private static var directory: URL { -+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") -+ } -+ -+ static func loadAll() -> [SavedProfile] { -+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] -+ return files -+ .filter { $0.pathExtension == "json" } -+ .compactMap { try? Data(contentsOf: $0) } -+ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } -+ } -+ -+ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. -+ static func nightscoutStore(unit: LoopUnit) -> [String: ProfileSet.Profile] { -+ var store: [String: ProfileSet.Profile] = [:] -+ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. -+ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { -+ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) -+ } -+ return store -+ } -+ -+ /// True when this profile uploads with this fingerprint, in either glucose unit. -+ func matches(fingerprint: String) -> Bool { -+ [LoopUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { -+ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint -+ } -+ } -+ -+ func nightscoutProfile(unit: LoopUnit) -> ProfileSet.Profile? { -+ guard let correctionRange = correctionRange.schedule(for: unit), -+ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) -+ else { -+ return nil -+ } -+ return ProfileSet.Profile( -+ timezone: basalRateSchedule.timeZone, -+ dia: .hours(6), -+ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, -+ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, -+ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) -+ } -+} -+ -+extension ProfileSet.Profile { -+ /// Followers compute the same value from the uploaded profile and send it with a switch command, -+ /// so Loop only activates the values the follower previewed. -+ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` -+ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. -+ var remoteFingerprint: String { -+ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { -+ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") -+ } -+ let canonical = [ -+ "basal=" + items(basal), -+ "carbratio=" + items(carbratio), -+ "sens=" + items(sensitivity), -+ "target_low=" + items(targetLow), -+ "target_high=" + items(targetHigh), -+ ].joined(separator: ";") -+ let digest = SHA256.hash(data: Data(canonical.utf8)) -+ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) -+ } -+} -+ -+enum RemoteProfileSwitchError: LocalizedError { -+ case profileChanged -+ case unavailable -+ case notActivated(String, Error) -+ -+ /// Followers read the profile name from this text to match the reply to their command. -+ var errorDescription: String? { -+ switch self { -+ case .profileChanged: -+ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") -+ case .unavailable: -+ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") -+ case .notActivated(let name, let error): -+ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index 2f12b47..2dec600 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -180,6 +180,7 @@ extension StoredDosingDecision { - enacted: automaticDoseDecision?.loopStatusEnacted, - failureReason: automaticDoseDecision?.loopStatusFailureReason, - failureCode: automaticDoseDecision?.errors.first?.id, -+ testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]], - algorithmExperiments: (automaticDoseDecision?.algorithmExperiments ?? algorithmExperiments)?.map { $0.jsonObject }), - overrideStatus: overrideStatus) - } -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -index 790330a..ae6fb9a 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -@@ -93,7 +93,8 @@ extension StoredSettings { - return nil - } - -- var store = ["Default": profile] -+ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) -+ store["Default"] = profile - var defaultProfile = "Default" - - if let name = UserDefaults.standard.string(forKey: "currentProfileName") { -diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index c625152..ec997ef 100644 ---- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift -+++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -473,6 +473,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - success = true - message = "Override '\(overrideCommand.name)' enacted successfully" - -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ success = true -+ message = "Profile '\(profileSwitchCommand.name)' activated" -+ - case .cancelTemporaryOverride: - commandType = .cancelOverride - try await serviceDelegate.cancelRemoteOverride() -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index a92b509..29313b6 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -9,6 +9,7 @@ - import Foundation - - public enum Action: Codable { -+ case profileSwitch(ProfileSwitchAction) - case temporaryScheduleOverride(OverrideAction) - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) -@@ -28,6 +29,8 @@ public enum Action: Codable { - return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") - case .temporaryScheduleOverride: - return LocalizedString("Override", comment: "The remote action name for Override") -+ case .profileSwitch: -+ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") - } - } - -@@ -43,6 +46,8 @@ public enum Action: Codable { - return "" - case .temporaryScheduleOverride(let overrideAction): - return overrideAction.name -+ case .profileSwitch(let profileSwitchAction): -+ return profileSwitchAction.name - } - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -new file mode 100644 -index 0000000..ce03ded ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -@@ -0,0 +1,17 @@ -+// -+// ProfileSwitchAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct ProfileSwitchAction: Codable { -+ -+ public let name: String -+ public let fingerprint: String -+ -+ public init(name: String, fingerprint: String) { -+ self.name = name -+ self.fingerprint = fingerprint -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 95274e4..4457f80 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -45,6 +45,7 @@ class RemoteNotificationResponseManager { - case carbs = "carbs" - case override = "override" - case cancelOverride = "cancel_override" -+ case profileSwitch = "profile_switch" - } - - func sendResponseNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -new file mode 100644 -index 0000000..ebcd6ef ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -0,0 +1,50 @@ -+// -+// ProfileSwitchRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Switches to a saved profile. `expiration` is required: decoding fails without it. -+public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { -+ -+ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. -+ static let commandKey = "profile-switch" -+ -+ public let profileName: String -+ public let fingerprint: String -+ public let remoteAddress: String -+ public let expirationDate: Date -+ public let sentAt: Date -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ public var expiration: Date? { -+ return expirationDate -+ } -+ -+ enum CodingKeys: String, CodingKey { -+ case profileName = "profile-switch" -+ case fingerprint = "profile-switch-fingerprint" -+ case remoteAddress = "remote-address" -+ case expirationDate = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification[commandKey] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index 24e63d7..a0f58a0 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -104,6 +104,8 @@ extension Dictionary { - return try OverrideRemoteNotification(dictionary: self) - } else if OverrideCancelRemoteNotification.includedInNotification(self) { - return try OverrideCancelRemoteNotification(dictionary: self) -+ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { -+ return try ProfileSwitchRemoteNotification(dictionary: self) - } else { - throw RemoteNotificationError.unhandledNotification(self) - } -diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..2fcec94 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -@@ -0,0 +1,71 @@ -+// -+// ProfileSwitchRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+import LoopKit -+import NightscoutKit -+@testable import NightscoutServiceKit -+ -+final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "sent-at": "2023-02-25T20:46:35.778Z", -+ "expiration": "2023-02-25T20:51:35.778Z", -+ "otp": "123456" -+ ] -+ -+ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) -+ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) -+ -+ XCTAssertEqual(profileSwitch.profileName, "Sport") -+ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") -+ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) -+ XCTAssertTrue(profileSwitch.otpValidationRequired()) -+ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { -+ return XCTFail("Expected a profile switch action") -+ } -+ XCTAssertEqual(action.name, "Sport") -+ } -+ -+ func testParseProfileSwitchNotification_MissingExpiration_Fails() { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "otp": "123456" -+ ] -+ -+ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) -+ } -+ -+ func testFingerprint_MatchesFollowerVector() { -+ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { -+ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } -+ } -+ let profile = ProfileSet.Profile( -+ timezone: TimeZone(secondsFromGMT: 3600)!, -+ dia: .hours(6), -+ sensitivity: items([(0, 45)]), -+ carbratio: items([(0, 10)]), -+ basal: items([(0, 0.85), (21600, 1.1)]), -+ targetLow: items([(0, 100)]), -+ targetHigh: items([(0, 110)]), -+ units: "mg/dL") -+ -+ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") -+ } -+} -+ -+private extension ISO8601DateFormatter { -+ static let fractional: ISO8601DateFormatter = { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ }() -+} -diff --git a/remote_profile.loopcustomization b/remote_profile.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/remote_window/dev_remote_window_remote_profile.patch b/remote_window/dev_remote_window_profiles.patch similarity index 100% rename from remote_window/dev_remote_window_remote_profile.patch rename to remote_window/dev_remote_window_profiles.patch diff --git a/remote_window/dev_remote_window_remote_carb_edit_remote_profile.patch b/remote_window/dev_remote_window_profiles_remote_carb_edit.patch similarity index 100% rename from remote_window/dev_remote_window_remote_carb_edit_remote_profile.patch rename to remote_window/dev_remote_window_profiles_remote_carb_edit.patch diff --git a/remote_window/nextdev_remote_window_remote_profile.patch b/remote_window/nextdev_remote_window_profiles.patch similarity index 100% rename from remote_window/nextdev_remote_window_remote_profile.patch rename to remote_window/nextdev_remote_window_profiles.patch diff --git a/remote_window/nextdev_remote_window_remote_carb_edit_remote_profile.patch b/remote_window/nextdev_remote_window_profiles_remote_carb_edit.patch similarity index 100% rename from remote_window/nextdev_remote_window_remote_carb_edit_remote_profile.patch rename to remote_window/nextdev_remote_window_profiles_remote_carb_edit.patch From 0f4fb04933ffc5c5d2df6b975d48022256313b2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sun, 25 Oct 2026 19:51:05 +0100 Subject: [PATCH 3/6] profiles: edit saved profiles and save loads in one settings change Saved profiles can be edited with the Therapy Settings editors without loading them; edits only change the profile file, and editing the active profile offers to load it. Loading a profile saves all four schedules in one settings change with the profile name already set, so stored and uploaded settings never mix two profiles, and the pump completion keeps the view model alive so a written basal schedule is always followed by the settings save. --- profiles/README.md | 6 + profiles/dev_profiles.patch | 272 ++++++++++++----- profiles/dev_profiles_basal_lock.patch | 272 ++++++++++++----- ...profiles_basal_lock_remote_carb_edit.patch | 272 ++++++++++++----- ..._lock_remote_carb_edit_remote_window.patch | 272 ++++++++++++----- ...ev_profiles_basal_lock_remote_window.patch | 272 ++++++++++++----- profiles/dev_profiles_remote_carb_edit.patch | 272 ++++++++++++----- ...files_remote_carb_edit_remote_window.patch | 272 ++++++++++++----- profiles/dev_profiles_remote_window.patch | 272 ++++++++++++----- profiles/nextdev_profiles.patch | 273 +++++++++++++----- .../nextdev_profiles_remote_carb_edit.patch | 273 +++++++++++++----- ...files_remote_carb_edit_remote_window.patch | 273 +++++++++++++----- profiles/nextdev_profiles_remote_window.patch | 273 +++++++++++++----- .../dev_remote_carb_edit_profiles.patch | 2 +- ...ote_carb_edit_profiles_remote_window.patch | 2 +- .../nextdev_remote_carb_edit_profiles.patch | 2 +- ...ote_carb_edit_profiles_remote_window.patch | 2 +- .../dev_remote_window_profiles.patch | 2 +- ...ote_window_profiles_remote_carb_edit.patch | 2 +- .../nextdev_remote_window_profiles.patch | 2 +- ...ote_window_profiles_remote_carb_edit.patch | 2 +- 21 files changed, 2466 insertions(+), 824 deletions(-) diff --git a/profiles/README.md b/profiles/README.md index 7c1a23c..8b0820c 100644 --- a/profiles/README.md +++ b/profiles/README.md @@ -2,6 +2,12 @@ Save the current therapy settings (correction range, carb ratios, basal rates and insulin sensitivities) as named profiles under Settings → Profiles, and load one later. +Loading a profile writes its basal schedule to the pump and then saves all four settings in one change, so stored and uploaded settings never mix two profiles. + +## Editing saved profiles + +Tap a profile to see its settings, then tap a setting to edit it with the same editors as Therapy Settings: the same guardrail warnings, confirmations and authentication. Edits change only the saved profile. Loop keeps dosing with its current settings and nothing is sent to the pump until the profile is loaded. When the edited profile is the active one, Loop offers to load it right away. + ## Remote profile switch A caregiver app (LoopFollow) can switch Loop to one of the saved profiles, through the same OTP-protected APNS remote command path as remote carbs and boluses. diff --git a/profiles/dev_profiles.patch b/profiles/dev_profiles.patch index e3b30fd..16bf660 100644 --- a/profiles/dev_profiles.patch +++ b/profiles/dev_profiles.patch @@ -81,7 +81,7 @@ index 996d1470..17cff75d 100644 return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) } diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 3fb788f9..c9d3e04f 100644 +index 3fb788f9..c61eda65 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -28,6 +28,7 @@ class ServicesManager { @@ -92,7 +92,7 @@ index 3fb788f9..c9d3e04f 100644 private var services = [Service]() -@@ -412,3 +413,59 @@ extension Service { +@@ -412,3 +413,58 @@ extension Service { ] } } @@ -127,7 +127,6 @@ index 3fb788f9..c9d3e04f 100644 + } + } + } -+ viewModel.triggerProfileSync() + NotificationManager.sendRemoteProfileNotification(name: name) + } catch { + NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) @@ -315,10 +314,10 @@ index 7fdc53ad..00fe25ab 100644 func cancelRemoteOverride() async throws diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..f48848cd +index 00000000..87672389 --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,426 @@ +@@ -0,0 +1,466 @@ +// +// ProfileViewModel+FileManagement.swift +// LoopKitUI @@ -556,17 +555,24 @@ index 00000000..f48848cd + else + { + do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in + switch result { + case .success(let syncedSchedule): + DispatchQueue.main.async { -+ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) -+ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) -+ self?.saveBasalRates(basalRates: syncedSchedule) -+ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ let previousSettings = self.therapySettings ++ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! ++ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule ++ self.therapySettings.basalRateSchedule = syncedSchedule ++ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! ++ // One settings change, so the stored and uploaded settings never mix two profiles. ++ self.setCurrentProfile(name: profile.name, syncChange: false) ++ self.delegate?.saveCompletion(therapySettings: self.therapySettings) ++ if self.therapySettings == previousSettings { ++ // Nothing changed, so only the profile name needs to reach the uploaded settings. ++ self.triggerProfileSync() ++ } + print("New profile loaded") -+ self?.setCurrentProfile(name: profile.name, syncChange: false) + completion(.success) + } + case .failure(let error): @@ -745,6 +751,39 @@ index 00000000..f48848cd + try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) + } +} ++ ++extension ProfileViewModel { ++ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. ++ func therapySettings(applying profile: Profile) -> TherapySettings { ++ var settings = therapySettings ++ let unit = settings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) ++ settings.carbRatioSchedule = profile.carbRatioSchedule ++ settings.basalRateSchedule = profile.basalRateSchedule ++ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) ++ return settings ++ } ++ ++ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. ++ func updateProfile(named name: String, from settings: TherapySettings) throws { ++ loadProfiles() ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ throw ProfileValidationError.fileLoadError ++ } ++ let profile = try getProfile(from: profileReference) ++ let updatedProfile = Profile( ++ name: profile.name, ++ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, ++ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, ++ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, ++ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) ++ loadProfiles() ++ triggerProfileSync() ++ } ++} diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift new file mode 100644 index 00000000..a48fa667 @@ -936,10 +975,10 @@ index 00000000..446910e4 +} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift new file mode 100644 -index 00000000..6de3598f +index 00000000..a956b6d5 --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,326 @@ +@@ -0,0 +1,423 @@ +// +// ProfilePreviewView.swift +// LoopKitUI @@ -957,6 +996,7 @@ index 00000000..6de3598f + case load + case delete + case error ++ case loadEdited +} + +struct ProfilePreviewView: View { @@ -964,7 +1004,10 @@ index 00000000..6de3598f + @ObservedObject var viewModel: ProfileViewModel + @Environment(\.dismissAction) var dismissAction + @Environment(\.presentationMode) var presentationMode -+ var profile: Profile ++ @Environment(\.appName) private var appName ++ @State private var profile: Profile ++ @State private var editingDelegate: ProfileEditingDelegate ++ @State private var therapySettingsViewModel: TherapySettingsViewModel + + @State private var showingAlert = false + @State private var activeAlert: ActiveAlert = .load @@ -975,11 +1018,21 @@ index 00000000..6de3598f + + let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } + ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) ++ _profile = State(initialValue: profile) ++ _editingDelegate = State(initialValue: editingDelegate) ++ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( ++ therapySettings: viewModel.therapySettings(applying: profile), ++ delegate: editingDelegate)) ++ } ++ + var body: some View { + ZStack { + ScrollView { + VStack(alignment: .leading) { -+ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") + .font(.body) + .foregroundColor(.primary) + .padding([.leading, .trailing]) @@ -1046,30 +1099,7 @@ index 00000000..6de3598f + return Alert( + title: Text("Load Profile"), + message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ }), ++ primaryButton: .default(Text("Yes"), action: load), + secondaryButton: .cancel() + ) + case .delete: @@ -1088,6 +1118,13 @@ index 00000000..6de3598f + message: Text(errorText ?? "Unknown error"), + dismissButton: .default(Text("OK")) + ) ++ case .loadEdited: ++ return Alert( ++ title: Text("Load Edited Profile?"), ++ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), ++ primaryButton: .default(Text("Load"), action: load), ++ secondaryButton: .cancel(Text("Later")) ++ ) + } + } + if isRenamingProfile { @@ -1109,6 +1146,22 @@ index 00000000..6de3598f + .offset(y: -22) + } + } ++ .onAppear { ++ // Runs again when an editor closes. ++ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } ++ profile = saved ++ // The editors show the saved values, also after a save that failed. ++ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) ++ if let error = editingDelegate.saveError { ++ editingDelegate.saveError = nil ++ presentAlertLater(.error, errorText: error.localizedDescription) ++ } else if editingDelegate.savedEdits { ++ editingDelegate.savedEdits = false ++ if viewModel.currentProfileName == profile.name { ++ presentAlertLater(.loadEdited) ++ } ++ } ++ } + .onChange(of: shouldDismissSelf) { shouldDismiss in + if shouldDismiss { + self.presentationMode.wrappedValue.dismiss() @@ -1116,6 +1169,40 @@ index 00000000..6de3598f + } + } + ++ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ guard !showingAlert else { return } ++ self.errorText = errorText ++ activeAlert = alert ++ showingAlert = true ++ } ++ } ++ ++ private func load() { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ + private var profileCardStack: CardStack { + var cards: [Card] = [] + @@ -1213,27 +1300,6 @@ index 00000000..6de3598f +} + + -+struct SectionWithContent: View where Content: View { -+ let title: String -+ let content: Content -+ -+ init(title: String, -+ content: () -> Content) -+ { -+ self.title = title -+ self.content = content() -+ } -+ -+ public var body: some View { -+ Section { -+ Text(title) -+ .bold() -+ .frame(maxWidth: .infinity, alignment: .leading) -+ content -+ } -+ } -+} -+ +// MARK: Utilities +extension ProfilePreviewView { + @@ -1247,8 +1313,34 @@ index 00000000..6de3598f + + private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { + Card { -+ SectionWithContent(title: therapySetting.title, -+ content: content) ++ SectionWithTapToEdit( ++ // The basal editor needs the pump's supported rates. ++ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, ++ title: therapySetting.title, ++ descriptiveText: therapySetting.descriptiveText(appName: appName), ++ destination: { dismiss in ++ editor(for: therapySetting, dismiss: dismiss) ++ .environment(\.dismissAction, dismiss) ++ }, ++ content: content ++ ) ++ } ++ } ++ ++ /// The therapy settings editors, saving to this profile. ++ @ViewBuilder ++ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { ++ switch therapySetting { ++ case .glucoseTargetRange: ++ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .carbRatio: ++ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .basalRate: ++ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .insulinSensitivity: ++ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ default: ++ EmptyView() + } + } +} @@ -1266,9 +1358,53 @@ index 00000000..6de3598f + .padding(.trailing, -8) + } +} ++ ++/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. ++final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { ++ private let viewModel: ProfileViewModel ++ private let profileName: String ++ private let basalTimeZone: TimeZone ++ /// Set when edits were saved, so the view can offer to load the edited active profile. ++ var savedEdits = false ++ /// Set when saving edits to the profile file failed. ++ var saveError: Error? ++ ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ self.profileName = profile.name ++ self.basalTimeZone = profile.basalRateSchedule.timeZone ++ } ++ ++ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { ++ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { ++ completion(.failure(ProfileValidationError.basalRateError)) ++ return ++ } ++ completion(.success(schedule)) ++ } ++ ++ func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { ++ completion(.success(deliveryLimits)) ++ } ++ ++ func saveCompletion(therapySettings: TherapySettings) { ++ do { ++ try viewModel.updateProfile(named: profileName, from: therapySettings) ++ savedEdits = true ++ } catch { ++ saveError = error ++ } ++ } ++ ++ func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return viewModel.pumpSupportedIncrements() ++ } ++ ++ func updateCurrentProfileName() {} ++} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift new file mode 100644 -index 00000000..1c6244a4 +index 00000000..f816492b --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift @@ -0,0 +1,173 @@ @@ -1386,15 +1522,15 @@ index 00000000..1c6244a4 + case .update: + return Alert( + title: Text("Profile Outdated"), -+ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), -+ primaryButton: .default(Text("Update")) { ++ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), ++ primaryButton: .default(Text("Save Current Settings")) { + viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") + }, -+ secondaryButton: .cancel() ++ secondaryButton: .cancel(Text("Keep Saved Profile")) + ) + case .info: + return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), + dismissButton: .default(Text("Got it!"))) + } + } diff --git a/profiles/dev_profiles_basal_lock.patch b/profiles/dev_profiles_basal_lock.patch index e5d457a..66b7f18 100644 --- a/profiles/dev_profiles_basal_lock.patch +++ b/profiles/dev_profiles_basal_lock.patch @@ -81,7 +81,7 @@ index 996d1470..17cff75d 100644 return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) } diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 3fb788f9..c9d3e04f 100644 +index 3fb788f9..c61eda65 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -28,6 +28,7 @@ class ServicesManager { @@ -92,7 +92,7 @@ index 3fb788f9..c9d3e04f 100644 private var services = [Service]() -@@ -412,3 +413,59 @@ extension Service { +@@ -412,3 +413,58 @@ extension Service { ] } } @@ -127,7 +127,6 @@ index 3fb788f9..c9d3e04f 100644 + } + } + } -+ viewModel.triggerProfileSync() + NotificationManager.sendRemoteProfileNotification(name: name) + } catch { + NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) @@ -316,10 +315,10 @@ index 7fdc53ad..00fe25ab 100644 func cancelRemoteOverride() async throws diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..f48848cd +index 00000000..87672389 --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,426 @@ +@@ -0,0 +1,466 @@ +// +// ProfileViewModel+FileManagement.swift +// LoopKitUI @@ -557,17 +556,24 @@ index 00000000..f48848cd + else + { + do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in + switch result { + case .success(let syncedSchedule): + DispatchQueue.main.async { -+ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) -+ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) -+ self?.saveBasalRates(basalRates: syncedSchedule) -+ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ let previousSettings = self.therapySettings ++ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! ++ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule ++ self.therapySettings.basalRateSchedule = syncedSchedule ++ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! ++ // One settings change, so the stored and uploaded settings never mix two profiles. ++ self.setCurrentProfile(name: profile.name, syncChange: false) ++ self.delegate?.saveCompletion(therapySettings: self.therapySettings) ++ if self.therapySettings == previousSettings { ++ // Nothing changed, so only the profile name needs to reach the uploaded settings. ++ self.triggerProfileSync() ++ } + print("New profile loaded") -+ self?.setCurrentProfile(name: profile.name, syncChange: false) + completion(.success) + } + case .failure(let error): @@ -746,6 +752,39 @@ index 00000000..f48848cd + try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) + } +} ++ ++extension ProfileViewModel { ++ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. ++ func therapySettings(applying profile: Profile) -> TherapySettings { ++ var settings = therapySettings ++ let unit = settings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) ++ settings.carbRatioSchedule = profile.carbRatioSchedule ++ settings.basalRateSchedule = profile.basalRateSchedule ++ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) ++ return settings ++ } ++ ++ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. ++ func updateProfile(named name: String, from settings: TherapySettings) throws { ++ loadProfiles() ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ throw ProfileValidationError.fileLoadError ++ } ++ let profile = try getProfile(from: profileReference) ++ let updatedProfile = Profile( ++ name: profile.name, ++ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, ++ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, ++ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, ++ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) ++ loadProfiles() ++ triggerProfileSync() ++ } ++} diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift new file mode 100644 index 00000000..a48fa667 @@ -937,10 +976,10 @@ index 00000000..446910e4 +} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift new file mode 100644 -index 00000000..6de3598f +index 00000000..a956b6d5 --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,326 @@ +@@ -0,0 +1,423 @@ +// +// ProfilePreviewView.swift +// LoopKitUI @@ -958,6 +997,7 @@ index 00000000..6de3598f + case load + case delete + case error ++ case loadEdited +} + +struct ProfilePreviewView: View { @@ -965,7 +1005,10 @@ index 00000000..6de3598f + @ObservedObject var viewModel: ProfileViewModel + @Environment(\.dismissAction) var dismissAction + @Environment(\.presentationMode) var presentationMode -+ var profile: Profile ++ @Environment(\.appName) private var appName ++ @State private var profile: Profile ++ @State private var editingDelegate: ProfileEditingDelegate ++ @State private var therapySettingsViewModel: TherapySettingsViewModel + + @State private var showingAlert = false + @State private var activeAlert: ActiveAlert = .load @@ -976,11 +1019,21 @@ index 00000000..6de3598f + + let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } + ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) ++ _profile = State(initialValue: profile) ++ _editingDelegate = State(initialValue: editingDelegate) ++ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( ++ therapySettings: viewModel.therapySettings(applying: profile), ++ delegate: editingDelegate)) ++ } ++ + var body: some View { + ZStack { + ScrollView { + VStack(alignment: .leading) { -+ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") + .font(.body) + .foregroundColor(.primary) + .padding([.leading, .trailing]) @@ -1047,30 +1100,7 @@ index 00000000..6de3598f + return Alert( + title: Text("Load Profile"), + message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ }), ++ primaryButton: .default(Text("Yes"), action: load), + secondaryButton: .cancel() + ) + case .delete: @@ -1089,6 +1119,13 @@ index 00000000..6de3598f + message: Text(errorText ?? "Unknown error"), + dismissButton: .default(Text("OK")) + ) ++ case .loadEdited: ++ return Alert( ++ title: Text("Load Edited Profile?"), ++ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), ++ primaryButton: .default(Text("Load"), action: load), ++ secondaryButton: .cancel(Text("Later")) ++ ) + } + } + if isRenamingProfile { @@ -1110,6 +1147,22 @@ index 00000000..6de3598f + .offset(y: -22) + } + } ++ .onAppear { ++ // Runs again when an editor closes. ++ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } ++ profile = saved ++ // The editors show the saved values, also after a save that failed. ++ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) ++ if let error = editingDelegate.saveError { ++ editingDelegate.saveError = nil ++ presentAlertLater(.error, errorText: error.localizedDescription) ++ } else if editingDelegate.savedEdits { ++ editingDelegate.savedEdits = false ++ if viewModel.currentProfileName == profile.name { ++ presentAlertLater(.loadEdited) ++ } ++ } ++ } + .onChange(of: shouldDismissSelf) { shouldDismiss in + if shouldDismiss { + self.presentationMode.wrappedValue.dismiss() @@ -1117,6 +1170,40 @@ index 00000000..6de3598f + } + } + ++ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ guard !showingAlert else { return } ++ self.errorText = errorText ++ activeAlert = alert ++ showingAlert = true ++ } ++ } ++ ++ private func load() { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ + private var profileCardStack: CardStack { + var cards: [Card] = [] + @@ -1214,27 +1301,6 @@ index 00000000..6de3598f +} + + -+struct SectionWithContent: View where Content: View { -+ let title: String -+ let content: Content -+ -+ init(title: String, -+ content: () -> Content) -+ { -+ self.title = title -+ self.content = content() -+ } -+ -+ public var body: some View { -+ Section { -+ Text(title) -+ .bold() -+ .frame(maxWidth: .infinity, alignment: .leading) -+ content -+ } -+ } -+} -+ +// MARK: Utilities +extension ProfilePreviewView { + @@ -1248,8 +1314,34 @@ index 00000000..6de3598f + + private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { + Card { -+ SectionWithContent(title: therapySetting.title, -+ content: content) ++ SectionWithTapToEdit( ++ // The basal editor needs the pump's supported rates. ++ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, ++ title: therapySetting.title, ++ descriptiveText: therapySetting.descriptiveText(appName: appName), ++ destination: { dismiss in ++ editor(for: therapySetting, dismiss: dismiss) ++ .environment(\.dismissAction, dismiss) ++ }, ++ content: content ++ ) ++ } ++ } ++ ++ /// The therapy settings editors, saving to this profile. ++ @ViewBuilder ++ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { ++ switch therapySetting { ++ case .glucoseTargetRange: ++ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .carbRatio: ++ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .basalRate: ++ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .insulinSensitivity: ++ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ default: ++ EmptyView() + } + } +} @@ -1267,9 +1359,53 @@ index 00000000..6de3598f + .padding(.trailing, -8) + } +} ++ ++/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. ++final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { ++ private let viewModel: ProfileViewModel ++ private let profileName: String ++ private let basalTimeZone: TimeZone ++ /// Set when edits were saved, so the view can offer to load the edited active profile. ++ var savedEdits = false ++ /// Set when saving edits to the profile file failed. ++ var saveError: Error? ++ ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ self.profileName = profile.name ++ self.basalTimeZone = profile.basalRateSchedule.timeZone ++ } ++ ++ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { ++ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { ++ completion(.failure(ProfileValidationError.basalRateError)) ++ return ++ } ++ completion(.success(schedule)) ++ } ++ ++ func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { ++ completion(.success(deliveryLimits)) ++ } ++ ++ func saveCompletion(therapySettings: TherapySettings) { ++ do { ++ try viewModel.updateProfile(named: profileName, from: therapySettings) ++ savedEdits = true ++ } catch { ++ saveError = error ++ } ++ } ++ ++ func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return viewModel.pumpSupportedIncrements() ++ } ++ ++ func updateCurrentProfileName() {} ++} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift new file mode 100644 -index 00000000..1c6244a4 +index 00000000..f816492b --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift @@ -0,0 +1,173 @@ @@ -1387,15 +1523,15 @@ index 00000000..1c6244a4 + case .update: + return Alert( + title: Text("Profile Outdated"), -+ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), -+ primaryButton: .default(Text("Update")) { ++ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), ++ primaryButton: .default(Text("Save Current Settings")) { + viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") + }, -+ secondaryButton: .cancel() ++ secondaryButton: .cancel(Text("Keep Saved Profile")) + ) + case .info: + return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), + dismissButton: .default(Text("Got it!"))) + } + } diff --git a/profiles/dev_profiles_basal_lock_remote_carb_edit.patch b/profiles/dev_profiles_basal_lock_remote_carb_edit.patch index 18fafd5..168e253 100644 --- a/profiles/dev_profiles_basal_lock_remote_carb_edit.patch +++ b/profiles/dev_profiles_basal_lock_remote_carb_edit.patch @@ -81,7 +81,7 @@ index 3eb3154b..6cefc092 100644 return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) } diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 3b74fd9d..5bf1ec36 100644 +index 3b74fd9d..7a8dc92f 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -28,6 +28,7 @@ class ServicesManager { @@ -92,7 +92,7 @@ index 3b74fd9d..5bf1ec36 100644 private var services = [Service]() -@@ -456,3 +457,59 @@ extension Service { +@@ -456,3 +457,58 @@ extension Service { ] } } @@ -127,7 +127,6 @@ index 3b74fd9d..5bf1ec36 100644 + } + } + } -+ viewModel.triggerProfileSync() + NotificationManager.sendRemoteProfileNotification(name: name) + } catch { + NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) @@ -316,10 +315,10 @@ index 4b2cecf3..56742c96 100644 func cancelRemoteOverride() async throws diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..f48848cd +index 00000000..87672389 --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,426 @@ +@@ -0,0 +1,466 @@ +// +// ProfileViewModel+FileManagement.swift +// LoopKitUI @@ -557,17 +556,24 @@ index 00000000..f48848cd + else + { + do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in + switch result { + case .success(let syncedSchedule): + DispatchQueue.main.async { -+ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) -+ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) -+ self?.saveBasalRates(basalRates: syncedSchedule) -+ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ let previousSettings = self.therapySettings ++ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! ++ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule ++ self.therapySettings.basalRateSchedule = syncedSchedule ++ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! ++ // One settings change, so the stored and uploaded settings never mix two profiles. ++ self.setCurrentProfile(name: profile.name, syncChange: false) ++ self.delegate?.saveCompletion(therapySettings: self.therapySettings) ++ if self.therapySettings == previousSettings { ++ // Nothing changed, so only the profile name needs to reach the uploaded settings. ++ self.triggerProfileSync() ++ } + print("New profile loaded") -+ self?.setCurrentProfile(name: profile.name, syncChange: false) + completion(.success) + } + case .failure(let error): @@ -746,6 +752,39 @@ index 00000000..f48848cd + try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) + } +} ++ ++extension ProfileViewModel { ++ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. ++ func therapySettings(applying profile: Profile) -> TherapySettings { ++ var settings = therapySettings ++ let unit = settings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) ++ settings.carbRatioSchedule = profile.carbRatioSchedule ++ settings.basalRateSchedule = profile.basalRateSchedule ++ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) ++ return settings ++ } ++ ++ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. ++ func updateProfile(named name: String, from settings: TherapySettings) throws { ++ loadProfiles() ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ throw ProfileValidationError.fileLoadError ++ } ++ let profile = try getProfile(from: profileReference) ++ let updatedProfile = Profile( ++ name: profile.name, ++ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, ++ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, ++ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, ++ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) ++ loadProfiles() ++ triggerProfileSync() ++ } ++} diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift new file mode 100644 index 00000000..a48fa667 @@ -937,10 +976,10 @@ index 00000000..446910e4 +} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift new file mode 100644 -index 00000000..6de3598f +index 00000000..a956b6d5 --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,326 @@ +@@ -0,0 +1,423 @@ +// +// ProfilePreviewView.swift +// LoopKitUI @@ -958,6 +997,7 @@ index 00000000..6de3598f + case load + case delete + case error ++ case loadEdited +} + +struct ProfilePreviewView: View { @@ -965,7 +1005,10 @@ index 00000000..6de3598f + @ObservedObject var viewModel: ProfileViewModel + @Environment(\.dismissAction) var dismissAction + @Environment(\.presentationMode) var presentationMode -+ var profile: Profile ++ @Environment(\.appName) private var appName ++ @State private var profile: Profile ++ @State private var editingDelegate: ProfileEditingDelegate ++ @State private var therapySettingsViewModel: TherapySettingsViewModel + + @State private var showingAlert = false + @State private var activeAlert: ActiveAlert = .load @@ -976,11 +1019,21 @@ index 00000000..6de3598f + + let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } + ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) ++ _profile = State(initialValue: profile) ++ _editingDelegate = State(initialValue: editingDelegate) ++ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( ++ therapySettings: viewModel.therapySettings(applying: profile), ++ delegate: editingDelegate)) ++ } ++ + var body: some View { + ZStack { + ScrollView { + VStack(alignment: .leading) { -+ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") + .font(.body) + .foregroundColor(.primary) + .padding([.leading, .trailing]) @@ -1047,30 +1100,7 @@ index 00000000..6de3598f + return Alert( + title: Text("Load Profile"), + message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ }), ++ primaryButton: .default(Text("Yes"), action: load), + secondaryButton: .cancel() + ) + case .delete: @@ -1089,6 +1119,13 @@ index 00000000..6de3598f + message: Text(errorText ?? "Unknown error"), + dismissButton: .default(Text("OK")) + ) ++ case .loadEdited: ++ return Alert( ++ title: Text("Load Edited Profile?"), ++ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), ++ primaryButton: .default(Text("Load"), action: load), ++ secondaryButton: .cancel(Text("Later")) ++ ) + } + } + if isRenamingProfile { @@ -1110,6 +1147,22 @@ index 00000000..6de3598f + .offset(y: -22) + } + } ++ .onAppear { ++ // Runs again when an editor closes. ++ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } ++ profile = saved ++ // The editors show the saved values, also after a save that failed. ++ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) ++ if let error = editingDelegate.saveError { ++ editingDelegate.saveError = nil ++ presentAlertLater(.error, errorText: error.localizedDescription) ++ } else if editingDelegate.savedEdits { ++ editingDelegate.savedEdits = false ++ if viewModel.currentProfileName == profile.name { ++ presentAlertLater(.loadEdited) ++ } ++ } ++ } + .onChange(of: shouldDismissSelf) { shouldDismiss in + if shouldDismiss { + self.presentationMode.wrappedValue.dismiss() @@ -1117,6 +1170,40 @@ index 00000000..6de3598f + } + } + ++ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ guard !showingAlert else { return } ++ self.errorText = errorText ++ activeAlert = alert ++ showingAlert = true ++ } ++ } ++ ++ private func load() { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ + private var profileCardStack: CardStack { + var cards: [Card] = [] + @@ -1214,27 +1301,6 @@ index 00000000..6de3598f +} + + -+struct SectionWithContent: View where Content: View { -+ let title: String -+ let content: Content -+ -+ init(title: String, -+ content: () -> Content) -+ { -+ self.title = title -+ self.content = content() -+ } -+ -+ public var body: some View { -+ Section { -+ Text(title) -+ .bold() -+ .frame(maxWidth: .infinity, alignment: .leading) -+ content -+ } -+ } -+} -+ +// MARK: Utilities +extension ProfilePreviewView { + @@ -1248,8 +1314,34 @@ index 00000000..6de3598f + + private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { + Card { -+ SectionWithContent(title: therapySetting.title, -+ content: content) ++ SectionWithTapToEdit( ++ // The basal editor needs the pump's supported rates. ++ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, ++ title: therapySetting.title, ++ descriptiveText: therapySetting.descriptiveText(appName: appName), ++ destination: { dismiss in ++ editor(for: therapySetting, dismiss: dismiss) ++ .environment(\.dismissAction, dismiss) ++ }, ++ content: content ++ ) ++ } ++ } ++ ++ /// The therapy settings editors, saving to this profile. ++ @ViewBuilder ++ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { ++ switch therapySetting { ++ case .glucoseTargetRange: ++ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .carbRatio: ++ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .basalRate: ++ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .insulinSensitivity: ++ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ default: ++ EmptyView() + } + } +} @@ -1267,9 +1359,53 @@ index 00000000..6de3598f + .padding(.trailing, -8) + } +} ++ ++/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. ++final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { ++ private let viewModel: ProfileViewModel ++ private let profileName: String ++ private let basalTimeZone: TimeZone ++ /// Set when edits were saved, so the view can offer to load the edited active profile. ++ var savedEdits = false ++ /// Set when saving edits to the profile file failed. ++ var saveError: Error? ++ ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ self.profileName = profile.name ++ self.basalTimeZone = profile.basalRateSchedule.timeZone ++ } ++ ++ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { ++ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { ++ completion(.failure(ProfileValidationError.basalRateError)) ++ return ++ } ++ completion(.success(schedule)) ++ } ++ ++ func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { ++ completion(.success(deliveryLimits)) ++ } ++ ++ func saveCompletion(therapySettings: TherapySettings) { ++ do { ++ try viewModel.updateProfile(named: profileName, from: therapySettings) ++ savedEdits = true ++ } catch { ++ saveError = error ++ } ++ } ++ ++ func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return viewModel.pumpSupportedIncrements() ++ } ++ ++ func updateCurrentProfileName() {} ++} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift new file mode 100644 -index 00000000..1c6244a4 +index 00000000..f816492b --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift @@ -0,0 +1,173 @@ @@ -1387,15 +1523,15 @@ index 00000000..1c6244a4 + case .update: + return Alert( + title: Text("Profile Outdated"), -+ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), -+ primaryButton: .default(Text("Update")) { ++ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), ++ primaryButton: .default(Text("Save Current Settings")) { + viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") + }, -+ secondaryButton: .cancel() ++ secondaryButton: .cancel(Text("Keep Saved Profile")) + ) + case .info: + return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), + dismissButton: .default(Text("Got it!"))) + } + } diff --git a/profiles/dev_profiles_basal_lock_remote_carb_edit_remote_window.patch b/profiles/dev_profiles_basal_lock_remote_carb_edit_remote_window.patch index 243fdb6..05c2026 100644 --- a/profiles/dev_profiles_basal_lock_remote_carb_edit_remote_window.patch +++ b/profiles/dev_profiles_basal_lock_remote_carb_edit_remote_window.patch @@ -81,7 +81,7 @@ index 3eb3154b..6cefc092 100644 return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) } diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 1e272bb2..161a42e3 100644 +index 1e272bb2..d804456f 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -28,6 +28,7 @@ class ServicesManager { @@ -92,7 +92,7 @@ index 1e272bb2..161a42e3 100644 private var services = [Service]() -@@ -482,3 +483,59 @@ extension Service { +@@ -482,3 +483,58 @@ extension Service { ] } } @@ -127,7 +127,6 @@ index 1e272bb2..161a42e3 100644 + } + } + } -+ viewModel.triggerProfileSync() + NotificationManager.sendRemoteProfileNotification(name: name) + } catch { + NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) @@ -316,10 +315,10 @@ index 8fb254b6..858a5621 100644 func cancelRemoteOverride() async throws -> TemporaryScheduleOverride diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..f48848cd +index 00000000..87672389 --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,426 @@ +@@ -0,0 +1,466 @@ +// +// ProfileViewModel+FileManagement.swift +// LoopKitUI @@ -557,17 +556,24 @@ index 00000000..f48848cd + else + { + do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in + switch result { + case .success(let syncedSchedule): + DispatchQueue.main.async { -+ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) -+ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) -+ self?.saveBasalRates(basalRates: syncedSchedule) -+ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ let previousSettings = self.therapySettings ++ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! ++ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule ++ self.therapySettings.basalRateSchedule = syncedSchedule ++ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! ++ // One settings change, so the stored and uploaded settings never mix two profiles. ++ self.setCurrentProfile(name: profile.name, syncChange: false) ++ self.delegate?.saveCompletion(therapySettings: self.therapySettings) ++ if self.therapySettings == previousSettings { ++ // Nothing changed, so only the profile name needs to reach the uploaded settings. ++ self.triggerProfileSync() ++ } + print("New profile loaded") -+ self?.setCurrentProfile(name: profile.name, syncChange: false) + completion(.success) + } + case .failure(let error): @@ -746,6 +752,39 @@ index 00000000..f48848cd + try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) + } +} ++ ++extension ProfileViewModel { ++ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. ++ func therapySettings(applying profile: Profile) -> TherapySettings { ++ var settings = therapySettings ++ let unit = settings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) ++ settings.carbRatioSchedule = profile.carbRatioSchedule ++ settings.basalRateSchedule = profile.basalRateSchedule ++ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) ++ return settings ++ } ++ ++ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. ++ func updateProfile(named name: String, from settings: TherapySettings) throws { ++ loadProfiles() ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ throw ProfileValidationError.fileLoadError ++ } ++ let profile = try getProfile(from: profileReference) ++ let updatedProfile = Profile( ++ name: profile.name, ++ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, ++ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, ++ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, ++ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) ++ loadProfiles() ++ triggerProfileSync() ++ } ++} diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift new file mode 100644 index 00000000..a48fa667 @@ -937,10 +976,10 @@ index 00000000..446910e4 +} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift new file mode 100644 -index 00000000..6de3598f +index 00000000..a956b6d5 --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,326 @@ +@@ -0,0 +1,423 @@ +// +// ProfilePreviewView.swift +// LoopKitUI @@ -958,6 +997,7 @@ index 00000000..6de3598f + case load + case delete + case error ++ case loadEdited +} + +struct ProfilePreviewView: View { @@ -965,7 +1005,10 @@ index 00000000..6de3598f + @ObservedObject var viewModel: ProfileViewModel + @Environment(\.dismissAction) var dismissAction + @Environment(\.presentationMode) var presentationMode -+ var profile: Profile ++ @Environment(\.appName) private var appName ++ @State private var profile: Profile ++ @State private var editingDelegate: ProfileEditingDelegate ++ @State private var therapySettingsViewModel: TherapySettingsViewModel + + @State private var showingAlert = false + @State private var activeAlert: ActiveAlert = .load @@ -976,11 +1019,21 @@ index 00000000..6de3598f + + let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } + ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) ++ _profile = State(initialValue: profile) ++ _editingDelegate = State(initialValue: editingDelegate) ++ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( ++ therapySettings: viewModel.therapySettings(applying: profile), ++ delegate: editingDelegate)) ++ } ++ + var body: some View { + ZStack { + ScrollView { + VStack(alignment: .leading) { -+ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") + .font(.body) + .foregroundColor(.primary) + .padding([.leading, .trailing]) @@ -1047,30 +1100,7 @@ index 00000000..6de3598f + return Alert( + title: Text("Load Profile"), + message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ }), ++ primaryButton: .default(Text("Yes"), action: load), + secondaryButton: .cancel() + ) + case .delete: @@ -1089,6 +1119,13 @@ index 00000000..6de3598f + message: Text(errorText ?? "Unknown error"), + dismissButton: .default(Text("OK")) + ) ++ case .loadEdited: ++ return Alert( ++ title: Text("Load Edited Profile?"), ++ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), ++ primaryButton: .default(Text("Load"), action: load), ++ secondaryButton: .cancel(Text("Later")) ++ ) + } + } + if isRenamingProfile { @@ -1110,6 +1147,22 @@ index 00000000..6de3598f + .offset(y: -22) + } + } ++ .onAppear { ++ // Runs again when an editor closes. ++ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } ++ profile = saved ++ // The editors show the saved values, also after a save that failed. ++ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) ++ if let error = editingDelegate.saveError { ++ editingDelegate.saveError = nil ++ presentAlertLater(.error, errorText: error.localizedDescription) ++ } else if editingDelegate.savedEdits { ++ editingDelegate.savedEdits = false ++ if viewModel.currentProfileName == profile.name { ++ presentAlertLater(.loadEdited) ++ } ++ } ++ } + .onChange(of: shouldDismissSelf) { shouldDismiss in + if shouldDismiss { + self.presentationMode.wrappedValue.dismiss() @@ -1117,6 +1170,40 @@ index 00000000..6de3598f + } + } + ++ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ guard !showingAlert else { return } ++ self.errorText = errorText ++ activeAlert = alert ++ showingAlert = true ++ } ++ } ++ ++ private func load() { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ + private var profileCardStack: CardStack { + var cards: [Card] = [] + @@ -1214,27 +1301,6 @@ index 00000000..6de3598f +} + + -+struct SectionWithContent: View where Content: View { -+ let title: String -+ let content: Content -+ -+ init(title: String, -+ content: () -> Content) -+ { -+ self.title = title -+ self.content = content() -+ } -+ -+ public var body: some View { -+ Section { -+ Text(title) -+ .bold() -+ .frame(maxWidth: .infinity, alignment: .leading) -+ content -+ } -+ } -+} -+ +// MARK: Utilities +extension ProfilePreviewView { + @@ -1248,8 +1314,34 @@ index 00000000..6de3598f + + private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { + Card { -+ SectionWithContent(title: therapySetting.title, -+ content: content) ++ SectionWithTapToEdit( ++ // The basal editor needs the pump's supported rates. ++ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, ++ title: therapySetting.title, ++ descriptiveText: therapySetting.descriptiveText(appName: appName), ++ destination: { dismiss in ++ editor(for: therapySetting, dismiss: dismiss) ++ .environment(\.dismissAction, dismiss) ++ }, ++ content: content ++ ) ++ } ++ } ++ ++ /// The therapy settings editors, saving to this profile. ++ @ViewBuilder ++ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { ++ switch therapySetting { ++ case .glucoseTargetRange: ++ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .carbRatio: ++ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .basalRate: ++ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .insulinSensitivity: ++ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ default: ++ EmptyView() + } + } +} @@ -1267,9 +1359,53 @@ index 00000000..6de3598f + .padding(.trailing, -8) + } +} ++ ++/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. ++final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { ++ private let viewModel: ProfileViewModel ++ private let profileName: String ++ private let basalTimeZone: TimeZone ++ /// Set when edits were saved, so the view can offer to load the edited active profile. ++ var savedEdits = false ++ /// Set when saving edits to the profile file failed. ++ var saveError: Error? ++ ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ self.profileName = profile.name ++ self.basalTimeZone = profile.basalRateSchedule.timeZone ++ } ++ ++ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { ++ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { ++ completion(.failure(ProfileValidationError.basalRateError)) ++ return ++ } ++ completion(.success(schedule)) ++ } ++ ++ func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { ++ completion(.success(deliveryLimits)) ++ } ++ ++ func saveCompletion(therapySettings: TherapySettings) { ++ do { ++ try viewModel.updateProfile(named: profileName, from: therapySettings) ++ savedEdits = true ++ } catch { ++ saveError = error ++ } ++ } ++ ++ func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return viewModel.pumpSupportedIncrements() ++ } ++ ++ func updateCurrentProfileName() {} ++} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift new file mode 100644 -index 00000000..1c6244a4 +index 00000000..f816492b --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift @@ -0,0 +1,173 @@ @@ -1387,15 +1523,15 @@ index 00000000..1c6244a4 + case .update: + return Alert( + title: Text("Profile Outdated"), -+ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), -+ primaryButton: .default(Text("Update")) { ++ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), ++ primaryButton: .default(Text("Save Current Settings")) { + viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") + }, -+ secondaryButton: .cancel() ++ secondaryButton: .cancel(Text("Keep Saved Profile")) + ) + case .info: + return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), + dismissButton: .default(Text("Got it!"))) + } + } diff --git a/profiles/dev_profiles_basal_lock_remote_window.patch b/profiles/dev_profiles_basal_lock_remote_window.patch index 876fe6c..7d9be1a 100644 --- a/profiles/dev_profiles_basal_lock_remote_window.patch +++ b/profiles/dev_profiles_basal_lock_remote_window.patch @@ -81,7 +81,7 @@ index 996d1470..17cff75d 100644 return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) } diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 0b498420..f5f0d3c5 100644 +index 0b498420..a9d9177d 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -28,6 +28,7 @@ class ServicesManager { @@ -92,7 +92,7 @@ index 0b498420..f5f0d3c5 100644 private var services = [Service]() -@@ -447,3 +448,59 @@ extension Service { +@@ -447,3 +448,58 @@ extension Service { ] } } @@ -127,7 +127,6 @@ index 0b498420..f5f0d3c5 100644 + } + } + } -+ viewModel.triggerProfileSync() + NotificationManager.sendRemoteProfileNotification(name: name) + } catch { + NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) @@ -316,10 +315,10 @@ index e4279746..6f691598 100644 func cancelRemoteOverride() async throws -> TemporaryScheduleOverride diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..f48848cd +index 00000000..87672389 --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,426 @@ +@@ -0,0 +1,466 @@ +// +// ProfileViewModel+FileManagement.swift +// LoopKitUI @@ -557,17 +556,24 @@ index 00000000..f48848cd + else + { + do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in + switch result { + case .success(let syncedSchedule): + DispatchQueue.main.async { -+ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) -+ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) -+ self?.saveBasalRates(basalRates: syncedSchedule) -+ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ let previousSettings = self.therapySettings ++ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! ++ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule ++ self.therapySettings.basalRateSchedule = syncedSchedule ++ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! ++ // One settings change, so the stored and uploaded settings never mix two profiles. ++ self.setCurrentProfile(name: profile.name, syncChange: false) ++ self.delegate?.saveCompletion(therapySettings: self.therapySettings) ++ if self.therapySettings == previousSettings { ++ // Nothing changed, so only the profile name needs to reach the uploaded settings. ++ self.triggerProfileSync() ++ } + print("New profile loaded") -+ self?.setCurrentProfile(name: profile.name, syncChange: false) + completion(.success) + } + case .failure(let error): @@ -746,6 +752,39 @@ index 00000000..f48848cd + try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) + } +} ++ ++extension ProfileViewModel { ++ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. ++ func therapySettings(applying profile: Profile) -> TherapySettings { ++ var settings = therapySettings ++ let unit = settings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) ++ settings.carbRatioSchedule = profile.carbRatioSchedule ++ settings.basalRateSchedule = profile.basalRateSchedule ++ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) ++ return settings ++ } ++ ++ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. ++ func updateProfile(named name: String, from settings: TherapySettings) throws { ++ loadProfiles() ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ throw ProfileValidationError.fileLoadError ++ } ++ let profile = try getProfile(from: profileReference) ++ let updatedProfile = Profile( ++ name: profile.name, ++ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, ++ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, ++ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, ++ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) ++ loadProfiles() ++ triggerProfileSync() ++ } ++} diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift new file mode 100644 index 00000000..a48fa667 @@ -937,10 +976,10 @@ index 00000000..446910e4 +} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift new file mode 100644 -index 00000000..6de3598f +index 00000000..a956b6d5 --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,326 @@ +@@ -0,0 +1,423 @@ +// +// ProfilePreviewView.swift +// LoopKitUI @@ -958,6 +997,7 @@ index 00000000..6de3598f + case load + case delete + case error ++ case loadEdited +} + +struct ProfilePreviewView: View { @@ -965,7 +1005,10 @@ index 00000000..6de3598f + @ObservedObject var viewModel: ProfileViewModel + @Environment(\.dismissAction) var dismissAction + @Environment(\.presentationMode) var presentationMode -+ var profile: Profile ++ @Environment(\.appName) private var appName ++ @State private var profile: Profile ++ @State private var editingDelegate: ProfileEditingDelegate ++ @State private var therapySettingsViewModel: TherapySettingsViewModel + + @State private var showingAlert = false + @State private var activeAlert: ActiveAlert = .load @@ -976,11 +1019,21 @@ index 00000000..6de3598f + + let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } + ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) ++ _profile = State(initialValue: profile) ++ _editingDelegate = State(initialValue: editingDelegate) ++ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( ++ therapySettings: viewModel.therapySettings(applying: profile), ++ delegate: editingDelegate)) ++ } ++ + var body: some View { + ZStack { + ScrollView { + VStack(alignment: .leading) { -+ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") + .font(.body) + .foregroundColor(.primary) + .padding([.leading, .trailing]) @@ -1047,30 +1100,7 @@ index 00000000..6de3598f + return Alert( + title: Text("Load Profile"), + message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ }), ++ primaryButton: .default(Text("Yes"), action: load), + secondaryButton: .cancel() + ) + case .delete: @@ -1089,6 +1119,13 @@ index 00000000..6de3598f + message: Text(errorText ?? "Unknown error"), + dismissButton: .default(Text("OK")) + ) ++ case .loadEdited: ++ return Alert( ++ title: Text("Load Edited Profile?"), ++ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), ++ primaryButton: .default(Text("Load"), action: load), ++ secondaryButton: .cancel(Text("Later")) ++ ) + } + } + if isRenamingProfile { @@ -1110,6 +1147,22 @@ index 00000000..6de3598f + .offset(y: -22) + } + } ++ .onAppear { ++ // Runs again when an editor closes. ++ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } ++ profile = saved ++ // The editors show the saved values, also after a save that failed. ++ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) ++ if let error = editingDelegate.saveError { ++ editingDelegate.saveError = nil ++ presentAlertLater(.error, errorText: error.localizedDescription) ++ } else if editingDelegate.savedEdits { ++ editingDelegate.savedEdits = false ++ if viewModel.currentProfileName == profile.name { ++ presentAlertLater(.loadEdited) ++ } ++ } ++ } + .onChange(of: shouldDismissSelf) { shouldDismiss in + if shouldDismiss { + self.presentationMode.wrappedValue.dismiss() @@ -1117,6 +1170,40 @@ index 00000000..6de3598f + } + } + ++ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ guard !showingAlert else { return } ++ self.errorText = errorText ++ activeAlert = alert ++ showingAlert = true ++ } ++ } ++ ++ private func load() { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ + private var profileCardStack: CardStack { + var cards: [Card] = [] + @@ -1214,27 +1301,6 @@ index 00000000..6de3598f +} + + -+struct SectionWithContent: View where Content: View { -+ let title: String -+ let content: Content -+ -+ init(title: String, -+ content: () -> Content) -+ { -+ self.title = title -+ self.content = content() -+ } -+ -+ public var body: some View { -+ Section { -+ Text(title) -+ .bold() -+ .frame(maxWidth: .infinity, alignment: .leading) -+ content -+ } -+ } -+} -+ +// MARK: Utilities +extension ProfilePreviewView { + @@ -1248,8 +1314,34 @@ index 00000000..6de3598f + + private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { + Card { -+ SectionWithContent(title: therapySetting.title, -+ content: content) ++ SectionWithTapToEdit( ++ // The basal editor needs the pump's supported rates. ++ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, ++ title: therapySetting.title, ++ descriptiveText: therapySetting.descriptiveText(appName: appName), ++ destination: { dismiss in ++ editor(for: therapySetting, dismiss: dismiss) ++ .environment(\.dismissAction, dismiss) ++ }, ++ content: content ++ ) ++ } ++ } ++ ++ /// The therapy settings editors, saving to this profile. ++ @ViewBuilder ++ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { ++ switch therapySetting { ++ case .glucoseTargetRange: ++ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .carbRatio: ++ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .basalRate: ++ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .insulinSensitivity: ++ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ default: ++ EmptyView() + } + } +} @@ -1267,9 +1359,53 @@ index 00000000..6de3598f + .padding(.trailing, -8) + } +} ++ ++/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. ++final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { ++ private let viewModel: ProfileViewModel ++ private let profileName: String ++ private let basalTimeZone: TimeZone ++ /// Set when edits were saved, so the view can offer to load the edited active profile. ++ var savedEdits = false ++ /// Set when saving edits to the profile file failed. ++ var saveError: Error? ++ ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ self.profileName = profile.name ++ self.basalTimeZone = profile.basalRateSchedule.timeZone ++ } ++ ++ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { ++ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { ++ completion(.failure(ProfileValidationError.basalRateError)) ++ return ++ } ++ completion(.success(schedule)) ++ } ++ ++ func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { ++ completion(.success(deliveryLimits)) ++ } ++ ++ func saveCompletion(therapySettings: TherapySettings) { ++ do { ++ try viewModel.updateProfile(named: profileName, from: therapySettings) ++ savedEdits = true ++ } catch { ++ saveError = error ++ } ++ } ++ ++ func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return viewModel.pumpSupportedIncrements() ++ } ++ ++ func updateCurrentProfileName() {} ++} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift new file mode 100644 -index 00000000..1c6244a4 +index 00000000..f816492b --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift @@ -0,0 +1,173 @@ @@ -1387,15 +1523,15 @@ index 00000000..1c6244a4 + case .update: + return Alert( + title: Text("Profile Outdated"), -+ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), -+ primaryButton: .default(Text("Update")) { ++ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), ++ primaryButton: .default(Text("Save Current Settings")) { + viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") + }, -+ secondaryButton: .cancel() ++ secondaryButton: .cancel(Text("Keep Saved Profile")) + ) + case .info: + return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), + dismissButton: .default(Text("Got it!"))) + } + } diff --git a/profiles/dev_profiles_remote_carb_edit.patch b/profiles/dev_profiles_remote_carb_edit.patch index 67ec6ba..9e2257c 100644 --- a/profiles/dev_profiles_remote_carb_edit.patch +++ b/profiles/dev_profiles_remote_carb_edit.patch @@ -81,7 +81,7 @@ index 3eb3154b..6cefc092 100644 return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) } diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 3b74fd9d..5bf1ec36 100644 +index 3b74fd9d..7a8dc92f 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -28,6 +28,7 @@ class ServicesManager { @@ -92,7 +92,7 @@ index 3b74fd9d..5bf1ec36 100644 private var services = [Service]() -@@ -456,3 +457,59 @@ extension Service { +@@ -456,3 +457,58 @@ extension Service { ] } } @@ -127,7 +127,6 @@ index 3b74fd9d..5bf1ec36 100644 + } + } + } -+ viewModel.triggerProfileSync() + NotificationManager.sendRemoteProfileNotification(name: name) + } catch { + NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) @@ -315,10 +314,10 @@ index 4b2cecf3..56742c96 100644 func cancelRemoteOverride() async throws diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..f48848cd +index 00000000..87672389 --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,426 @@ +@@ -0,0 +1,466 @@ +// +// ProfileViewModel+FileManagement.swift +// LoopKitUI @@ -556,17 +555,24 @@ index 00000000..f48848cd + else + { + do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in + switch result { + case .success(let syncedSchedule): + DispatchQueue.main.async { -+ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) -+ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) -+ self?.saveBasalRates(basalRates: syncedSchedule) -+ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ let previousSettings = self.therapySettings ++ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! ++ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule ++ self.therapySettings.basalRateSchedule = syncedSchedule ++ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! ++ // One settings change, so the stored and uploaded settings never mix two profiles. ++ self.setCurrentProfile(name: profile.name, syncChange: false) ++ self.delegate?.saveCompletion(therapySettings: self.therapySettings) ++ if self.therapySettings == previousSettings { ++ // Nothing changed, so only the profile name needs to reach the uploaded settings. ++ self.triggerProfileSync() ++ } + print("New profile loaded") -+ self?.setCurrentProfile(name: profile.name, syncChange: false) + completion(.success) + } + case .failure(let error): @@ -745,6 +751,39 @@ index 00000000..f48848cd + try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) + } +} ++ ++extension ProfileViewModel { ++ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. ++ func therapySettings(applying profile: Profile) -> TherapySettings { ++ var settings = therapySettings ++ let unit = settings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) ++ settings.carbRatioSchedule = profile.carbRatioSchedule ++ settings.basalRateSchedule = profile.basalRateSchedule ++ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) ++ return settings ++ } ++ ++ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. ++ func updateProfile(named name: String, from settings: TherapySettings) throws { ++ loadProfiles() ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ throw ProfileValidationError.fileLoadError ++ } ++ let profile = try getProfile(from: profileReference) ++ let updatedProfile = Profile( ++ name: profile.name, ++ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, ++ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, ++ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, ++ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) ++ loadProfiles() ++ triggerProfileSync() ++ } ++} diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift new file mode 100644 index 00000000..a48fa667 @@ -936,10 +975,10 @@ index 00000000..446910e4 +} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift new file mode 100644 -index 00000000..6de3598f +index 00000000..a956b6d5 --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,326 @@ +@@ -0,0 +1,423 @@ +// +// ProfilePreviewView.swift +// LoopKitUI @@ -957,6 +996,7 @@ index 00000000..6de3598f + case load + case delete + case error ++ case loadEdited +} + +struct ProfilePreviewView: View { @@ -964,7 +1004,10 @@ index 00000000..6de3598f + @ObservedObject var viewModel: ProfileViewModel + @Environment(\.dismissAction) var dismissAction + @Environment(\.presentationMode) var presentationMode -+ var profile: Profile ++ @Environment(\.appName) private var appName ++ @State private var profile: Profile ++ @State private var editingDelegate: ProfileEditingDelegate ++ @State private var therapySettingsViewModel: TherapySettingsViewModel + + @State private var showingAlert = false + @State private var activeAlert: ActiveAlert = .load @@ -975,11 +1018,21 @@ index 00000000..6de3598f + + let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } + ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) ++ _profile = State(initialValue: profile) ++ _editingDelegate = State(initialValue: editingDelegate) ++ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( ++ therapySettings: viewModel.therapySettings(applying: profile), ++ delegate: editingDelegate)) ++ } ++ + var body: some View { + ZStack { + ScrollView { + VStack(alignment: .leading) { -+ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") + .font(.body) + .foregroundColor(.primary) + .padding([.leading, .trailing]) @@ -1046,30 +1099,7 @@ index 00000000..6de3598f + return Alert( + title: Text("Load Profile"), + message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ }), ++ primaryButton: .default(Text("Yes"), action: load), + secondaryButton: .cancel() + ) + case .delete: @@ -1088,6 +1118,13 @@ index 00000000..6de3598f + message: Text(errorText ?? "Unknown error"), + dismissButton: .default(Text("OK")) + ) ++ case .loadEdited: ++ return Alert( ++ title: Text("Load Edited Profile?"), ++ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), ++ primaryButton: .default(Text("Load"), action: load), ++ secondaryButton: .cancel(Text("Later")) ++ ) + } + } + if isRenamingProfile { @@ -1109,6 +1146,22 @@ index 00000000..6de3598f + .offset(y: -22) + } + } ++ .onAppear { ++ // Runs again when an editor closes. ++ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } ++ profile = saved ++ // The editors show the saved values, also after a save that failed. ++ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) ++ if let error = editingDelegate.saveError { ++ editingDelegate.saveError = nil ++ presentAlertLater(.error, errorText: error.localizedDescription) ++ } else if editingDelegate.savedEdits { ++ editingDelegate.savedEdits = false ++ if viewModel.currentProfileName == profile.name { ++ presentAlertLater(.loadEdited) ++ } ++ } ++ } + .onChange(of: shouldDismissSelf) { shouldDismiss in + if shouldDismiss { + self.presentationMode.wrappedValue.dismiss() @@ -1116,6 +1169,40 @@ index 00000000..6de3598f + } + } + ++ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ guard !showingAlert else { return } ++ self.errorText = errorText ++ activeAlert = alert ++ showingAlert = true ++ } ++ } ++ ++ private func load() { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ + private var profileCardStack: CardStack { + var cards: [Card] = [] + @@ -1213,27 +1300,6 @@ index 00000000..6de3598f +} + + -+struct SectionWithContent: View where Content: View { -+ let title: String -+ let content: Content -+ -+ init(title: String, -+ content: () -> Content) -+ { -+ self.title = title -+ self.content = content() -+ } -+ -+ public var body: some View { -+ Section { -+ Text(title) -+ .bold() -+ .frame(maxWidth: .infinity, alignment: .leading) -+ content -+ } -+ } -+} -+ +// MARK: Utilities +extension ProfilePreviewView { + @@ -1247,8 +1313,34 @@ index 00000000..6de3598f + + private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { + Card { -+ SectionWithContent(title: therapySetting.title, -+ content: content) ++ SectionWithTapToEdit( ++ // The basal editor needs the pump's supported rates. ++ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, ++ title: therapySetting.title, ++ descriptiveText: therapySetting.descriptiveText(appName: appName), ++ destination: { dismiss in ++ editor(for: therapySetting, dismiss: dismiss) ++ .environment(\.dismissAction, dismiss) ++ }, ++ content: content ++ ) ++ } ++ } ++ ++ /// The therapy settings editors, saving to this profile. ++ @ViewBuilder ++ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { ++ switch therapySetting { ++ case .glucoseTargetRange: ++ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .carbRatio: ++ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .basalRate: ++ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .insulinSensitivity: ++ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ default: ++ EmptyView() + } + } +} @@ -1266,9 +1358,53 @@ index 00000000..6de3598f + .padding(.trailing, -8) + } +} ++ ++/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. ++final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { ++ private let viewModel: ProfileViewModel ++ private let profileName: String ++ private let basalTimeZone: TimeZone ++ /// Set when edits were saved, so the view can offer to load the edited active profile. ++ var savedEdits = false ++ /// Set when saving edits to the profile file failed. ++ var saveError: Error? ++ ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ self.profileName = profile.name ++ self.basalTimeZone = profile.basalRateSchedule.timeZone ++ } ++ ++ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { ++ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { ++ completion(.failure(ProfileValidationError.basalRateError)) ++ return ++ } ++ completion(.success(schedule)) ++ } ++ ++ func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { ++ completion(.success(deliveryLimits)) ++ } ++ ++ func saveCompletion(therapySettings: TherapySettings) { ++ do { ++ try viewModel.updateProfile(named: profileName, from: therapySettings) ++ savedEdits = true ++ } catch { ++ saveError = error ++ } ++ } ++ ++ func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return viewModel.pumpSupportedIncrements() ++ } ++ ++ func updateCurrentProfileName() {} ++} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift new file mode 100644 -index 00000000..1c6244a4 +index 00000000..f816492b --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift @@ -0,0 +1,173 @@ @@ -1386,15 +1522,15 @@ index 00000000..1c6244a4 + case .update: + return Alert( + title: Text("Profile Outdated"), -+ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), -+ primaryButton: .default(Text("Update")) { ++ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), ++ primaryButton: .default(Text("Save Current Settings")) { + viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") + }, -+ secondaryButton: .cancel() ++ secondaryButton: .cancel(Text("Keep Saved Profile")) + ) + case .info: + return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), + dismissButton: .default(Text("Got it!"))) + } + } diff --git a/profiles/dev_profiles_remote_carb_edit_remote_window.patch b/profiles/dev_profiles_remote_carb_edit_remote_window.patch index e8599f8..d62af50 100644 --- a/profiles/dev_profiles_remote_carb_edit_remote_window.patch +++ b/profiles/dev_profiles_remote_carb_edit_remote_window.patch @@ -81,7 +81,7 @@ index 3eb3154b..6cefc092 100644 return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) } diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 1e272bb2..161a42e3 100644 +index 1e272bb2..d804456f 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -28,6 +28,7 @@ class ServicesManager { @@ -92,7 +92,7 @@ index 1e272bb2..161a42e3 100644 private var services = [Service]() -@@ -482,3 +483,59 @@ extension Service { +@@ -482,3 +483,58 @@ extension Service { ] } } @@ -127,7 +127,6 @@ index 1e272bb2..161a42e3 100644 + } + } + } -+ viewModel.triggerProfileSync() + NotificationManager.sendRemoteProfileNotification(name: name) + } catch { + NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) @@ -315,10 +314,10 @@ index 8fb254b6..858a5621 100644 func cancelRemoteOverride() async throws -> TemporaryScheduleOverride diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..f48848cd +index 00000000..87672389 --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,426 @@ +@@ -0,0 +1,466 @@ +// +// ProfileViewModel+FileManagement.swift +// LoopKitUI @@ -556,17 +555,24 @@ index 00000000..f48848cd + else + { + do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in + switch result { + case .success(let syncedSchedule): + DispatchQueue.main.async { -+ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) -+ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) -+ self?.saveBasalRates(basalRates: syncedSchedule) -+ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ let previousSettings = self.therapySettings ++ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! ++ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule ++ self.therapySettings.basalRateSchedule = syncedSchedule ++ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! ++ // One settings change, so the stored and uploaded settings never mix two profiles. ++ self.setCurrentProfile(name: profile.name, syncChange: false) ++ self.delegate?.saveCompletion(therapySettings: self.therapySettings) ++ if self.therapySettings == previousSettings { ++ // Nothing changed, so only the profile name needs to reach the uploaded settings. ++ self.triggerProfileSync() ++ } + print("New profile loaded") -+ self?.setCurrentProfile(name: profile.name, syncChange: false) + completion(.success) + } + case .failure(let error): @@ -745,6 +751,39 @@ index 00000000..f48848cd + try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) + } +} ++ ++extension ProfileViewModel { ++ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. ++ func therapySettings(applying profile: Profile) -> TherapySettings { ++ var settings = therapySettings ++ let unit = settings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) ++ settings.carbRatioSchedule = profile.carbRatioSchedule ++ settings.basalRateSchedule = profile.basalRateSchedule ++ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) ++ return settings ++ } ++ ++ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. ++ func updateProfile(named name: String, from settings: TherapySettings) throws { ++ loadProfiles() ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ throw ProfileValidationError.fileLoadError ++ } ++ let profile = try getProfile(from: profileReference) ++ let updatedProfile = Profile( ++ name: profile.name, ++ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, ++ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, ++ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, ++ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) ++ loadProfiles() ++ triggerProfileSync() ++ } ++} diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift new file mode 100644 index 00000000..a48fa667 @@ -936,10 +975,10 @@ index 00000000..446910e4 +} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift new file mode 100644 -index 00000000..6de3598f +index 00000000..a956b6d5 --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,326 @@ +@@ -0,0 +1,423 @@ +// +// ProfilePreviewView.swift +// LoopKitUI @@ -957,6 +996,7 @@ index 00000000..6de3598f + case load + case delete + case error ++ case loadEdited +} + +struct ProfilePreviewView: View { @@ -964,7 +1004,10 @@ index 00000000..6de3598f + @ObservedObject var viewModel: ProfileViewModel + @Environment(\.dismissAction) var dismissAction + @Environment(\.presentationMode) var presentationMode -+ var profile: Profile ++ @Environment(\.appName) private var appName ++ @State private var profile: Profile ++ @State private var editingDelegate: ProfileEditingDelegate ++ @State private var therapySettingsViewModel: TherapySettingsViewModel + + @State private var showingAlert = false + @State private var activeAlert: ActiveAlert = .load @@ -975,11 +1018,21 @@ index 00000000..6de3598f + + let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } + ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) ++ _profile = State(initialValue: profile) ++ _editingDelegate = State(initialValue: editingDelegate) ++ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( ++ therapySettings: viewModel.therapySettings(applying: profile), ++ delegate: editingDelegate)) ++ } ++ + var body: some View { + ZStack { + ScrollView { + VStack(alignment: .leading) { -+ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") + .font(.body) + .foregroundColor(.primary) + .padding([.leading, .trailing]) @@ -1046,30 +1099,7 @@ index 00000000..6de3598f + return Alert( + title: Text("Load Profile"), + message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ }), ++ primaryButton: .default(Text("Yes"), action: load), + secondaryButton: .cancel() + ) + case .delete: @@ -1088,6 +1118,13 @@ index 00000000..6de3598f + message: Text(errorText ?? "Unknown error"), + dismissButton: .default(Text("OK")) + ) ++ case .loadEdited: ++ return Alert( ++ title: Text("Load Edited Profile?"), ++ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), ++ primaryButton: .default(Text("Load"), action: load), ++ secondaryButton: .cancel(Text("Later")) ++ ) + } + } + if isRenamingProfile { @@ -1109,6 +1146,22 @@ index 00000000..6de3598f + .offset(y: -22) + } + } ++ .onAppear { ++ // Runs again when an editor closes. ++ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } ++ profile = saved ++ // The editors show the saved values, also after a save that failed. ++ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) ++ if let error = editingDelegate.saveError { ++ editingDelegate.saveError = nil ++ presentAlertLater(.error, errorText: error.localizedDescription) ++ } else if editingDelegate.savedEdits { ++ editingDelegate.savedEdits = false ++ if viewModel.currentProfileName == profile.name { ++ presentAlertLater(.loadEdited) ++ } ++ } ++ } + .onChange(of: shouldDismissSelf) { shouldDismiss in + if shouldDismiss { + self.presentationMode.wrappedValue.dismiss() @@ -1116,6 +1169,40 @@ index 00000000..6de3598f + } + } + ++ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ guard !showingAlert else { return } ++ self.errorText = errorText ++ activeAlert = alert ++ showingAlert = true ++ } ++ } ++ ++ private func load() { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ + private var profileCardStack: CardStack { + var cards: [Card] = [] + @@ -1213,27 +1300,6 @@ index 00000000..6de3598f +} + + -+struct SectionWithContent: View where Content: View { -+ let title: String -+ let content: Content -+ -+ init(title: String, -+ content: () -> Content) -+ { -+ self.title = title -+ self.content = content() -+ } -+ -+ public var body: some View { -+ Section { -+ Text(title) -+ .bold() -+ .frame(maxWidth: .infinity, alignment: .leading) -+ content -+ } -+ } -+} -+ +// MARK: Utilities +extension ProfilePreviewView { + @@ -1247,8 +1313,34 @@ index 00000000..6de3598f + + private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { + Card { -+ SectionWithContent(title: therapySetting.title, -+ content: content) ++ SectionWithTapToEdit( ++ // The basal editor needs the pump's supported rates. ++ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, ++ title: therapySetting.title, ++ descriptiveText: therapySetting.descriptiveText(appName: appName), ++ destination: { dismiss in ++ editor(for: therapySetting, dismiss: dismiss) ++ .environment(\.dismissAction, dismiss) ++ }, ++ content: content ++ ) ++ } ++ } ++ ++ /// The therapy settings editors, saving to this profile. ++ @ViewBuilder ++ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { ++ switch therapySetting { ++ case .glucoseTargetRange: ++ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .carbRatio: ++ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .basalRate: ++ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .insulinSensitivity: ++ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ default: ++ EmptyView() + } + } +} @@ -1266,9 +1358,53 @@ index 00000000..6de3598f + .padding(.trailing, -8) + } +} ++ ++/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. ++final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { ++ private let viewModel: ProfileViewModel ++ private let profileName: String ++ private let basalTimeZone: TimeZone ++ /// Set when edits were saved, so the view can offer to load the edited active profile. ++ var savedEdits = false ++ /// Set when saving edits to the profile file failed. ++ var saveError: Error? ++ ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ self.profileName = profile.name ++ self.basalTimeZone = profile.basalRateSchedule.timeZone ++ } ++ ++ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { ++ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { ++ completion(.failure(ProfileValidationError.basalRateError)) ++ return ++ } ++ completion(.success(schedule)) ++ } ++ ++ func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { ++ completion(.success(deliveryLimits)) ++ } ++ ++ func saveCompletion(therapySettings: TherapySettings) { ++ do { ++ try viewModel.updateProfile(named: profileName, from: therapySettings) ++ savedEdits = true ++ } catch { ++ saveError = error ++ } ++ } ++ ++ func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return viewModel.pumpSupportedIncrements() ++ } ++ ++ func updateCurrentProfileName() {} ++} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift new file mode 100644 -index 00000000..1c6244a4 +index 00000000..f816492b --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift @@ -0,0 +1,173 @@ @@ -1386,15 +1522,15 @@ index 00000000..1c6244a4 + case .update: + return Alert( + title: Text("Profile Outdated"), -+ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), -+ primaryButton: .default(Text("Update")) { ++ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), ++ primaryButton: .default(Text("Save Current Settings")) { + viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") + }, -+ secondaryButton: .cancel() ++ secondaryButton: .cancel(Text("Keep Saved Profile")) + ) + case .info: + return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), + dismissButton: .default(Text("Got it!"))) + } + } diff --git a/profiles/dev_profiles_remote_window.patch b/profiles/dev_profiles_remote_window.patch index 186c582..3791ed8 100644 --- a/profiles/dev_profiles_remote_window.patch +++ b/profiles/dev_profiles_remote_window.patch @@ -81,7 +81,7 @@ index 996d1470..17cff75d 100644 return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) } diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 0b498420..f5f0d3c5 100644 +index 0b498420..a9d9177d 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -28,6 +28,7 @@ class ServicesManager { @@ -92,7 +92,7 @@ index 0b498420..f5f0d3c5 100644 private var services = [Service]() -@@ -447,3 +448,59 @@ extension Service { +@@ -447,3 +448,58 @@ extension Service { ] } } @@ -127,7 +127,6 @@ index 0b498420..f5f0d3c5 100644 + } + } + } -+ viewModel.triggerProfileSync() + NotificationManager.sendRemoteProfileNotification(name: name) + } catch { + NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) @@ -315,10 +314,10 @@ index e4279746..6f691598 100644 func cancelRemoteOverride() async throws -> TemporaryScheduleOverride diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..f48848cd +index 00000000..87672389 --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,426 @@ +@@ -0,0 +1,466 @@ +// +// ProfileViewModel+FileManagement.swift +// LoopKitUI @@ -556,17 +555,24 @@ index 00000000..f48848cd + else + { + do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in + switch result { + case .success(let syncedSchedule): + DispatchQueue.main.async { -+ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) -+ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) -+ self?.saveBasalRates(basalRates: syncedSchedule) -+ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ let previousSettings = self.therapySettings ++ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! ++ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule ++ self.therapySettings.basalRateSchedule = syncedSchedule ++ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! ++ // One settings change, so the stored and uploaded settings never mix two profiles. ++ self.setCurrentProfile(name: profile.name, syncChange: false) ++ self.delegate?.saveCompletion(therapySettings: self.therapySettings) ++ if self.therapySettings == previousSettings { ++ // Nothing changed, so only the profile name needs to reach the uploaded settings. ++ self.triggerProfileSync() ++ } + print("New profile loaded") -+ self?.setCurrentProfile(name: profile.name, syncChange: false) + completion(.success) + } + case .failure(let error): @@ -745,6 +751,39 @@ index 00000000..f48848cd + try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) + } +} ++ ++extension ProfileViewModel { ++ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. ++ func therapySettings(applying profile: Profile) -> TherapySettings { ++ var settings = therapySettings ++ let unit = settings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter ++ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) ++ settings.carbRatioSchedule = profile.carbRatioSchedule ++ settings.basalRateSchedule = profile.basalRateSchedule ++ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) ++ return settings ++ } ++ ++ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. ++ func updateProfile(named name: String, from settings: TherapySettings) throws { ++ loadProfiles() ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ throw ProfileValidationError.fileLoadError ++ } ++ let profile = try getProfile(from: profileReference) ++ let updatedProfile = Profile( ++ name: profile.name, ++ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, ++ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, ++ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, ++ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) ++ loadProfiles() ++ triggerProfileSync() ++ } ++} diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift new file mode 100644 index 00000000..a48fa667 @@ -936,10 +975,10 @@ index 00000000..446910e4 +} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift new file mode 100644 -index 00000000..6de3598f +index 00000000..a956b6d5 --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,326 @@ +@@ -0,0 +1,423 @@ +// +// ProfilePreviewView.swift +// LoopKitUI @@ -957,6 +996,7 @@ index 00000000..6de3598f + case load + case delete + case error ++ case loadEdited +} + +struct ProfilePreviewView: View { @@ -964,7 +1004,10 @@ index 00000000..6de3598f + @ObservedObject var viewModel: ProfileViewModel + @Environment(\.dismissAction) var dismissAction + @Environment(\.presentationMode) var presentationMode -+ var profile: Profile ++ @Environment(\.appName) private var appName ++ @State private var profile: Profile ++ @State private var editingDelegate: ProfileEditingDelegate ++ @State private var therapySettingsViewModel: TherapySettingsViewModel + + @State private var showingAlert = false + @State private var activeAlert: ActiveAlert = .load @@ -975,11 +1018,21 @@ index 00000000..6de3598f + + let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } + ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) ++ _profile = State(initialValue: profile) ++ _editingDelegate = State(initialValue: editingDelegate) ++ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( ++ therapySettings: viewModel.therapySettings(applying: profile), ++ delegate: editingDelegate)) ++ } ++ + var body: some View { + ZStack { + ScrollView { + VStack(alignment: .leading) { -+ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") + .font(.body) + .foregroundColor(.primary) + .padding([.leading, .trailing]) @@ -1046,30 +1099,7 @@ index 00000000..6de3598f + return Alert( + title: Text("Load Profile"), + message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ }), ++ primaryButton: .default(Text("Yes"), action: load), + secondaryButton: .cancel() + ) + case .delete: @@ -1088,6 +1118,13 @@ index 00000000..6de3598f + message: Text(errorText ?? "Unknown error"), + dismissButton: .default(Text("OK")) + ) ++ case .loadEdited: ++ return Alert( ++ title: Text("Load Edited Profile?"), ++ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), ++ primaryButton: .default(Text("Load"), action: load), ++ secondaryButton: .cancel(Text("Later")) ++ ) + } + } + if isRenamingProfile { @@ -1109,6 +1146,22 @@ index 00000000..6de3598f + .offset(y: -22) + } + } ++ .onAppear { ++ // Runs again when an editor closes. ++ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } ++ profile = saved ++ // The editors show the saved values, also after a save that failed. ++ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) ++ if let error = editingDelegate.saveError { ++ editingDelegate.saveError = nil ++ presentAlertLater(.error, errorText: error.localizedDescription) ++ } else if editingDelegate.savedEdits { ++ editingDelegate.savedEdits = false ++ if viewModel.currentProfileName == profile.name { ++ presentAlertLater(.loadEdited) ++ } ++ } ++ } + .onChange(of: shouldDismissSelf) { shouldDismiss in + if shouldDismiss { + self.presentationMode.wrappedValue.dismiss() @@ -1116,6 +1169,40 @@ index 00000000..6de3598f + } + } + ++ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ guard !showingAlert else { return } ++ self.errorText = errorText ++ activeAlert = alert ++ showingAlert = true ++ } ++ } ++ ++ private func load() { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ + private var profileCardStack: CardStack { + var cards: [Card] = [] + @@ -1213,27 +1300,6 @@ index 00000000..6de3598f +} + + -+struct SectionWithContent: View where Content: View { -+ let title: String -+ let content: Content -+ -+ init(title: String, -+ content: () -> Content) -+ { -+ self.title = title -+ self.content = content() -+ } -+ -+ public var body: some View { -+ Section { -+ Text(title) -+ .bold() -+ .frame(maxWidth: .infinity, alignment: .leading) -+ content -+ } -+ } -+} -+ +// MARK: Utilities +extension ProfilePreviewView { + @@ -1247,8 +1313,34 @@ index 00000000..6de3598f + + private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { + Card { -+ SectionWithContent(title: therapySetting.title, -+ content: content) ++ SectionWithTapToEdit( ++ // The basal editor needs the pump's supported rates. ++ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, ++ title: therapySetting.title, ++ descriptiveText: therapySetting.descriptiveText(appName: appName), ++ destination: { dismiss in ++ editor(for: therapySetting, dismiss: dismiss) ++ .environment(\.dismissAction, dismiss) ++ }, ++ content: content ++ ) ++ } ++ } ++ ++ /// The therapy settings editors, saving to this profile. ++ @ViewBuilder ++ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { ++ switch therapySetting { ++ case .glucoseTargetRange: ++ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .carbRatio: ++ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .basalRate: ++ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .insulinSensitivity: ++ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ default: ++ EmptyView() + } + } +} @@ -1266,9 +1358,53 @@ index 00000000..6de3598f + .padding(.trailing, -8) + } +} ++ ++/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. ++final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { ++ private let viewModel: ProfileViewModel ++ private let profileName: String ++ private let basalTimeZone: TimeZone ++ /// Set when edits were saved, so the view can offer to load the edited active profile. ++ var savedEdits = false ++ /// Set when saving edits to the profile file failed. ++ var saveError: Error? ++ ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ self.profileName = profile.name ++ self.basalTimeZone = profile.basalRateSchedule.timeZone ++ } ++ ++ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { ++ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { ++ completion(.failure(ProfileValidationError.basalRateError)) ++ return ++ } ++ completion(.success(schedule)) ++ } ++ ++ func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { ++ completion(.success(deliveryLimits)) ++ } ++ ++ func saveCompletion(therapySettings: TherapySettings) { ++ do { ++ try viewModel.updateProfile(named: profileName, from: therapySettings) ++ savedEdits = true ++ } catch { ++ saveError = error ++ } ++ } ++ ++ func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return viewModel.pumpSupportedIncrements() ++ } ++ ++ func updateCurrentProfileName() {} ++} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift new file mode 100644 -index 00000000..1c6244a4 +index 00000000..f816492b --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift @@ -0,0 +1,173 @@ @@ -1386,15 +1522,15 @@ index 00000000..1c6244a4 + case .update: + return Alert( + title: Text("Profile Outdated"), -+ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), -+ primaryButton: .default(Text("Update")) { ++ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), ++ primaryButton: .default(Text("Save Current Settings")) { + viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") + }, -+ secondaryButton: .cancel() ++ secondaryButton: .cancel(Text("Keep Saved Profile")) + ) + case .info: + return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), + dismissButton: .default(Text("Got it!"))) + } + } diff --git a/profiles/nextdev_profiles.patch b/profiles/nextdev_profiles.patch index 407ac8d..57af0bd 100644 --- a/profiles/nextdev_profiles.patch +++ b/profiles/nextdev_profiles.patch @@ -86,7 +86,7 @@ index 42eff19f..c4d0f32e 100644 return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) } diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index b557f184..15602f08 100644 +index b557f184..a7da8548 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -30,6 +30,7 @@ class ServicesManager { @@ -97,7 +97,7 @@ index b557f184..15602f08 100644 private var services = [Service]() -@@ -423,3 +424,58 @@ extension Service { +@@ -423,3 +424,57 @@ extension Service { ] } } @@ -131,7 +131,6 @@ index b557f184..15602f08 100644 + } + } + } -+ viewModel.triggerProfileSync() + NotificationManager.sendRemoteProfileNotification(name: name) + } catch { + NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) @@ -273,10 +272,10 @@ index 8f824906..a7c9c653 100644 func cancelRemoteOverride() async throws diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..0a287cac +index 00000000..b876029d --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,427 @@ +@@ -0,0 +1,467 @@ +// +// ProfileViewModel+FileManagement.swift +// LoopKitUI @@ -515,17 +514,24 @@ index 00000000..0a287cac + else + { + do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in + switch result { + case .success(let syncedSchedule): + DispatchQueue.main.async { -+ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter -+ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) -+ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) -+ self?.saveBasalRates(basalRates: syncedSchedule) -+ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ let previousSettings = self.therapySettings ++ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter ++ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! ++ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule ++ self.therapySettings.basalRateSchedule = syncedSchedule ++ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! ++ // One settings change, so the stored and uploaded settings never mix two profiles. ++ self.setCurrentProfile(name: profile.name, syncChange: false) ++ self.delegate?.saveCompletion(therapySettings: self.therapySettings) ++ if self.therapySettings == previousSettings { ++ // Nothing changed, so only the profile name needs to reach the uploaded settings. ++ self.triggerProfileSync() ++ } + print("New profile loaded") -+ self?.setCurrentProfile(name: profile.name, syncChange: false) + completion(.success) + } + case .failure(let error): @@ -704,6 +710,39 @@ index 00000000..0a287cac + try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) + } +} ++ ++extension ProfileViewModel { ++ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. ++ func therapySettings(applying profile: Profile) -> TherapySettings { ++ var settings = therapySettings ++ let unit = settings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter ++ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) ++ settings.carbRatioSchedule = profile.carbRatioSchedule ++ settings.basalRateSchedule = profile.basalRateSchedule ++ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) ++ return settings ++ } ++ ++ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. ++ func updateProfile(named name: String, from settings: TherapySettings) throws { ++ loadProfiles() ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ throw ProfileValidationError.fileLoadError ++ } ++ let profile = try getProfile(from: profileReference) ++ let updatedProfile = Profile( ++ name: profile.name, ++ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, ++ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, ++ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, ++ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) ++ loadProfiles() ++ triggerProfileSync() ++ } ++} diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift new file mode 100644 index 00000000..a48fa667 @@ -895,10 +934,10 @@ index 00000000..446910e4 +} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift new file mode 100644 -index 00000000..3566055d +index 00000000..9eb00f90 --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,334 @@ +@@ -0,0 +1,432 @@ +// +// ProfilePreviewView.swift +// LoopKitUI @@ -917,6 +956,7 @@ index 00000000..3566055d + case load + case delete + case error ++ case loadEdited +} + +struct ProfilePreviewView: View { @@ -924,7 +964,11 @@ index 00000000..3566055d + @ObservedObject var viewModel: ProfileViewModel + @Environment(\.dismissAction) var dismissAction + @Environment(\.presentationMode) var presentationMode -+ var profile: Profile ++ @Environment(\.appName) private var appName ++ @Environment(\.dosingStrategySelectionEnabled) private var dosingStrategySelectionEnabled ++ @State private var profile: Profile ++ @State private var editingDelegate: ProfileEditingDelegate ++ @State private var therapySettingsViewModel: TherapySettingsViewModel + + @State private var showingAlert = false + @State private var activeAlert: ActiveAlert = .load @@ -935,11 +979,21 @@ index 00000000..3566055d + + let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } + ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) ++ _profile = State(initialValue: profile) ++ _editingDelegate = State(initialValue: editingDelegate) ++ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( ++ therapySettings: viewModel.therapySettings(applying: profile), ++ delegate: editingDelegate)) ++ } ++ + var body: some View { + ZStack { + ScrollView { + VStack(alignment: .leading) { -+ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") + .font(.body) + .foregroundColor(.primary) + .padding([.leading, .trailing]) @@ -1006,30 +1060,7 @@ index 00000000..3566055d + return Alert( + title: Text("Load Profile"), + message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ }), ++ primaryButton: .default(Text("Yes"), action: load), + secondaryButton: .cancel() + ) + case .delete: @@ -1048,6 +1079,13 @@ index 00000000..3566055d + message: Text(errorText ?? "Unknown error"), + dismissButton: .default(Text("OK")) + ) ++ case .loadEdited: ++ return Alert( ++ title: Text("Load Edited Profile?"), ++ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), ++ primaryButton: .default(Text("Load"), action: load), ++ secondaryButton: .cancel(Text("Later")) ++ ) + } + } + if isRenamingProfile { @@ -1069,6 +1107,22 @@ index 00000000..3566055d + .offset(y: -22) + } + } ++ .onAppear { ++ // Runs again when an editor closes. ++ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } ++ profile = saved ++ // The editors show the saved values, also after a save that failed. ++ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) ++ if let error = editingDelegate.saveError { ++ editingDelegate.saveError = nil ++ presentAlertLater(.error, errorText: error.localizedDescription) ++ } else if editingDelegate.savedEdits { ++ editingDelegate.savedEdits = false ++ if viewModel.currentProfileName == profile.name { ++ presentAlertLater(.loadEdited) ++ } ++ } ++ } + .onChange(of: shouldDismissSelf) { shouldDismiss in + if shouldDismiss { + self.presentationMode.wrappedValue.dismiss() @@ -1076,6 +1130,40 @@ index 00000000..3566055d + } + } + ++ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ guard !showingAlert else { return } ++ self.errorText = errorText ++ activeAlert = alert ++ showingAlert = true ++ } ++ } ++ ++ private func load() { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ + private var profileCardStack: CardStack { + var cards: [Card] = [] + @@ -1173,27 +1261,6 @@ index 00000000..3566055d +} + + -+struct SectionWithContent: View where Content: View { -+ let title: String -+ let content: Content -+ -+ init(title: String, -+ content: () -> Content) -+ { -+ self.title = title -+ self.content = content() -+ } -+ -+ public var body: some View { -+ Section { -+ Text(title) -+ .bold() -+ .frame(maxWidth: .infinity, alignment: .leading) -+ content -+ } -+ } -+} -+ +// MARK: Utilities +extension ProfilePreviewView { + @@ -1214,8 +1281,34 @@ index 00000000..3566055d + + private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { + Card { -+ SectionWithContent(title: therapySetting.title, -+ content: content) ++ SectionWithTapToEdit( ++ // The basal editor needs the pump's supported rates. ++ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, ++ title: therapySetting.title, ++ descriptiveText: therapySetting.descriptiveText(appName: appName, dosingStrategySelectionEnabled: dosingStrategySelectionEnabled), ++ destination: { dismiss in ++ editor(for: therapySetting, dismiss: dismiss) ++ .environment(\.dismissAction, dismiss) ++ }, ++ content: content ++ ) ++ } ++ } ++ ++ /// The therapy settings editors, saving to this profile. ++ @ViewBuilder ++ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { ++ switch therapySetting { ++ case .glucoseTargetRange: ++ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .carbRatio: ++ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .basalRate: ++ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .insulinSensitivity: ++ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ default: ++ EmptyView() + } + } +} @@ -1233,9 +1326,53 @@ index 00000000..3566055d + .padding(.trailing, -8) + } +} ++ ++/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. ++final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { ++ private let viewModel: ProfileViewModel ++ private let profileName: String ++ private let basalTimeZone: TimeZone ++ /// Set when edits were saved, so the view can offer to load the edited active profile. ++ var savedEdits = false ++ /// Set when saving edits to the profile file failed. ++ var saveError: Error? ++ ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ self.profileName = profile.name ++ self.basalTimeZone = profile.basalRateSchedule.timeZone ++ } ++ ++ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { ++ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { ++ completion(.failure(ProfileValidationError.basalRateError)) ++ return ++ } ++ completion(.success(schedule)) ++ } ++ ++ func syncDeliveryLimits(deliveryLimits: DeliveryLimits) async throws -> DeliveryLimits { ++ return deliveryLimits ++ } ++ ++ func saveCompletion(therapySettings: TherapySettings) { ++ do { ++ try viewModel.updateProfile(named: profileName, from: therapySettings) ++ savedEdits = true ++ } catch { ++ saveError = error ++ } ++ } ++ ++ func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return viewModel.pumpSupportedIncrements() ++ } ++ ++ func updateCurrentProfileName() {} ++} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift new file mode 100644 -index 00000000..a1f35678 +index 00000000..ffced54b --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift @@ -0,0 +1,172 @@ @@ -1353,15 +1490,15 @@ index 00000000..a1f35678 + case .update: + return Alert( + title: Text("Profile Outdated"), -+ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), -+ primaryButton: .default(Text("Update")) { ++ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), ++ primaryButton: .default(Text("Save Current Settings")) { + viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") + }, -+ secondaryButton: .cancel() ++ secondaryButton: .cancel(Text("Keep Saved Profile")) + ) + case .info: + return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), + dismissButton: .default(Text("Got it!"))) + } + } diff --git a/profiles/nextdev_profiles_remote_carb_edit.patch b/profiles/nextdev_profiles_remote_carb_edit.patch index 40a9678..39042f1 100644 --- a/profiles/nextdev_profiles_remote_carb_edit.patch +++ b/profiles/nextdev_profiles_remote_carb_edit.patch @@ -86,7 +86,7 @@ index 18975ce1..1cf442dd 100644 return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) } diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 56cb3642..ffafea70 100644 +index 56cb3642..20d2f3d4 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -30,6 +30,7 @@ class ServicesManager { @@ -97,7 +97,7 @@ index 56cb3642..ffafea70 100644 private var services = [Service]() -@@ -467,3 +468,58 @@ extension Service { +@@ -467,3 +468,57 @@ extension Service { ] } } @@ -131,7 +131,6 @@ index 56cb3642..ffafea70 100644 + } + } + } -+ viewModel.triggerProfileSync() + NotificationManager.sendRemoteProfileNotification(name: name) + } catch { + NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) @@ -273,10 +272,10 @@ index 6d3f9db2..5f1f37f3 100644 func cancelRemoteOverride() async throws diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..0a287cac +index 00000000..b876029d --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,427 @@ +@@ -0,0 +1,467 @@ +// +// ProfileViewModel+FileManagement.swift +// LoopKitUI @@ -515,17 +514,24 @@ index 00000000..0a287cac + else + { + do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in + switch result { + case .success(let syncedSchedule): + DispatchQueue.main.async { -+ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter -+ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) -+ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) -+ self?.saveBasalRates(basalRates: syncedSchedule) -+ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ let previousSettings = self.therapySettings ++ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter ++ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! ++ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule ++ self.therapySettings.basalRateSchedule = syncedSchedule ++ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! ++ // One settings change, so the stored and uploaded settings never mix two profiles. ++ self.setCurrentProfile(name: profile.name, syncChange: false) ++ self.delegate?.saveCompletion(therapySettings: self.therapySettings) ++ if self.therapySettings == previousSettings { ++ // Nothing changed, so only the profile name needs to reach the uploaded settings. ++ self.triggerProfileSync() ++ } + print("New profile loaded") -+ self?.setCurrentProfile(name: profile.name, syncChange: false) + completion(.success) + } + case .failure(let error): @@ -704,6 +710,39 @@ index 00000000..0a287cac + try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) + } +} ++ ++extension ProfileViewModel { ++ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. ++ func therapySettings(applying profile: Profile) -> TherapySettings { ++ var settings = therapySettings ++ let unit = settings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter ++ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) ++ settings.carbRatioSchedule = profile.carbRatioSchedule ++ settings.basalRateSchedule = profile.basalRateSchedule ++ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) ++ return settings ++ } ++ ++ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. ++ func updateProfile(named name: String, from settings: TherapySettings) throws { ++ loadProfiles() ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ throw ProfileValidationError.fileLoadError ++ } ++ let profile = try getProfile(from: profileReference) ++ let updatedProfile = Profile( ++ name: profile.name, ++ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, ++ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, ++ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, ++ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) ++ loadProfiles() ++ triggerProfileSync() ++ } ++} diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift new file mode 100644 index 00000000..a48fa667 @@ -895,10 +934,10 @@ index 00000000..446910e4 +} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift new file mode 100644 -index 00000000..3566055d +index 00000000..9eb00f90 --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,334 @@ +@@ -0,0 +1,432 @@ +// +// ProfilePreviewView.swift +// LoopKitUI @@ -917,6 +956,7 @@ index 00000000..3566055d + case load + case delete + case error ++ case loadEdited +} + +struct ProfilePreviewView: View { @@ -924,7 +964,11 @@ index 00000000..3566055d + @ObservedObject var viewModel: ProfileViewModel + @Environment(\.dismissAction) var dismissAction + @Environment(\.presentationMode) var presentationMode -+ var profile: Profile ++ @Environment(\.appName) private var appName ++ @Environment(\.dosingStrategySelectionEnabled) private var dosingStrategySelectionEnabled ++ @State private var profile: Profile ++ @State private var editingDelegate: ProfileEditingDelegate ++ @State private var therapySettingsViewModel: TherapySettingsViewModel + + @State private var showingAlert = false + @State private var activeAlert: ActiveAlert = .load @@ -935,11 +979,21 @@ index 00000000..3566055d + + let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } + ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) ++ _profile = State(initialValue: profile) ++ _editingDelegate = State(initialValue: editingDelegate) ++ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( ++ therapySettings: viewModel.therapySettings(applying: profile), ++ delegate: editingDelegate)) ++ } ++ + var body: some View { + ZStack { + ScrollView { + VStack(alignment: .leading) { -+ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") + .font(.body) + .foregroundColor(.primary) + .padding([.leading, .trailing]) @@ -1006,30 +1060,7 @@ index 00000000..3566055d + return Alert( + title: Text("Load Profile"), + message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ }), ++ primaryButton: .default(Text("Yes"), action: load), + secondaryButton: .cancel() + ) + case .delete: @@ -1048,6 +1079,13 @@ index 00000000..3566055d + message: Text(errorText ?? "Unknown error"), + dismissButton: .default(Text("OK")) + ) ++ case .loadEdited: ++ return Alert( ++ title: Text("Load Edited Profile?"), ++ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), ++ primaryButton: .default(Text("Load"), action: load), ++ secondaryButton: .cancel(Text("Later")) ++ ) + } + } + if isRenamingProfile { @@ -1069,6 +1107,22 @@ index 00000000..3566055d + .offset(y: -22) + } + } ++ .onAppear { ++ // Runs again when an editor closes. ++ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } ++ profile = saved ++ // The editors show the saved values, also after a save that failed. ++ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) ++ if let error = editingDelegate.saveError { ++ editingDelegate.saveError = nil ++ presentAlertLater(.error, errorText: error.localizedDescription) ++ } else if editingDelegate.savedEdits { ++ editingDelegate.savedEdits = false ++ if viewModel.currentProfileName == profile.name { ++ presentAlertLater(.loadEdited) ++ } ++ } ++ } + .onChange(of: shouldDismissSelf) { shouldDismiss in + if shouldDismiss { + self.presentationMode.wrappedValue.dismiss() @@ -1076,6 +1130,40 @@ index 00000000..3566055d + } + } + ++ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ guard !showingAlert else { return } ++ self.errorText = errorText ++ activeAlert = alert ++ showingAlert = true ++ } ++ } ++ ++ private func load() { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ + private var profileCardStack: CardStack { + var cards: [Card] = [] + @@ -1173,27 +1261,6 @@ index 00000000..3566055d +} + + -+struct SectionWithContent: View where Content: View { -+ let title: String -+ let content: Content -+ -+ init(title: String, -+ content: () -> Content) -+ { -+ self.title = title -+ self.content = content() -+ } -+ -+ public var body: some View { -+ Section { -+ Text(title) -+ .bold() -+ .frame(maxWidth: .infinity, alignment: .leading) -+ content -+ } -+ } -+} -+ +// MARK: Utilities +extension ProfilePreviewView { + @@ -1214,8 +1281,34 @@ index 00000000..3566055d + + private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { + Card { -+ SectionWithContent(title: therapySetting.title, -+ content: content) ++ SectionWithTapToEdit( ++ // The basal editor needs the pump's supported rates. ++ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, ++ title: therapySetting.title, ++ descriptiveText: therapySetting.descriptiveText(appName: appName, dosingStrategySelectionEnabled: dosingStrategySelectionEnabled), ++ destination: { dismiss in ++ editor(for: therapySetting, dismiss: dismiss) ++ .environment(\.dismissAction, dismiss) ++ }, ++ content: content ++ ) ++ } ++ } ++ ++ /// The therapy settings editors, saving to this profile. ++ @ViewBuilder ++ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { ++ switch therapySetting { ++ case .glucoseTargetRange: ++ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .carbRatio: ++ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .basalRate: ++ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .insulinSensitivity: ++ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ default: ++ EmptyView() + } + } +} @@ -1233,9 +1326,53 @@ index 00000000..3566055d + .padding(.trailing, -8) + } +} ++ ++/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. ++final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { ++ private let viewModel: ProfileViewModel ++ private let profileName: String ++ private let basalTimeZone: TimeZone ++ /// Set when edits were saved, so the view can offer to load the edited active profile. ++ var savedEdits = false ++ /// Set when saving edits to the profile file failed. ++ var saveError: Error? ++ ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ self.profileName = profile.name ++ self.basalTimeZone = profile.basalRateSchedule.timeZone ++ } ++ ++ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { ++ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { ++ completion(.failure(ProfileValidationError.basalRateError)) ++ return ++ } ++ completion(.success(schedule)) ++ } ++ ++ func syncDeliveryLimits(deliveryLimits: DeliveryLimits) async throws -> DeliveryLimits { ++ return deliveryLimits ++ } ++ ++ func saveCompletion(therapySettings: TherapySettings) { ++ do { ++ try viewModel.updateProfile(named: profileName, from: therapySettings) ++ savedEdits = true ++ } catch { ++ saveError = error ++ } ++ } ++ ++ func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return viewModel.pumpSupportedIncrements() ++ } ++ ++ func updateCurrentProfileName() {} ++} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift new file mode 100644 -index 00000000..a1f35678 +index 00000000..ffced54b --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift @@ -0,0 +1,172 @@ @@ -1353,15 +1490,15 @@ index 00000000..a1f35678 + case .update: + return Alert( + title: Text("Profile Outdated"), -+ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), -+ primaryButton: .default(Text("Update")) { ++ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), ++ primaryButton: .default(Text("Save Current Settings")) { + viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") + }, -+ secondaryButton: .cancel() ++ secondaryButton: .cancel(Text("Keep Saved Profile")) + ) + case .info: + return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), + dismissButton: .default(Text("Got it!"))) + } + } diff --git a/profiles/nextdev_profiles_remote_carb_edit_remote_window.patch b/profiles/nextdev_profiles_remote_carb_edit_remote_window.patch index ec1128a..082ab59 100644 --- a/profiles/nextdev_profiles_remote_carb_edit_remote_window.patch +++ b/profiles/nextdev_profiles_remote_carb_edit_remote_window.patch @@ -86,7 +86,7 @@ index 18975ce1..1cf442dd 100644 return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) } diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 3aaf5212..65a47001 100644 +index 3aaf5212..f710fb3e 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -30,6 +30,7 @@ class ServicesManager { @@ -97,7 +97,7 @@ index 3aaf5212..65a47001 100644 private var services = [Service]() -@@ -476,3 +477,58 @@ extension Service { +@@ -476,3 +477,57 @@ extension Service { ] } } @@ -131,7 +131,6 @@ index 3aaf5212..65a47001 100644 + } + } + } -+ viewModel.triggerProfileSync() + NotificationManager.sendRemoteProfileNotification(name: name) + } catch { + NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) @@ -273,10 +272,10 @@ index 7da27b70..39c0eee9 100644 func cancelRemoteOverride() async throws diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..0a287cac +index 00000000..b876029d --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,427 @@ +@@ -0,0 +1,467 @@ +// +// ProfileViewModel+FileManagement.swift +// LoopKitUI @@ -515,17 +514,24 @@ index 00000000..0a287cac + else + { + do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in + switch result { + case .success(let syncedSchedule): + DispatchQueue.main.async { -+ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter -+ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) -+ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) -+ self?.saveBasalRates(basalRates: syncedSchedule) -+ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ let previousSettings = self.therapySettings ++ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter ++ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! ++ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule ++ self.therapySettings.basalRateSchedule = syncedSchedule ++ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! ++ // One settings change, so the stored and uploaded settings never mix two profiles. ++ self.setCurrentProfile(name: profile.name, syncChange: false) ++ self.delegate?.saveCompletion(therapySettings: self.therapySettings) ++ if self.therapySettings == previousSettings { ++ // Nothing changed, so only the profile name needs to reach the uploaded settings. ++ self.triggerProfileSync() ++ } + print("New profile loaded") -+ self?.setCurrentProfile(name: profile.name, syncChange: false) + completion(.success) + } + case .failure(let error): @@ -704,6 +710,39 @@ index 00000000..0a287cac + try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) + } +} ++ ++extension ProfileViewModel { ++ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. ++ func therapySettings(applying profile: Profile) -> TherapySettings { ++ var settings = therapySettings ++ let unit = settings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter ++ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) ++ settings.carbRatioSchedule = profile.carbRatioSchedule ++ settings.basalRateSchedule = profile.basalRateSchedule ++ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) ++ return settings ++ } ++ ++ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. ++ func updateProfile(named name: String, from settings: TherapySettings) throws { ++ loadProfiles() ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ throw ProfileValidationError.fileLoadError ++ } ++ let profile = try getProfile(from: profileReference) ++ let updatedProfile = Profile( ++ name: profile.name, ++ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, ++ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, ++ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, ++ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) ++ loadProfiles() ++ triggerProfileSync() ++ } ++} diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift new file mode 100644 index 00000000..a48fa667 @@ -895,10 +934,10 @@ index 00000000..446910e4 +} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift new file mode 100644 -index 00000000..3566055d +index 00000000..9eb00f90 --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,334 @@ +@@ -0,0 +1,432 @@ +// +// ProfilePreviewView.swift +// LoopKitUI @@ -917,6 +956,7 @@ index 00000000..3566055d + case load + case delete + case error ++ case loadEdited +} + +struct ProfilePreviewView: View { @@ -924,7 +964,11 @@ index 00000000..3566055d + @ObservedObject var viewModel: ProfileViewModel + @Environment(\.dismissAction) var dismissAction + @Environment(\.presentationMode) var presentationMode -+ var profile: Profile ++ @Environment(\.appName) private var appName ++ @Environment(\.dosingStrategySelectionEnabled) private var dosingStrategySelectionEnabled ++ @State private var profile: Profile ++ @State private var editingDelegate: ProfileEditingDelegate ++ @State private var therapySettingsViewModel: TherapySettingsViewModel + + @State private var showingAlert = false + @State private var activeAlert: ActiveAlert = .load @@ -935,11 +979,21 @@ index 00000000..3566055d + + let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } + ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) ++ _profile = State(initialValue: profile) ++ _editingDelegate = State(initialValue: editingDelegate) ++ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( ++ therapySettings: viewModel.therapySettings(applying: profile), ++ delegate: editingDelegate)) ++ } ++ + var body: some View { + ZStack { + ScrollView { + VStack(alignment: .leading) { -+ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") + .font(.body) + .foregroundColor(.primary) + .padding([.leading, .trailing]) @@ -1006,30 +1060,7 @@ index 00000000..3566055d + return Alert( + title: Text("Load Profile"), + message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ }), ++ primaryButton: .default(Text("Yes"), action: load), + secondaryButton: .cancel() + ) + case .delete: @@ -1048,6 +1079,13 @@ index 00000000..3566055d + message: Text(errorText ?? "Unknown error"), + dismissButton: .default(Text("OK")) + ) ++ case .loadEdited: ++ return Alert( ++ title: Text("Load Edited Profile?"), ++ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), ++ primaryButton: .default(Text("Load"), action: load), ++ secondaryButton: .cancel(Text("Later")) ++ ) + } + } + if isRenamingProfile { @@ -1069,6 +1107,22 @@ index 00000000..3566055d + .offset(y: -22) + } + } ++ .onAppear { ++ // Runs again when an editor closes. ++ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } ++ profile = saved ++ // The editors show the saved values, also after a save that failed. ++ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) ++ if let error = editingDelegate.saveError { ++ editingDelegate.saveError = nil ++ presentAlertLater(.error, errorText: error.localizedDescription) ++ } else if editingDelegate.savedEdits { ++ editingDelegate.savedEdits = false ++ if viewModel.currentProfileName == profile.name { ++ presentAlertLater(.loadEdited) ++ } ++ } ++ } + .onChange(of: shouldDismissSelf) { shouldDismiss in + if shouldDismiss { + self.presentationMode.wrappedValue.dismiss() @@ -1076,6 +1130,40 @@ index 00000000..3566055d + } + } + ++ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ guard !showingAlert else { return } ++ self.errorText = errorText ++ activeAlert = alert ++ showingAlert = true ++ } ++ } ++ ++ private func load() { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ + private var profileCardStack: CardStack { + var cards: [Card] = [] + @@ -1173,27 +1261,6 @@ index 00000000..3566055d +} + + -+struct SectionWithContent: View where Content: View { -+ let title: String -+ let content: Content -+ -+ init(title: String, -+ content: () -> Content) -+ { -+ self.title = title -+ self.content = content() -+ } -+ -+ public var body: some View { -+ Section { -+ Text(title) -+ .bold() -+ .frame(maxWidth: .infinity, alignment: .leading) -+ content -+ } -+ } -+} -+ +// MARK: Utilities +extension ProfilePreviewView { + @@ -1214,8 +1281,34 @@ index 00000000..3566055d + + private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { + Card { -+ SectionWithContent(title: therapySetting.title, -+ content: content) ++ SectionWithTapToEdit( ++ // The basal editor needs the pump's supported rates. ++ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, ++ title: therapySetting.title, ++ descriptiveText: therapySetting.descriptiveText(appName: appName, dosingStrategySelectionEnabled: dosingStrategySelectionEnabled), ++ destination: { dismiss in ++ editor(for: therapySetting, dismiss: dismiss) ++ .environment(\.dismissAction, dismiss) ++ }, ++ content: content ++ ) ++ } ++ } ++ ++ /// The therapy settings editors, saving to this profile. ++ @ViewBuilder ++ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { ++ switch therapySetting { ++ case .glucoseTargetRange: ++ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .carbRatio: ++ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .basalRate: ++ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .insulinSensitivity: ++ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ default: ++ EmptyView() + } + } +} @@ -1233,9 +1326,53 @@ index 00000000..3566055d + .padding(.trailing, -8) + } +} ++ ++/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. ++final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { ++ private let viewModel: ProfileViewModel ++ private let profileName: String ++ private let basalTimeZone: TimeZone ++ /// Set when edits were saved, so the view can offer to load the edited active profile. ++ var savedEdits = false ++ /// Set when saving edits to the profile file failed. ++ var saveError: Error? ++ ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ self.profileName = profile.name ++ self.basalTimeZone = profile.basalRateSchedule.timeZone ++ } ++ ++ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { ++ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { ++ completion(.failure(ProfileValidationError.basalRateError)) ++ return ++ } ++ completion(.success(schedule)) ++ } ++ ++ func syncDeliveryLimits(deliveryLimits: DeliveryLimits) async throws -> DeliveryLimits { ++ return deliveryLimits ++ } ++ ++ func saveCompletion(therapySettings: TherapySettings) { ++ do { ++ try viewModel.updateProfile(named: profileName, from: therapySettings) ++ savedEdits = true ++ } catch { ++ saveError = error ++ } ++ } ++ ++ func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return viewModel.pumpSupportedIncrements() ++ } ++ ++ func updateCurrentProfileName() {} ++} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift new file mode 100644 -index 00000000..a1f35678 +index 00000000..ffced54b --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift @@ -0,0 +1,172 @@ @@ -1353,15 +1490,15 @@ index 00000000..a1f35678 + case .update: + return Alert( + title: Text("Profile Outdated"), -+ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), -+ primaryButton: .default(Text("Update")) { ++ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), ++ primaryButton: .default(Text("Save Current Settings")) { + viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") + }, -+ secondaryButton: .cancel() ++ secondaryButton: .cancel(Text("Keep Saved Profile")) + ) + case .info: + return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), + dismissButton: .default(Text("Got it!"))) + } + } diff --git a/profiles/nextdev_profiles_remote_window.patch b/profiles/nextdev_profiles_remote_window.patch index 9b7b2a7..eb49ecf 100644 --- a/profiles/nextdev_profiles_remote_window.patch +++ b/profiles/nextdev_profiles_remote_window.patch @@ -86,7 +86,7 @@ index 42eff19f..c4d0f32e 100644 return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) } diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index e81a230b..8e7c86ef 100644 +index e81a230b..462e815b 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -30,6 +30,7 @@ class ServicesManager { @@ -97,7 +97,7 @@ index e81a230b..8e7c86ef 100644 private var services = [Service]() -@@ -432,3 +433,58 @@ extension Service { +@@ -432,3 +433,57 @@ extension Service { ] } } @@ -131,7 +131,6 @@ index e81a230b..8e7c86ef 100644 + } + } + } -+ viewModel.triggerProfileSync() + NotificationManager.sendRemoteProfileNotification(name: name) + } catch { + NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) @@ -273,10 +272,10 @@ index 90d64a0f..b82f71ed 100644 func cancelRemoteOverride() async throws diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..0a287cac +index 00000000..b876029d --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,427 @@ +@@ -0,0 +1,467 @@ +// +// ProfileViewModel+FileManagement.swift +// LoopKitUI @@ -515,17 +514,24 @@ index 00000000..0a287cac + else + { + do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { [weak self] result in ++ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in + switch result { + case .success(let syncedSchedule): + DispatchQueue.main.async { -+ let unit = self?.therapySettings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter -+ self?.saveCorrectionRange(range: profile.correctionRange.schedule(for: unit)! ) -+ self?.saveCarbRatioSchedule(carbRatioSchedule: profile.carbRatioSchedule) -+ self?.saveBasalRates(basalRates: syncedSchedule) -+ self?.saveInsulinSensitivitySchedule(insulinSensitivitySchedule: profile.insulinSensitivitySchedule.schedule(for: unit)!) ++ let previousSettings = self.therapySettings ++ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter ++ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! ++ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule ++ self.therapySettings.basalRateSchedule = syncedSchedule ++ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! ++ // One settings change, so the stored and uploaded settings never mix two profiles. ++ self.setCurrentProfile(name: profile.name, syncChange: false) ++ self.delegate?.saveCompletion(therapySettings: self.therapySettings) ++ if self.therapySettings == previousSettings { ++ // Nothing changed, so only the profile name needs to reach the uploaded settings. ++ self.triggerProfileSync() ++ } + print("New profile loaded") -+ self?.setCurrentProfile(name: profile.name, syncChange: false) + completion(.success) + } + case .failure(let error): @@ -704,6 +710,39 @@ index 00000000..0a287cac + try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) + } +} ++ ++extension ProfileViewModel { ++ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. ++ func therapySettings(applying profile: Profile) -> TherapySettings { ++ var settings = therapySettings ++ let unit = settings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter ++ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) ++ settings.carbRatioSchedule = profile.carbRatioSchedule ++ settings.basalRateSchedule = profile.basalRateSchedule ++ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) ++ return settings ++ } ++ ++ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. ++ func updateProfile(named name: String, from settings: TherapySettings) throws { ++ loadProfiles() ++ guard let profileReference = profiles.first(where: { $0.name == name }) else { ++ throw ProfileValidationError.fileLoadError ++ } ++ let profile = try getProfile(from: profileReference) ++ let updatedProfile = Profile( ++ name: profile.name, ++ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, ++ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, ++ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, ++ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, ++ sortOrder: profile.sortOrder ++ ) ++ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) ++ loadProfiles() ++ triggerProfileSync() ++ } ++} diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift new file mode 100644 index 00000000..a48fa667 @@ -895,10 +934,10 @@ index 00000000..446910e4 +} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift new file mode 100644 -index 00000000..3566055d +index 00000000..9eb00f90 --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,334 @@ +@@ -0,0 +1,432 @@ +// +// ProfilePreviewView.swift +// LoopKitUI @@ -917,6 +956,7 @@ index 00000000..3566055d + case load + case delete + case error ++ case loadEdited +} + +struct ProfilePreviewView: View { @@ -924,7 +964,11 @@ index 00000000..3566055d + @ObservedObject var viewModel: ProfileViewModel + @Environment(\.dismissAction) var dismissAction + @Environment(\.presentationMode) var presentationMode -+ var profile: Profile ++ @Environment(\.appName) private var appName ++ @Environment(\.dosingStrategySelectionEnabled) private var dosingStrategySelectionEnabled ++ @State private var profile: Profile ++ @State private var editingDelegate: ProfileEditingDelegate ++ @State private var therapySettingsViewModel: TherapySettingsViewModel + + @State private var showingAlert = false + @State private var activeAlert: ActiveAlert = .load @@ -935,11 +979,21 @@ index 00000000..3566055d + + let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } + ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) ++ _profile = State(initialValue: profile) ++ _editingDelegate = State(initialValue: editingDelegate) ++ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( ++ therapySettings: viewModel.therapySettings(applying: profile), ++ delegate: editingDelegate)) ++ } ++ + var body: some View { + ZStack { + ScrollView { + VStack(alignment: .leading) { -+ Text("Before proceeding, review the profile details below. Scroll to find options to Load, Rename, or Delete.") ++ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") + .font(.body) + .foregroundColor(.primary) + .padding([.leading, .trailing]) @@ -1006,30 +1060,7 @@ index 00000000..3566055d + return Alert( + title: Text("Load Profile"), + message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ }), ++ primaryButton: .default(Text("Yes"), action: load), + secondaryButton: .cancel() + ) + case .delete: @@ -1048,6 +1079,13 @@ index 00000000..3566055d + message: Text(errorText ?? "Unknown error"), + dismissButton: .default(Text("OK")) + ) ++ case .loadEdited: ++ return Alert( ++ title: Text("Load Edited Profile?"), ++ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), ++ primaryButton: .default(Text("Load"), action: load), ++ secondaryButton: .cancel(Text("Later")) ++ ) + } + } + if isRenamingProfile { @@ -1069,6 +1107,22 @@ index 00000000..3566055d + .offset(y: -22) + } + } ++ .onAppear { ++ // Runs again when an editor closes. ++ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } ++ profile = saved ++ // The editors show the saved values, also after a save that failed. ++ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) ++ if let error = editingDelegate.saveError { ++ editingDelegate.saveError = nil ++ presentAlertLater(.error, errorText: error.localizedDescription) ++ } else if editingDelegate.savedEdits { ++ editingDelegate.savedEdits = false ++ if viewModel.currentProfileName == profile.name { ++ presentAlertLater(.loadEdited) ++ } ++ } ++ } + .onChange(of: shouldDismissSelf) { shouldDismiss in + if shouldDismiss { + self.presentationMode.wrappedValue.dismiss() @@ -1076,6 +1130,40 @@ index 00000000..3566055d + } + } + ++ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ guard !showingAlert else { return } ++ self.errorText = errorText ++ activeAlert = alert ++ showingAlert = true ++ } ++ } ++ ++ private func load() { ++ let validationResult = viewModel.validateProfile(profile) ++ switch validationResult { ++ case .success: ++ viewModel.loadProfile(profile: profile) { result in ++ switch result { ++ case .success: ++ dismissAction() ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ case .failure(let error): ++ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { ++ self.errorText = error.localizedDescription ++ self.activeAlert = .error ++ self.showingAlert = true ++ } ++ } ++ } ++ + private var profileCardStack: CardStack { + var cards: [Card] = [] + @@ -1173,27 +1261,6 @@ index 00000000..3566055d +} + + -+struct SectionWithContent: View where Content: View { -+ let title: String -+ let content: Content -+ -+ init(title: String, -+ content: () -> Content) -+ { -+ self.title = title -+ self.content = content() -+ } -+ -+ public var body: some View { -+ Section { -+ Text(title) -+ .bold() -+ .frame(maxWidth: .infinity, alignment: .leading) -+ content -+ } -+ } -+} -+ +// MARK: Utilities +extension ProfilePreviewView { + @@ -1214,8 +1281,34 @@ index 00000000..3566055d + + private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { + Card { -+ SectionWithContent(title: therapySetting.title, -+ content: content) ++ SectionWithTapToEdit( ++ // The basal editor needs the pump's supported rates. ++ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, ++ title: therapySetting.title, ++ descriptiveText: therapySetting.descriptiveText(appName: appName, dosingStrategySelectionEnabled: dosingStrategySelectionEnabled), ++ destination: { dismiss in ++ editor(for: therapySetting, dismiss: dismiss) ++ .environment(\.dismissAction, dismiss) ++ }, ++ content: content ++ ) ++ } ++ } ++ ++ /// The therapy settings editors, saving to this profile. ++ @ViewBuilder ++ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { ++ switch therapySetting { ++ case .glucoseTargetRange: ++ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .carbRatio: ++ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .basalRate: ++ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ case .insulinSensitivity: ++ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) ++ default: ++ EmptyView() + } + } +} @@ -1233,9 +1326,53 @@ index 00000000..3566055d + .padding(.trailing, -8) + } +} ++ ++/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. ++final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { ++ private let viewModel: ProfileViewModel ++ private let profileName: String ++ private let basalTimeZone: TimeZone ++ /// Set when edits were saved, so the view can offer to load the edited active profile. ++ var savedEdits = false ++ /// Set when saving edits to the profile file failed. ++ var saveError: Error? ++ ++ init(viewModel: ProfileViewModel, profile: Profile) { ++ self.viewModel = viewModel ++ self.profileName = profile.name ++ self.basalTimeZone = profile.basalRateSchedule.timeZone ++ } ++ ++ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { ++ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { ++ completion(.failure(ProfileValidationError.basalRateError)) ++ return ++ } ++ completion(.success(schedule)) ++ } ++ ++ func syncDeliveryLimits(deliveryLimits: DeliveryLimits) async throws -> DeliveryLimits { ++ return deliveryLimits ++ } ++ ++ func saveCompletion(therapySettings: TherapySettings) { ++ do { ++ try viewModel.updateProfile(named: profileName, from: therapySettings) ++ savedEdits = true ++ } catch { ++ saveError = error ++ } ++ } ++ ++ func pumpSupportedIncrements() -> PumpSupportedIncrements? { ++ return viewModel.pumpSupportedIncrements() ++ } ++ ++ func updateCurrentProfileName() {} ++} diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift new file mode 100644 -index 00000000..a1f35678 +index 00000000..ffced54b --- /dev/null +++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift @@ -0,0 +1,172 @@ @@ -1353,15 +1490,15 @@ index 00000000..a1f35678 + case .update: + return Alert( + title: Text("Profile Outdated"), -+ message: Text("The current therapy settings have changed. Do you want to update the profile named: \(viewModel.currentProfileName ?? "")?"), -+ primaryButton: .default(Text("Update")) { ++ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), ++ primaryButton: .default(Text("Save Current Settings")) { + viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") + }, -+ secondaryButton: .cancel() ++ secondaryButton: .cancel(Text("Keep Saved Profile")) + ) + case .info: + return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap on any profile to review its settings in detail. In detail view, you can Load, Rename or Delete that particular profile.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), ++ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), + dismissButton: .default(Text("Got it!"))) + } + } diff --git a/remote_carb_edit/dev_remote_carb_edit_profiles.patch b/remote_carb_edit/dev_remote_carb_edit_profiles.patch index f5a129d..3a6792b 100644 --- a/remote_carb_edit/dev_remote_carb_edit_profiles.patch +++ b/remote_carb_edit/dev_remote_carb_edit_profiles.patch @@ -164,7 +164,7 @@ index 17cff75d..6cefc092 100644 let notification = UNMutableNotificationContent() /// Notifications should expire after the missed meal is no longer relevant diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index c9d3e04f..5bf1ec36 100644 +index c61eda65..7a8dc92f 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -240,6 +240,8 @@ public protocol ServicesManagerDelegate: AnyObject { diff --git a/remote_carb_edit/dev_remote_carb_edit_profiles_remote_window.patch b/remote_carb_edit/dev_remote_carb_edit_profiles_remote_window.patch index c9aa87a..2750ebe 100644 --- a/remote_carb_edit/dev_remote_carb_edit_profiles_remote_window.patch +++ b/remote_carb_edit/dev_remote_carb_edit_profiles_remote_window.patch @@ -177,7 +177,7 @@ index 17cff75d..6cefc092 100644 let notification = UNMutableNotificationContent() /// Notifications should expire after the missed meal is no longer relevant diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index f5f0d3c5..161a42e3 100644 +index a9d9177d..d804456f 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -240,6 +240,8 @@ public protocol ServicesManagerDelegate: AnyObject { diff --git a/remote_carb_edit/nextdev_remote_carb_edit_profiles.patch b/remote_carb_edit/nextdev_remote_carb_edit_profiles.patch index 42a5a5c..ae1f810 100644 --- a/remote_carb_edit/nextdev_remote_carb_edit_profiles.patch +++ b/remote_carb_edit/nextdev_remote_carb_edit_profiles.patch @@ -143,7 +143,7 @@ index c4d0f32e..1cf442dd 100644 let notification = UNMutableNotificationContent() /// Notifications should expire after the missed meal is no longer relevant diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 15602f08..ffafea70 100644 +index a7da8548..20d2f3d4 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -252,6 +252,8 @@ public protocol ServicesManagerDelegate: AnyObject { diff --git a/remote_carb_edit/nextdev_remote_carb_edit_profiles_remote_window.patch b/remote_carb_edit/nextdev_remote_carb_edit_profiles_remote_window.patch index 9720c23..c60494a 100644 --- a/remote_carb_edit/nextdev_remote_carb_edit_profiles_remote_window.patch +++ b/remote_carb_edit/nextdev_remote_carb_edit_profiles_remote_window.patch @@ -158,7 +158,7 @@ index c4d0f32e..1cf442dd 100644 let notification = UNMutableNotificationContent() /// Notifications should expire after the missed meal is no longer relevant diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 8e7c86ef..65a47001 100644 +index 462e815b..f710fb3e 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -252,6 +252,8 @@ public protocol ServicesManagerDelegate: AnyObject { diff --git a/remote_window/dev_remote_window_profiles.patch b/remote_window/dev_remote_window_profiles.patch index 3f7f163..2adb70e 100644 --- a/remote_window/dev_remote_window_profiles.patch +++ b/remote_window/dev_remote_window_profiles.patch @@ -237,7 +237,7 @@ index d0d2b312..0a5c29eb 100644 carbStore.addCarbEntry(carbEntry) { result in switch result { diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index c9d3e04f..f5f0d3c5 100644 +index c61eda65..a9d9177d 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -233,13 +233,13 @@ class ServicesManager { diff --git a/remote_window/dev_remote_window_profiles_remote_carb_edit.patch b/remote_window/dev_remote_window_profiles_remote_carb_edit.patch index 4688407..6e6af92 100644 --- a/remote_window/dev_remote_window_profiles_remote_carb_edit.patch +++ b/remote_window/dev_remote_window_profiles_remote_carb_edit.patch @@ -233,7 +233,7 @@ index 02359815..03ffe6c7 100644 carbStore.addCarbEntry(carbEntry) { result in switch result { diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 5bf1ec36..161a42e3 100644 +index 7a8dc92f..d804456f 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -233,13 +233,13 @@ class ServicesManager { diff --git a/remote_window/nextdev_remote_window_profiles.patch b/remote_window/nextdev_remote_window_profiles.patch index 33b0bad..97a8134 100644 --- a/remote_window/nextdev_remote_window_profiles.patch +++ b/remote_window/nextdev_remote_window_profiles.patch @@ -103,7 +103,7 @@ index 28f07307..1966dcde 100644 return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Carb error description: carbs exceed maximum amount.") case .invalidCarbs: diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 15602f08..8e7c86ef 100644 +index a7da8548..462e815b 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -245,13 +245,13 @@ class ServicesManager { diff --git a/remote_window/nextdev_remote_window_profiles_remote_carb_edit.patch b/remote_window/nextdev_remote_window_profiles_remote_carb_edit.patch index 94212b4..9463a82 100644 --- a/remote_window/nextdev_remote_window_profiles_remote_carb_edit.patch +++ b/remote_window/nextdev_remote_window_profiles_remote_carb_edit.patch @@ -98,7 +98,7 @@ index 61cdb5c0..feb53338 100644 return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Carb error description: carbs exceed maximum amount.") case .invalidCarbs: diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index ffafea70..65a47001 100644 +index 20d2f3d4..f710fb3e 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -245,13 +245,13 @@ class ServicesManager { From 2a2013a5b722fd298b185f930108ef9023ac7c0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 29 Oct 2026 16:54:36 +0100 Subject: [PATCH 4/6] profiles: compare schedule values when checking if the active profile is outdated The basal schedule saved on load is the one returned by the pump manager, which carries that pump's time zone. Carb ratio and basal rates are now compared by their entries, like correction range and insulin sensitivity, so a profile is only reported as outdated when its values differ. --- profiles/dev_profiles.patch | 6 +++--- profiles/dev_profiles_basal_lock.patch | 6 +++--- profiles/dev_profiles_basal_lock_remote_carb_edit.patch | 6 +++--- ...profiles_basal_lock_remote_carb_edit_remote_window.patch | 6 +++--- profiles/dev_profiles_basal_lock_remote_window.patch | 6 +++--- profiles/dev_profiles_remote_carb_edit.patch | 6 +++--- profiles/dev_profiles_remote_carb_edit_remote_window.patch | 6 +++--- profiles/dev_profiles_remote_window.patch | 6 +++--- profiles/nextdev_profiles.patch | 6 +++--- profiles/nextdev_profiles_remote_carb_edit.patch | 6 +++--- .../nextdev_profiles_remote_carb_edit_remote_window.patch | 6 +++--- profiles/nextdev_profiles_remote_window.patch | 6 +++--- 12 files changed, 36 insertions(+), 36 deletions(-) diff --git a/profiles/dev_profiles.patch b/profiles/dev_profiles.patch index 16bf660..ad69272 100644 --- a/profiles/dev_profiles.patch +++ b/profiles/dev_profiles.patch @@ -314,7 +314,7 @@ index 7fdc53ad..00fe25ab 100644 func cancelRemoteOverride() async throws diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..87672389 +index 00000000..b571cf7b --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift @@ -0,0 +1,466 @@ @@ -728,11 +728,11 @@ index 00000000..87672389 + print("Glucose target range schedule differs") + isOutOfSync = true + } -+ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { + print("Carb ratio schedule differs") + isOutOfSync = true + } -+ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { + print("Basal rate schedule differs") + isOutOfSync = true + } diff --git a/profiles/dev_profiles_basal_lock.patch b/profiles/dev_profiles_basal_lock.patch index 66b7f18..b378498 100644 --- a/profiles/dev_profiles_basal_lock.patch +++ b/profiles/dev_profiles_basal_lock.patch @@ -315,7 +315,7 @@ index 7fdc53ad..00fe25ab 100644 func cancelRemoteOverride() async throws diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..87672389 +index 00000000..b571cf7b --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift @@ -0,0 +1,466 @@ @@ -729,11 +729,11 @@ index 00000000..87672389 + print("Glucose target range schedule differs") + isOutOfSync = true + } -+ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { + print("Carb ratio schedule differs") + isOutOfSync = true + } -+ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { + print("Basal rate schedule differs") + isOutOfSync = true + } diff --git a/profiles/dev_profiles_basal_lock_remote_carb_edit.patch b/profiles/dev_profiles_basal_lock_remote_carb_edit.patch index 168e253..f04b4ef 100644 --- a/profiles/dev_profiles_basal_lock_remote_carb_edit.patch +++ b/profiles/dev_profiles_basal_lock_remote_carb_edit.patch @@ -315,7 +315,7 @@ index 4b2cecf3..56742c96 100644 func cancelRemoteOverride() async throws diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..87672389 +index 00000000..b571cf7b --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift @@ -0,0 +1,466 @@ @@ -729,11 +729,11 @@ index 00000000..87672389 + print("Glucose target range schedule differs") + isOutOfSync = true + } -+ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { + print("Carb ratio schedule differs") + isOutOfSync = true + } -+ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { + print("Basal rate schedule differs") + isOutOfSync = true + } diff --git a/profiles/dev_profiles_basal_lock_remote_carb_edit_remote_window.patch b/profiles/dev_profiles_basal_lock_remote_carb_edit_remote_window.patch index 05c2026..45fcceb 100644 --- a/profiles/dev_profiles_basal_lock_remote_carb_edit_remote_window.patch +++ b/profiles/dev_profiles_basal_lock_remote_carb_edit_remote_window.patch @@ -315,7 +315,7 @@ index 8fb254b6..858a5621 100644 func cancelRemoteOverride() async throws -> TemporaryScheduleOverride diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..87672389 +index 00000000..b571cf7b --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift @@ -0,0 +1,466 @@ @@ -729,11 +729,11 @@ index 00000000..87672389 + print("Glucose target range schedule differs") + isOutOfSync = true + } -+ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { + print("Carb ratio schedule differs") + isOutOfSync = true + } -+ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { + print("Basal rate schedule differs") + isOutOfSync = true + } diff --git a/profiles/dev_profiles_basal_lock_remote_window.patch b/profiles/dev_profiles_basal_lock_remote_window.patch index 7d9be1a..b348c61 100644 --- a/profiles/dev_profiles_basal_lock_remote_window.patch +++ b/profiles/dev_profiles_basal_lock_remote_window.patch @@ -315,7 +315,7 @@ index e4279746..6f691598 100644 func cancelRemoteOverride() async throws -> TemporaryScheduleOverride diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..87672389 +index 00000000..b571cf7b --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift @@ -0,0 +1,466 @@ @@ -729,11 +729,11 @@ index 00000000..87672389 + print("Glucose target range schedule differs") + isOutOfSync = true + } -+ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { + print("Carb ratio schedule differs") + isOutOfSync = true + } -+ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { + print("Basal rate schedule differs") + isOutOfSync = true + } diff --git a/profiles/dev_profiles_remote_carb_edit.patch b/profiles/dev_profiles_remote_carb_edit.patch index 9e2257c..17a9e4b 100644 --- a/profiles/dev_profiles_remote_carb_edit.patch +++ b/profiles/dev_profiles_remote_carb_edit.patch @@ -314,7 +314,7 @@ index 4b2cecf3..56742c96 100644 func cancelRemoteOverride() async throws diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..87672389 +index 00000000..b571cf7b --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift @@ -0,0 +1,466 @@ @@ -728,11 +728,11 @@ index 00000000..87672389 + print("Glucose target range schedule differs") + isOutOfSync = true + } -+ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { + print("Carb ratio schedule differs") + isOutOfSync = true + } -+ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { + print("Basal rate schedule differs") + isOutOfSync = true + } diff --git a/profiles/dev_profiles_remote_carb_edit_remote_window.patch b/profiles/dev_profiles_remote_carb_edit_remote_window.patch index d62af50..350c8b5 100644 --- a/profiles/dev_profiles_remote_carb_edit_remote_window.patch +++ b/profiles/dev_profiles_remote_carb_edit_remote_window.patch @@ -314,7 +314,7 @@ index 8fb254b6..858a5621 100644 func cancelRemoteOverride() async throws -> TemporaryScheduleOverride diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..87672389 +index 00000000..b571cf7b --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift @@ -0,0 +1,466 @@ @@ -728,11 +728,11 @@ index 00000000..87672389 + print("Glucose target range schedule differs") + isOutOfSync = true + } -+ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { + print("Carb ratio schedule differs") + isOutOfSync = true + } -+ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { + print("Basal rate schedule differs") + isOutOfSync = true + } diff --git a/profiles/dev_profiles_remote_window.patch b/profiles/dev_profiles_remote_window.patch index 3791ed8..1bf1372 100644 --- a/profiles/dev_profiles_remote_window.patch +++ b/profiles/dev_profiles_remote_window.patch @@ -314,7 +314,7 @@ index e4279746..6f691598 100644 func cancelRemoteOverride() async throws -> TemporaryScheduleOverride diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..87672389 +index 00000000..b571cf7b --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift @@ -0,0 +1,466 @@ @@ -728,11 +728,11 @@ index 00000000..87672389 + print("Glucose target range schedule differs") + isOutOfSync = true + } -+ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { + print("Carb ratio schedule differs") + isOutOfSync = true + } -+ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { + print("Basal rate schedule differs") + isOutOfSync = true + } diff --git a/profiles/nextdev_profiles.patch b/profiles/nextdev_profiles.patch index 57af0bd..385e660 100644 --- a/profiles/nextdev_profiles.patch +++ b/profiles/nextdev_profiles.patch @@ -272,7 +272,7 @@ index 8f824906..a7c9c653 100644 func cancelRemoteOverride() async throws diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..b876029d +index 00000000..6c27aa9f --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift @@ -0,0 +1,467 @@ @@ -687,11 +687,11 @@ index 00000000..b876029d + print("Glucose target range schedule differs") + isOutOfSync = true + } -+ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { + print("Carb ratio schedule differs") + isOutOfSync = true + } -+ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { + print("Basal rate schedule differs") + isOutOfSync = true + } diff --git a/profiles/nextdev_profiles_remote_carb_edit.patch b/profiles/nextdev_profiles_remote_carb_edit.patch index 39042f1..5b3d678 100644 --- a/profiles/nextdev_profiles_remote_carb_edit.patch +++ b/profiles/nextdev_profiles_remote_carb_edit.patch @@ -272,7 +272,7 @@ index 6d3f9db2..5f1f37f3 100644 func cancelRemoteOverride() async throws diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..b876029d +index 00000000..6c27aa9f --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift @@ -0,0 +1,467 @@ @@ -687,11 +687,11 @@ index 00000000..b876029d + print("Glucose target range schedule differs") + isOutOfSync = true + } -+ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { + print("Carb ratio schedule differs") + isOutOfSync = true + } -+ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { + print("Basal rate schedule differs") + isOutOfSync = true + } diff --git a/profiles/nextdev_profiles_remote_carb_edit_remote_window.patch b/profiles/nextdev_profiles_remote_carb_edit_remote_window.patch index 082ab59..f230792 100644 --- a/profiles/nextdev_profiles_remote_carb_edit_remote_window.patch +++ b/profiles/nextdev_profiles_remote_carb_edit_remote_window.patch @@ -272,7 +272,7 @@ index 7da27b70..39c0eee9 100644 func cancelRemoteOverride() async throws diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..b876029d +index 00000000..6c27aa9f --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift @@ -0,0 +1,467 @@ @@ -687,11 +687,11 @@ index 00000000..b876029d + print("Glucose target range schedule differs") + isOutOfSync = true + } -+ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { + print("Carb ratio schedule differs") + isOutOfSync = true + } -+ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { + print("Basal rate schedule differs") + isOutOfSync = true + } diff --git a/profiles/nextdev_profiles_remote_window.patch b/profiles/nextdev_profiles_remote_window.patch index eb49ecf..3cd9473 100644 --- a/profiles/nextdev_profiles_remote_window.patch +++ b/profiles/nextdev_profiles_remote_window.patch @@ -272,7 +272,7 @@ index 90d64a0f..b82f71ed 100644 func cancelRemoteOverride() async throws diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 -index 00000000..b876029d +index 00000000..6c27aa9f --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift @@ -0,0 +1,467 @@ @@ -687,11 +687,11 @@ index 00000000..b876029d + print("Glucose target range schedule differs") + isOutOfSync = true + } -+ if therapySettings.carbRatioSchedule != profile.carbRatioSchedule { ++ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { + print("Carb ratio schedule differs") + isOutOfSync = true + } -+ if therapySettings.basalRateSchedule != profile.basalRateSchedule { ++ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { + print("Basal rate schedule differs") + isOutOfSync = true + } From 073d13ee8126196086cbf755fabc75b2b14e5af1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Thu, 29 Oct 2026 18:18:49 +0100 Subject: [PATCH 5/6] profiles: update nextdev patches for next-dev 97b2cfc --- profiles/nextdev_profiles.patch | 4 ++-- profiles/nextdev_profiles_remote_carb_edit.patch | 4 ++-- .../nextdev_profiles_remote_carb_edit_remote_window.patch | 4 ++-- profiles/nextdev_profiles_remote_window.patch | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/profiles/nextdev_profiles.patch b/profiles/nextdev_profiles.patch index 385e660..09eaa25 100644 --- a/profiles/nextdev_profiles.patch +++ b/profiles/nextdev_profiles.patch @@ -18,7 +18,7 @@ index 3c9d61cb..5591c9a5 100644 settingsManager.mutateLoopSettings { settings in settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift -index 6b263045..fe3c3e7f 100644 +index 3e1110d1..cc990cd0 100644 --- a/Loop/Loop/Managers/LoopAppManager.swift +++ b/Loop/Loop/Managers/LoopAppManager.swift @@ -401,6 +401,7 @@ class LoopAppManager: NSObject { @@ -27,7 +27,7 @@ index 6b263045..fe3c3e7f 100644 remoteDataServicesManager.delegate = deviceDataManager + servicesManager.therapySettingsViewModelDelegate = deviceDataManager - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceDataManager.deviceLog, alertManager.alertStore] + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, temporaryPresetsManager.presetHistory, deviceDataManager.deviceLog, alertManager.alertStore] criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift index 30ab8fb2..28f07307 100644 diff --git a/profiles/nextdev_profiles_remote_carb_edit.patch b/profiles/nextdev_profiles_remote_carb_edit.patch index 5b3d678..a5cec6d 100644 --- a/profiles/nextdev_profiles_remote_carb_edit.patch +++ b/profiles/nextdev_profiles_remote_carb_edit.patch @@ -18,7 +18,7 @@ index 3c9d61cb..5591c9a5 100644 settingsManager.mutateLoopSettings { settings in settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift -index 6b263045..fe3c3e7f 100644 +index 3e1110d1..cc990cd0 100644 --- a/Loop/Loop/Managers/LoopAppManager.swift +++ b/Loop/Loop/Managers/LoopAppManager.swift @@ -401,6 +401,7 @@ class LoopAppManager: NSObject { @@ -27,7 +27,7 @@ index 6b263045..fe3c3e7f 100644 remoteDataServicesManager.delegate = deviceDataManager + servicesManager.therapySettingsViewModelDelegate = deviceDataManager - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceDataManager.deviceLog, alertManager.alertStore] + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, temporaryPresetsManager.presetHistory, deviceDataManager.deviceLog, alertManager.alertStore] criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift index 7ecb16d1..61cdb5c0 100644 diff --git a/profiles/nextdev_profiles_remote_carb_edit_remote_window.patch b/profiles/nextdev_profiles_remote_carb_edit_remote_window.patch index f230792..f69df8b 100644 --- a/profiles/nextdev_profiles_remote_carb_edit_remote_window.patch +++ b/profiles/nextdev_profiles_remote_carb_edit_remote_window.patch @@ -18,7 +18,7 @@ index 3c9d61cb..5591c9a5 100644 settingsManager.mutateLoopSettings { settings in settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift -index dec6dff3..0b8248c2 100644 +index 18204a86..58b4516b 100644 --- a/Loop/Loop/Managers/LoopAppManager.swift +++ b/Loop/Loop/Managers/LoopAppManager.swift @@ -401,6 +401,7 @@ class LoopAppManager: NSObject { @@ -27,7 +27,7 @@ index dec6dff3..0b8248c2 100644 remoteDataServicesManager.delegate = deviceDataManager + servicesManager.therapySettingsViewModelDelegate = deviceDataManager - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceDataManager.deviceLog, alertManager.alertStore] + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, temporaryPresetsManager.presetHistory, deviceDataManager.deviceLog, alertManager.alertStore] criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift index 69f660ca..feb53338 100644 diff --git a/profiles/nextdev_profiles_remote_window.patch b/profiles/nextdev_profiles_remote_window.patch index 3cd9473..6d94b00 100644 --- a/profiles/nextdev_profiles_remote_window.patch +++ b/profiles/nextdev_profiles_remote_window.patch @@ -18,7 +18,7 @@ index 3c9d61cb..5591c9a5 100644 settingsManager.mutateLoopSettings { settings in settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift -index dec6dff3..0b8248c2 100644 +index 18204a86..58b4516b 100644 --- a/Loop/Loop/Managers/LoopAppManager.swift +++ b/Loop/Loop/Managers/LoopAppManager.swift @@ -401,6 +401,7 @@ class LoopAppManager: NSObject { @@ -27,7 +27,7 @@ index dec6dff3..0b8248c2 100644 remoteDataServicesManager.delegate = deviceDataManager + servicesManager.therapySettingsViewModelDelegate = deviceDataManager - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceDataManager.deviceLog, alertManager.alertStore] + let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, temporaryPresetsManager.presetHistory, deviceDataManager.deviceLog, alertManager.alertStore] criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift index 18f5e18d..1966dcde 100644 From aabad8267403a32f0686e7f86690c26581eadd64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 30 Oct 2026 09:44:28 +0100 Subject: [PATCH 6/6] profiles: remote profile switch without variants for other customizations The profile switch command is routed in RemoteDataServicesManager to its own handler in NightscoutService, which skips duplicates, checks expiration and OTP, activates the profile, sends the reply push and posts a note on errors, using the shared OTP secret and used-OTP list. profile-switch is added to remoteCommands at device status upload, next to the commands already listed there. On dev the Profiles screen opens from its own sheet state and the LoopKit project entries sit apart from basal_lock's. profiles, remote_carb_edit, remote_window and basal_lock now apply in any order without variants, so the combined variant patches are removed. --- profiles/README.md | 12 +- profiles/dev_profiles.patch | 877 ++++--- profiles/dev_profiles_basal_lock.patch | 2169 ---------------- ...profiles_basal_lock_remote_carb_edit.patch | 2168 ---------------- ..._lock_remote_carb_edit_remote_window.patch | 2231 ---------------- ...ev_profiles_basal_lock_remote_window.patch | 2232 ----------------- profiles/dev_profiles_remote_carb_edit.patch | 2167 ---------------- ...files_remote_carb_edit_remote_window.patch | 2230 ---------------- profiles/dev_profiles_remote_window.patch | 2231 ---------------- profiles/nextdev_profiles.patch | 811 +++--- .../nextdev_profiles_remote_carb_edit.patch | 2134 ---------------- ...files_remote_carb_edit_remote_window.patch | 2134 ---------------- profiles/nextdev_profiles_remote_window.patch | 2133 ---------------- remote_carb_edit/README.md | 9 - .../dev_remote_carb_edit_profiles.patch | 1010 -------- ...ote_carb_edit_profiles_remote_window.patch | 1073 -------- .../nextdev_remote_carb_edit_profiles.patch | 953 ------- ...ote_carb_edit_profiles_remote_window.patch | 956 ------- .../dev_remote_window_profiles.patch | 1782 ------------- ...ote_window_profiles_remote_carb_edit.patch | 1989 --------------- .../nextdev_remote_window_profiles.patch | 542 ---- ...ote_window_profiles_remote_carb_edit.patch | 625 ----- 22 files changed, 1015 insertions(+), 31453 deletions(-) delete mode 100644 profiles/dev_profiles_basal_lock.patch delete mode 100644 profiles/dev_profiles_basal_lock_remote_carb_edit.patch delete mode 100644 profiles/dev_profiles_basal_lock_remote_carb_edit_remote_window.patch delete mode 100644 profiles/dev_profiles_basal_lock_remote_window.patch delete mode 100644 profiles/dev_profiles_remote_carb_edit.patch delete mode 100644 profiles/dev_profiles_remote_carb_edit_remote_window.patch delete mode 100644 profiles/dev_profiles_remote_window.patch delete mode 100644 profiles/nextdev_profiles_remote_carb_edit.patch delete mode 100644 profiles/nextdev_profiles_remote_carb_edit_remote_window.patch delete mode 100644 profiles/nextdev_profiles_remote_window.patch delete mode 100644 remote_carb_edit/dev_remote_carb_edit_profiles.patch delete mode 100644 remote_carb_edit/dev_remote_carb_edit_profiles_remote_window.patch delete mode 100644 remote_carb_edit/nextdev_remote_carb_edit_profiles.patch delete mode 100644 remote_carb_edit/nextdev_remote_carb_edit_profiles_remote_window.patch delete mode 100644 remote_window/dev_remote_window_profiles.patch delete mode 100644 remote_window/dev_remote_window_profiles_remote_carb_edit.patch delete mode 100644 remote_window/nextdev_remote_window_profiles.patch delete mode 100644 remote_window/nextdev_remote_window_profiles_remote_carb_edit.patch diff --git a/profiles/README.md b/profiles/README.md index 8b0820c..f2dd9e0 100644 --- a/profiles/README.md +++ b/profiles/README.md @@ -40,14 +40,10 @@ Touches Loop, LoopKit, LoopOnboarding and NightscoutService. `dev_profiles.patch git apply --whitespace=nowarn dev_profiles.patch ``` -On `dev`, `dev_profiles_basal_lock*.patch` apply on top of basal_lock; on `next-dev` the two coexist without a variant. Profiles coexists with negative_insulin. The profiles patches without remote switching are in `archive/*_noremote.patch`, so the selector reports an update for workspaces that have them applied. +The patch needs no variant for any other customization. Its changes sit where no other customization edits, so it applies before or after any of them, in any order, and can be removed on its own. The profiles patches without remote switching are in `archive/*_noremote.patch`, so the selector reports an update for workspaces that have them applied. -## Combining with remote_carb_edit and remote_window +## Other remote commands -Both also change the remote command code, so each has variants for the others already being in the tree: +The profile switch is handled apart from the shared remote command code (`RemoteCommandSourceV1`), with the same steps in the same order: duplicates are skipped, then expiration and the OTP are checked, then the profile is activated, the reply is pushed and any error is posted as a Nightscout note. It uses the same OTP secret and list of used OTPs as the other commands. `profile-switch` is added to `remoteCommands` when the device status is uploaded, next to the commands other customizations list there, such as `carbs-delete` and `carbs-edit` from remote_carb_edit. -- `_profiles[_basal_lock]_remote_carb_edit.patch`, `..._remote_window.patch` and `..._remote_carb_edit_remote_window.patch`: profiles on top of the named customizations. -- `remote_carb_edit/_remote_carb_edit_profiles.patch` and `..._profiles_remote_window.patch`: remote_carb_edit on top of profiles (and remote_window). -- `remote_window/_remote_window_profiles.patch` and `..._profiles_remote_carb_edit.patch`: remote_window on top of profiles (and remote_carb_edit). - -Every order produces the same tree, and each customization can be removed on its own. With remote_carb_edit, `remoteCommands` lists all three commands. With remote_window, `sent-at` is required, and on `dev` profile switches also appear in the remote command history. +With remote_window, profile switches keep the `expiration` sent by the caregiver app; remote_window's longer acceptance window applies to the shared remote commands only. On `dev`, profile switches are not listed in remote_window's command history. diff --git a/profiles/dev_profiles.patch b/profiles/dev_profiles.patch index ad69272..7790cab 100644 --- a/profiles/dev_profiles.patch +++ b/profiles/dev_profiles.patch @@ -37,51 +37,22 @@ index c9aef285..d0d2b312 100644 func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { var oldValue: LoopSettings! let newValue = lockedSettings.mutate { settings in -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 996d1470..17cff75d 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -263,6 +263,38 @@ extension NotificationManager { - } - } +diff --git a/Loop/Loop/Managers/RemoteDataServicesManager.swift b/Loop/Loop/Managers/RemoteDataServicesManager.swift +index bf21376b..0ce31ac6 100644 +--- a/Loop/Loop/Managers/RemoteDataServicesManager.swift ++++ b/Loop/Loop/Managers/RemoteDataServicesManager.swift +@@ -594,6 +594,9 @@ extension RemoteDataServicesManager { -+ static func sendRemoteProfileNotification(name: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfile", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteProfileFailureNotification(name: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfileFailure", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ private static func remoteProfileNotificationBody(name: String) -> String { -+ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) -+ } -+ - private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { - return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) + public func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async throws { + let service = try serviceForPushNotification(notification) ++ if let receiver = service as? RemoteProfileSwitchReceiver, receiver.isProfileSwitch(notification) { ++ return await receiver.profileSwitchWasReceived(notification) ++ } + return try await service.remoteNotificationWasReceived(notification) } + diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 3fb788f9..c61eda65 100644 +index 3fb788f9..18c25671 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -28,6 +28,7 @@ class ServicesManager { @@ -92,7 +63,7 @@ index 3fb788f9..c61eda65 100644 private var services = [Service]() -@@ -412,3 +413,58 @@ extension Service { +@@ -412,3 +413,73 @@ extension Service { ] } } @@ -127,13 +98,28 @@ index 3fb788f9..c61eda65 100644 + } + } + } -+ NotificationManager.sendRemoteProfileNotification(name: name) ++ sendRemoteProfileNotification(name: name, error: nil) + } catch { -+ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ sendRemoteProfileNotification(name: name, error: error) + throw error + } + } + ++ private func sendRemoteProfileNotification(name: String, error: Error?) { ++ let notification = UNMutableNotificationContent() ++ let title = String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ notification.body = [title, error?.localizedDescription ?? "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: error == nil ? "com.loopkit.Loop.RemoteProfile" : "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ + enum RemoteProfileActionError: LocalizedError { + case unavailable + case noPump @@ -152,94 +138,83 @@ index 3fb788f9..c61eda65 100644 + } +} diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift -index 9f9e3aa0..c1c9ea87 100644 +index 9f9e3aa0..d718491a 100644 --- a/Loop/Loop/Views/SettingsView.swift +++ b/Loop/Loop/Views/SettingsView.swift -@@ -50,6 +50,7 @@ public struct SettingsView: View { - } - - case favoriteFoods -+ case profiles - } - } +@@ -56,6 +56,7 @@ public struct SettingsView: View { + @State private var actionSheet: Destination.ActionSheet? + @State private var alert: Destination.Alert? + @State private var sheet: Destination.Sheet? ++ @State private var showProfiles = false -@@ -119,6 +120,19 @@ public struct SettingsView: View { - switch sheet { - case .favoriteFoods: - FavoriteFoodsView() -+ case .profiles: -+ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), -+ sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, -+ adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, -+ delegate: self.viewModel.therapySettingsViewModelDelegate)) -+ .environmentObject(displayGlucosePreference) -+ .environment(\.dismissAction, self.dismiss) -+ .environment(\.appName, self.appName) -+ .environment(\.chartColorPalette, .primary) -+ .environment(\.carbTintColor, self.carbTintColor) -+ .environment(\.glucoseTintColor, self.glucoseTintColor) -+ .environment(\.guidanceColors, self.guidanceColors) -+ .environment(\.insulinTintColor, self.insulinTintColor) - } - } - } -@@ -279,6 +293,11 @@ extension SettingsView { + var localizedAppNameAndVersion: String + +@@ -279,6 +280,25 @@ extension SettingsView { descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) } -+ LargeButton(action: { sheet = .profiles }, ++ LargeButton(action: { showProfiles = true }, + includeArrow: true, + imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), + label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), + descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) ++ .sheet(isPresented: $showProfiles) { ++ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), ++ sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, ++ adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, ++ delegate: self.viewModel.therapySettingsViewModelDelegate)) ++ .environmentObject(displayGlucosePreference) ++ .environment(\.dismissAction, self.dismiss) ++ .environment(\.appName, self.appName) ++ .environment(\.chartColorPalette, .primary) ++ .environment(\.carbTintColor, self.carbTintColor) ++ .environment(\.glucoseTintColor, self.glucoseTintColor) ++ .environment(\.guidanceColors, self.guidanceColors) ++ .environment(\.insulinTintColor, self.insulinTintColor) ++ } ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in item.view } Submodule LoopKit contains modified content diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj -index e4423ce6..89045896 100644 +index e4423ce6..84a2e8e9 100644 --- a/LoopKit/LoopKit.xcodeproj/project.pbxproj +++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj -@@ -873,6 +873,12 @@ - D1A0B000000000000000000C /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; - D1A0B000000000000000000D /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; - D1A0B0000000000000000081 /* PersistenceControllerMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */; }; +@@ -863,6 +863,12 @@ + C1F8403923DB84B700673141 /* DeviceLogEntry+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F8403723DB84B700673141 /* DeviceLogEntry+CoreDataClass.swift */; }; + C1F8403A23DB84B700673141 /* DeviceLogEntry+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F8403823DB84B700673141 /* DeviceLogEntry+CoreDataProperties.swift */; }; + C1FAC06328C7B0A100754AE2 /* reservoir_iob_test.json in Resources */ = {isa = PBXBuildFile; fileRef = C1FAC06228C7B0A100754AE2 /* reservoir_iob_test.json */; }; + DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; + DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; + DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; + DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; + DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; + DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; - E9077D2724ACD59F0066A88D /* InformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9077D2624ACD59F0066A88D /* InformationView.swift */; }; - E9077D2A24ACDE2C0066A88D /* CorrectionRangeInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9077D2924ACDE2C0066A88D /* CorrectionRangeInformationView.swift */; }; - E9086B2924B39EDC0062F5C8 /* ChartsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9086B2824B39EDC0062F5C8 /* ChartsTableViewController.swift */; }; -@@ -1813,6 +1819,12 @@ - D1A0B0000000000000000006 /* Modelv5ToModelv6.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = Modelv5ToModelv6.xcmappingmodel; sourceTree = ""; }; - D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelMappingv4v5Tov6Policy.swift; sourceTree = ""; }; - D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; + C1FAEC1D264AD6B400A3250B /* DeviceStatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FAEC1C264AD6B400A3250B /* DeviceStatusBadge.swift */; }; + C1FAEC1F264AE12700A3250B /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B47ECF8725DC20810024A54D /* UIImage.swift */; }; + C1FAEC21264AEEA300A3250B /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FAEC20264AEEA300A3250B /* UIImage.swift */; }; +@@ -1803,6 +1809,12 @@ + C1F8403723DB84B700673141 /* DeviceLogEntry+CoreDataClass.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DeviceLogEntry+CoreDataClass.swift"; sourceTree = ""; }; + C1F8403823DB84B700673141 /* DeviceLogEntry+CoreDataProperties.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "DeviceLogEntry+CoreDataProperties.swift"; sourceTree = ""; }; + C1FAC06228C7B0A100754AE2 /* reservoir_iob_test.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = reservoir_iob_test.json; sourceTree = ""; }; + DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; + DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; + DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; + DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; + DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; + DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; - E9077D2624ACD59F0066A88D /* InformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InformationView.swift; sourceTree = ""; }; - E9077D2924ACDE2C0066A88D /* CorrectionRangeInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionRangeInformationView.swift; sourceTree = ""; }; - E9086B2824B39EDC0062F5C8 /* ChartsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartsTableViewController.swift; sourceTree = ""; }; -@@ -3009,11 +3021,13 @@ + C1FAEC1C264AD6B400A3250B /* DeviceStatusBadge.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceStatusBadge.swift; sourceTree = ""; }; + C1FAEC20264AEEA300A3250B /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; + D1A0B0000000000000000001 /* Modelv5.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Modelv5.xcdatamodel; sourceTree = ""; }; +@@ -3009,6 +3021,8 @@ isa = PBXGroup; children = ( B455F3A325FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift */, + DDAF746829F4234000719F0A /* ProfileViewModel.swift */, ++ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, B4B7C1BB2604E3FC007379F6 /* CorrectionRangeScheduleEditorViewModel.swift */, B4D4C20C25F95A8700DA809D /* DisplayGlucosePreference.swift */, B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, - B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, - 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, -+ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, - ); - path = ViewModels; - sourceTree = ""; @@ -3201,6 +3215,10 @@ E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, @@ -295,23 +270,28 @@ index e4423ce6..89045896 100644 892A5DB32231E191008961AB /* LevelHUDView.swift in Sources */, B41A60B223D1DBC700636320 /* UIFont.swift in Sources */, B41A60AF23D1DB5B00636320 /* TableViewTitleLabel.swift in Sources */, -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 7fdc53ad..00fe25ab 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -8,6 +8,12 @@ - - import Foundation - +diff --git a/LoopKit/LoopKit/Service/RemoteDataService.swift b/LoopKit/LoopKit/Service/RemoteDataService.swift +index edf169d7..91d977a3 100644 +--- a/LoopKit/LoopKit/Service/RemoteDataService.swift ++++ b/LoopKit/LoopKit/Service/RemoteDataService.swift +@@ -128,3 +128,17 @@ public extension RemoteDataService { + var pumpEventDataLimit: Int? { return nil } + var settingsDataLimit: Int? { return nil } + } ++ ++/// A remote data service that handles remote profile switch commands itself. ++public protocol RemoteProfileSwitchReceiver: RemoteDataService { ++ /// True when the push notification is a profile switch command. ++ func isProfileSwitch(_ notification: [String: AnyObject]) -> Bool ++ ++ func profileSwitchWasReceived(_ notification: [String: AnyObject]) async ++} ++ +/// Switches to a saved therapy profile on a remote command. +public protocol RemoteProfileActionDelegate: AnyObject { + /// `verify` receives the schedules about to be activated and throws to reject them. + func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws +} -+ - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws - func cancelRemoteOverride() async throws diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 index 00000000..b571cf7b @@ -1696,87 +1676,390 @@ index cc61f2d..6462e2c 100644 Submodule NightscoutService contains modified content diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index 69f7a26..14a7691 100644 +index 69f7a26..2c269a0 100644 --- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -24,6 +24,10 @@ +@@ -24,6 +24,9 @@ A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* NightscoutService+RemoteProfileSwitch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* NightscoutService+RemoteProfileSwitch.swift */; }; + 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* RemoteProfileSwitchTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* RemoteProfileSwitchTestCase.swift */; }; A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; -@@ -158,6 +162,10 @@ +@@ -158,6 +161,9 @@ A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* NightscoutService+RemoteProfileSwitch.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NightscoutService+RemoteProfileSwitch.swift"; sourceTree = ""; }; + 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* RemoteProfileSwitchTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteProfileSwitchTestCase.swift; sourceTree = ""; }; A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; -@@ -268,6 +276,7 @@ +@@ -268,6 +274,8 @@ A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, A934B6172358F49300949C8B /* StoredSettings.swift */, + 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* NightscoutService+RemoteProfileSwitch.swift */, A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, -@@ -324,6 +333,7 @@ +@@ -324,6 +332,7 @@ A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* RemoteProfileSwitchTestCase.swift */, A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, ); path = NightscoutServiceKitTests; -@@ -364,6 +374,7 @@ - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, - ); - path = Notifications; - sourceTree = ""; -@@ -412,6 +423,7 @@ - A9AA6E5729EB06AA008FFA78 /* Actions */ = { - isa = PBXGroup; - children = ( -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -@@ -744,6 +756,9 @@ +@@ -744,6 +753,8 @@ C1398D2227C434FB00416AD6 /* Data.swift in Sources */, A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* NightscoutService+RemoteProfileSwitch.swift in Sources */, + 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, ); -@@ -757,6 +772,7 @@ +@@ -757,6 +768,7 @@ A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* RemoteProfileSwitchTestCase.swift in Sources */, A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/NightscoutService+RemoteProfileSwitch.swift b/NightscoutService/NightscoutServiceKit/Extensions/NightscoutService+RemoteProfileSwitch.swift +new file mode 100644 +index 0000000..23a7dd1 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/NightscoutService+RemoteProfileSwitch.swift +@@ -0,0 +1,315 @@ ++// ++// NightscoutService+RemoteProfileSwitch.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++import NightscoutKit ++import OSLog ++ ++/// Remote profile switch commands. They are handled apart from `RemoteCommandSourceV1` with the same steps: ++/// decode, skip duplicates, check expiration and OTP, act, reply through the return push, and post a ++/// Nightscout note on any error. ++extension NightscoutService: RemoteProfileSwitchReceiver { ++ ++ private static let profileSwitchLog = OSLog(category: "RemoteProfileSwitch") ++ private static let recentProfileSwitches = RecentProfileSwitches() ++ ++ public func isProfileSwitch(_ notification: [String: AnyObject]) -> Bool { ++ return notification[ProfileSwitchCommand.commandKey] != nil ++ } ++ ++ public func profileSwitchWasReceived(_ notification: [String: AnyObject]) async { ++ do { ++ let command = try ProfileSwitchCommand(dictionary: notification) ++ guard await Self.recentProfileSwitches.isNew(command) else { ++ // Duplicate notifications are expected after app is force killed ++ return ++ } ++ try command.validate(otpManager: otpManager) ++ try await activateProfile(command) ++ } catch { ++ os_log("Remote profile switch: %{public}@. Error: %{public}@", log: Self.profileSwitchLog, type: .error, String(describing: notification), String(describing: error)) ++ try? await uploadProfileSwitchNote(error: error, notification: notification) ++ } ++ } ++ ++ private func activateProfile(_ command: ProfileSwitchCommand) async throws { ++ let returnInfo = command.returnNotificationInfo() ++ do { ++ guard let profileDelegate = serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.unavailable ++ } ++ try await profileDelegate.activateRemoteProfile(name: command.profileName) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: command.profileName, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: command.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ let failure = RemoteProfileSwitchError.notActivated(command.profileName, error) ++ await ProfileSwitchResponse.send(to: returnInfo, success: false, message: "Command failed: \(failure.localizedDescription)") ++ throw failure ++ } ++ await ProfileSwitchResponse.send(to: returnInfo, success: true, message: "Profile '\(command.profileName)' activated") ++ } ++ ++ private func uploadProfileSwitchNote(error: Error, notification: [String: AnyObject]) async throws { ++ guard let siteURL, let apiSecret else { ++ throw NightscoutServiceError.missingCredentials ++ } ++ let notificationJSON = try JSONSerialization.data(withJSONObject: notification) ++ let notificationJSONString = String(data: notificationJSON, encoding: .utf8) ?? "" ++ ++ let treatment = NightscoutTreatment( ++ timestamp: Date(), ++ enteredBy: LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch"), ++ notes: """ ++ \(error.localizedDescription) ++ \(notificationJSONString) ++ """, ++ eventType: .note ++ ) ++ ++ let uploader = NightscoutClient(siteURL: siteURL, apiSecret: apiSecret) ++ return try await withCheckedThrowingContinuation { continuation in ++ uploader.upload([treatment]) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ } ++} ++ ++/// A profile switch push. `expiration` is required: decoding fails without it. ++struct ProfileSwitchCommand: Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ let profileName: String ++ let fingerprint: String ++ let remoteAddress: String ++ let expiration: Date ++ let sentAt: Date? ++ let otp: String? ++ let enteredBy: String? ++ let encryptedReturnNotification: String? ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expiration = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ init(dictionary: [String: Any]) throws { ++ let data = try JSONSerialization.data(withJSONObject: dictionary) ++ let jsonDecoder = JSONDecoder() ++ jsonDecoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601DateDecoder) ++ self = try jsonDecoder.decode(Self.self, from: data) ++ } ++ ++ /// Expiration first, so an expired command does not use up its OTP. ++ func validate(otpManager: OTPManager, now: Date = Date()) throws { ++ guard now <= expiration else { ++ throw RemoteProfileSwitchError.expired ++ } ++ guard let otp else { ++ throw RemoteProfileSwitchError.missingOTP ++ } ++ try otpManager.validatePassword(password: otp, deliveryDate: sentAt) ++ } ++ ++ func returnNotificationInfo() -> ReturnNotificationInfo? { ++ guard let encryptedReturnNotification, let otp, let messenger = OTPSecureMessenger(otpCode: otp) else { ++ return nil ++ } ++ return try? messenger.decrypt(base64EncodedString: encryptedReturnNotification) ++ } ++} ++ ++private actor RecentProfileSwitches { ++ private var sentDates = [Date]() ++ ++ /// Commands without `sent-at` cannot be matched and always count as new. ++ func isNew(_ command: ProfileSwitchCommand) -> Bool { ++ guard let sentAt = command.sentAt else { ++ return true ++ } ++ if sentDates.contains(sentAt) { ++ return false ++ } ++ sentDates.append(sentAt) ++ return true ++ } ++} ++ ++/// The return push to the follower, in the format of `RemoteNotificationResponseManager` with ++/// `command_type` `profile_switch`. Followers match the reply by the profile name in the body. ++enum ProfileSwitchResponse { ++ private static let log = OSLog(category: "RemoteProfileSwitch") ++ ++ struct Payload: Encodable { ++ let aps: APS ++ let commandStatus: String ++ let commandType: String ++ let timestamp: TimeInterval ++ ++ enum CodingKeys: String, CodingKey { ++ case aps ++ case commandStatus = "command_status" ++ case commandType = "command_type" ++ case timestamp ++ } ++ } ++ ++ struct APS: Encodable { ++ let alert: Alert ++ let sound: String = "default" ++ } ++ ++ struct Alert: Encodable { ++ let title: String ++ let body: String ++ } ++ ++ static func payload(success: Bool, message: String) -> Payload { ++ return Payload( ++ aps: APS(alert: Alert(title: success ? "Command Successful" : "Command Failed", body: message)), ++ commandStatus: success ? "success" : "failed", ++ commandType: "profile_switch", ++ timestamp: Date().timeIntervalSince1970 ++ ) ++ } ++ ++ static func send(to returnInfo: ReturnNotificationInfo?, success: Bool, message: String) async { ++ guard let returnInfo, !returnInfo.deviceToken.isEmpty else { ++ os_log("No return notification info, response not sent", log: log, type: .info) ++ return ++ } ++ guard let jwt = APNSJWTManager.shared.getOrGenerateJWT(keyId: returnInfo.keyId, teamId: returnInfo.teamId, apnsKey: returnInfo.apnsKey) else { ++ os_log("Failed to generate JWT for response notification", log: log, type: .error) ++ return ++ } ++ let host = returnInfo.productionEnvironment ? "api.push.apple.com" : "api.sandbox.push.apple.com" ++ guard let url = URL(string: "https://\(host)/3/device/\(returnInfo.deviceToken)") else { ++ os_log("Failed to construct APNs URL", log: log, type: .error) ++ return ++ } ++ ++ var request = URLRequest(url: url) ++ request.httpMethod = "POST" ++ request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") ++ request.setValue("application/json", forHTTPHeaderField: "content-type") ++ request.setValue("10", forHTTPHeaderField: "apns-priority") ++ request.setValue("0", forHTTPHeaderField: "apns-expiration") ++ request.setValue(returnInfo.bundleId, forHTTPHeaderField: "apns-topic") ++ request.setValue("alert", forHTTPHeaderField: "apns-push-type") ++ ++ do { ++ request.httpBody = try JSONEncoder().encode(payload(success: success, message: message)) ++ let (_, response) = try await URLSession.shared.data(for: request) ++ if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { ++ os_log("Failed to send response notification: %d", log: log, type: .error, httpResponse.statusCode) ++ } ++ } catch { ++ os_log("Error sending response notification: %{public}@", log: log, type: .error, error.localizedDescription) ++ } ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case expired ++ case missingOTP ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .expired: ++ return LocalizedString("Expired", comment: "Remote command error description: expired.") ++ case .missingOTP: ++ return LocalizedString("Missing OTP", comment: "Remote command error description: Missing OTP.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} ++ ++extension DeviceStatus { ++ /// Adds `command` to `loop.testingDetails.remoteCommands`, keeping commands already listed there. ++ /// `LoopStatus` is copied field by field; when the copy differs from the original in anything but ++ /// `testingDetails`, the original is returned unchanged so no uploaded data is lost. ++ func addingRemoteCommand(_ command: String) -> DeviceStatus { ++ guard let loopStatus else { ++ return self ++ } ++ var testingDetails = loopStatus.testingDetails ?? [:] ++ let remoteCommands = testingDetails["remoteCommands"] as? [String] ?? [] ++ guard !remoteCommands.contains(command) else { ++ return self ++ } ++ testingDetails["remoteCommands"] = remoteCommands + [command] ++ ++ let updated = DeviceStatus( ++ device: device, ++ timestamp: timestamp, ++ pumpStatus: pumpStatus, ++ uploaderStatus: uploaderStatus, ++ loopStatus: loopStatus.replacingTestingDetails(testingDetails), ++ radioAdapter: radioAdapter, ++ overrideStatus: overrideStatus, ++ identifier: identifier) ++ ++ guard updated.dictionaryRepresentationWithoutTestingDetails == dictionaryRepresentationWithoutTestingDetails else { ++ return self ++ } ++ return updated ++ } ++ ++ var dictionaryRepresentationWithoutTestingDetails: NSDictionary { ++ var representation = dictionaryRepresentation ++ if var loop = representation["loop"] as? [String: Any] { ++ loop["testingDetails"] = nil ++ representation["loop"] = loop ++ } ++ return NSDictionary(dictionary: representation) ++ } ++} ++ ++extension LoopStatus { ++ func replacingTestingDetails(_ testingDetails: [String: Any]) -> LoopStatus { ++ return LoopStatus( ++ name: name, ++ version: version, ++ timestamp: timestamp, ++ iob: iob, ++ cob: cob, ++ predicted: predicted, ++ automaticDoseRecommendation: automaticDoseRecommendation, ++ recommendedBolus: recommendedBolus, ++ enacted: enacted, ++ rileylinks: rileylinks, ++ failureReason: failureReason, ++ currentCorrectionRange: currentCorrectionRange, ++ forecastError: forecastError, ++ testingDetails: testingDetails ++ ) ++ } ++} diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift new file mode 100644 -index 0000000..de73ca9 +index 0000000..2b9b2d1 --- /dev/null +++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -@@ -0,0 +1,104 @@ +@@ -0,0 +1,86 @@ +// +// SavedProfile.swift +// NightscoutServiceKit @@ -1863,38 +2146,6 @@ index 0000000..de73ca9 + return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) + } +} -+ -+enum RemoteProfileSwitchError: LocalizedError { -+ case profileChanged -+ case unavailable -+ case notActivated(String, Error) -+ -+ /// Followers read the profile name from this text to match the reply to their command. -+ var errorDescription: String? { -+ switch self { -+ case .profileChanged: -+ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") -+ case .unavailable: -+ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") -+ case .notActivated(let name, let error): -+ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index 4e1d49e..b66fb97 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -159,7 +159,8 @@ extension StoredDosingDecision { - automaticDoseRecommendation: loopStatusAutomaticDoseRecommendation, - recommendedBolus: loopStatusRecommendedBolus, - enacted: automaticDoseDecision?.loopStatusEnacted, -- failureReason: automaticDoseDecision?.loopStatusFailureReason), -+ failureReason: automaticDoseDecision?.loopStatusFailureReason, -+ testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]]), - overrideStatus: overrideStatus) - } - diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift index 2adadb9..dd7ac28 100644 --- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift @@ -1924,176 +2175,26 @@ index 2adadb9..dd7ac28 100644 syncIdentifier: syncIdentifier.uuidString, isAPNSProduction: Bundle.main.isAPNSProductionEnvironment) diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index 4626630..ef69236 100644 +index 4626630..1fb455a 100644 --- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift +++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -436,6 +436,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - success = true - message = "Override '\(overrideCommand.name)' enacted successfully" - -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ success = true -+ message = "Profile '\(profileSwitchCommand.name)' activated" -+ - case .cancelTemporaryOverride: - commandType = .cancelOverride - try await self.serviceDelegate?.cancelRemoteOverride() -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index a92b509..29313b6 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -9,6 +9,7 @@ - import Foundation - - public enum Action: Codable { -+ case profileSwitch(ProfileSwitchAction) - case temporaryScheduleOverride(OverrideAction) - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) -@@ -28,6 +29,8 @@ public enum Action: Codable { - return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") - case .temporaryScheduleOverride: - return LocalizedString("Override", comment: "The remote action name for Override") -+ case .profileSwitch: -+ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") - } - } - -@@ -43,6 +46,8 @@ public enum Action: Codable { - return "" - case .temporaryScheduleOverride(let overrideAction): - return overrideAction.name -+ case .profileSwitch(let profileSwitchAction): -+ return profileSwitchAction.name +@@ -293,7 +293,7 @@ extension NightscoutService: RemoteDataService { } - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -new file mode 100644 -index 0000000..ce03ded ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -@@ -0,0 +1,17 @@ -+// -+// ProfileSwitchAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct ProfileSwitchAction: Codable { -+ -+ public let name: String -+ public let fingerprint: String -+ -+ public init(name: String, fingerprint: String) { -+ self.name = name -+ self.fingerprint = fingerprint -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 95274e4..4457f80 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -45,6 +45,7 @@ class RemoteNotificationResponseManager { - case carbs = "carbs" - case override = "override" - case cancelOverride = "cancel_override" -+ case profileSwitch = "profile_switch" - } - - func sendResponseNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -new file mode 100644 -index 0000000..487c620 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -0,0 +1,50 @@ -+// -+// ProfileSwitchRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Switches to a saved profile. `expiration` is required: decoding fails without it. -+public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { -+ -+ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. -+ static let commandKey = "profile-switch" -+ -+ public let profileName: String -+ public let fingerprint: String -+ public let remoteAddress: String -+ public let expirationDate: Date -+ public let sentAt: Date? -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ public var expiration: Date? { -+ return expirationDate -+ } -+ -+ enum CodingKeys: String, CodingKey { -+ case profileName = "profile-switch" -+ case fingerprint = "profile-switch-fingerprint" -+ case remoteAddress = "remote-address" -+ case expirationDate = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification[commandKey] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index 0c5f8e3..3362bf7 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -108,6 +108,8 @@ extension Dictionary { - return try OverrideRemoteNotification(dictionary: self) - } else if OverrideCancelRemoteNotification.includedInNotification(self) { - return try OverrideCancelRemoteNotification(dictionary: self) -+ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { -+ return try ProfileSwitchRemoteNotification(dictionary: self) - } else { - throw RemoteNotificationError.unhandledNotification(self) + + let statuses = uploadPairs.map { (decision, automaticDoseDecision) in +- return decision.deviceStatus(automaticDoseDecision: automaticDoseDecision) ++ return decision.deviceStatus(automaticDoseDecision: automaticDoseDecision).addingRemoteCommand(ProfileSwitchCommand.commandKey) } -diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift + + guard statuses.count > 0 else { +diff --git a/NightscoutService/NightscoutServiceKitTests/RemoteProfileSwitchTestCase.swift b/NightscoutService/NightscoutServiceKitTests/RemoteProfileSwitchTestCase.swift new file mode 100644 -index 0000000..2fcec94 +index 0000000..369fb3e --- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -@@ -0,0 +1,71 @@ ++++ b/NightscoutService/NightscoutServiceKitTests/RemoteProfileSwitchTestCase.swift +@@ -0,0 +1,135 @@ +// -+// ProfileSwitchRemoteNotificationTestCase.swift ++// RemoteProfileSwitchTestCase.swift +// NightscoutServiceKitTests +// + @@ -2102,9 +2203,9 @@ index 0000000..2fcec94 +import NightscoutKit +@testable import NightscoutServiceKit + -+final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++final class RemoteProfileSwitchTestCase: XCTestCase { + -+ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ func testParseProfileSwitchCommand_ValidPayload_Succeeds() throws { + let notification: [String: Any] = [ + "profile-switch": "Sport", + "profile-switch-fingerprint": "fec4f066b5a54ecd", @@ -2114,20 +2215,15 @@ index 0000000..2fcec94 + "otp": "123456" + ] + -+ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) -+ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ let command = try ProfileSwitchCommand(dictionary: notification) + -+ XCTAssertEqual(profileSwitch.profileName, "Sport") -+ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") -+ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) -+ XCTAssertTrue(profileSwitch.otpValidationRequired()) -+ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { -+ return XCTFail("Expected a profile switch action") -+ } -+ XCTAssertEqual(action.name, "Sport") ++ XCTAssertEqual(command.profileName, "Sport") ++ XCTAssertEqual(command.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(command.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertEqual(command.sentAt, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:46:35.778Z")) + } + -+ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ func testParseProfileSwitchCommand_MissingExpiration_Fails() { + let notification: [String: Any] = [ + "profile-switch": "Sport", + "profile-switch-fingerprint": "fec4f066b5a54ecd", @@ -2135,13 +2231,26 @@ index 0000000..2fcec94 + "otp": "123456" + ] + -+ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ XCTAssertThrowsError(try ProfileSwitchCommand(dictionary: notification)) + } + -+ func testFingerprint_MatchesFollowerVector() { -+ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { -+ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ func testValidate_Expired_FailsBeforeOTP() throws { ++ let command = try ProfileSwitchCommand(dictionary: [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ]) ++ ++ XCTAssertThrowsError(try command.validate(otpManager: OTPManager(secretStore: MockSecretStore()))) { error in ++ guard case RemoteProfileSwitchError.expired = error else { ++ return XCTFail("Expected expired, got \(error)") ++ } + } ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { + let profile = ProfileSet.Profile( + timezone: TimeZone(secondsFromGMT: 3600)!, + dia: .hours(6), @@ -2154,6 +2263,62 @@ index 0000000..2fcec94 + + XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") + } ++ ++ func testAddingRemoteCommand_KeepsListedCommandsAndOtherFields() { ++ let status = deviceStatus(testingDetails: ["remoteCommands": ["carbs-delete"], "other": 1]) ++ ++ let updated = status.addingRemoteCommand(ProfileSwitchCommand.commandKey) ++ ++ XCTAssertEqual(updated.loopStatus?.testingDetails?["remoteCommands"] as? [String], ["carbs-delete", "profile-switch"]) ++ XCTAssertEqual(updated.loopStatus?.testingDetails?["other"] as? Int, 1) ++ XCTAssertEqual(updated.dictionaryRepresentationWithoutTestingDetails, status.dictionaryRepresentationWithoutTestingDetails) ++ } ++ ++ func testAddingRemoteCommand_WithoutTestingDetails_AddsList() { ++ let updated = deviceStatus(testingDetails: nil).addingRemoteCommand(ProfileSwitchCommand.commandKey) ++ ++ XCTAssertEqual(updated.loopStatus?.testingDetails?["remoteCommands"] as? [String], ["profile-switch"]) ++ } ++ ++ func testResponsePayload_UsesProfileSwitchCommandType() throws { ++ let data = try JSONEncoder().encode(ProfileSwitchResponse.payload(success: false, message: "Command failed: Profile 'Sport' not activated: Pod not connected")) ++ let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) ++ ++ XCTAssertEqual(json["command_type"] as? String, "profile_switch") ++ XCTAssertEqual(json["command_status"] as? String, "failed") ++ let alert = try XCTUnwrap((json["aps"] as? [String: Any])?["alert"] as? [String: Any]) ++ XCTAssertEqual(alert["body"] as? String, "Command failed: Profile 'Sport' not activated: Pod not connected") ++ } ++ ++ private func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ ++ private func deviceStatus(testingDetails: [String: Any]?) -> DeviceStatus { ++ let date = Date(timeIntervalSince1970: 1_700_000_000) ++ let loopStatus = LoopStatus( ++ name: "Loop", ++ version: "3.0", ++ timestamp: date, ++ iob: IOBStatus(timestamp: date, iob: 1.5), ++ recommendedBolus: 0.4, ++ failureReason: "failure", ++ testingDetails: testingDetails) ++ return DeviceStatus(device: "loop://iPhone", timestamp: date, uploaderStatus: UploaderStatus(name: "iPhone", timestamp: date, battery: 50), loopStatus: loopStatus, identifier: "id") ++ } ++} ++ ++private final class MockSecretStore: OTPSecretStore { ++ private var key: String? = "2KBHFASYM6DKWFGW" ++ private var keyName: String? = "Mock" ++ private var accepted = [String]() ++ ++ func tokenSecretKey() -> String? { key } ++ func tokenSecretKeyName() -> String? { keyName } ++ func setTokenSecretKey(_ key: String?) throws { self.key = key } ++ func setTokenSecretKeyName(_ name: String?) throws { self.keyName = name } ++ func recentAcceptedPasswords() -> [String] { accepted } ++ func setRecentAcceptedPasswords(_ passwords: [String]) throws { accepted = passwords } +} + +private extension ISO8601DateFormatter { diff --git a/profiles/dev_profiles_basal_lock.patch b/profiles/dev_profiles_basal_lock.patch deleted file mode 100644 index b378498..0000000 --- a/profiles/dev_profiles_basal_lock.patch +++ /dev/null @@ -1,2169 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index cabca1e6..3b5f1de2 100644 ---- a/Loop/Loop/Managers/DeviceDataManager.swift -+++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -433,6 +433,7 @@ final class DeviceDataManager { - ) - - statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) -+ servicesManager.deviceDataManager = self - - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] - criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, -@@ -1673,6 +1674,10 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { - } - } - -+ func updateCurrentProfileName() { -+ loopManager.updateCurrentProfileName() -+ } -+ - func saveCompletion(therapySettings: TherapySettings) { - - loopManager.mutateSettings { settings in -diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift -index 1a6e00d7..dd378316 100644 ---- a/Loop/Loop/Managers/LoopDataManager.swift -+++ b/Loop/Loop/Managers/LoopDataManager.swift -@@ -244,6 +244,10 @@ final class LoopDataManager { - lockedSettings.value - } - -+ func updateCurrentProfileName () { -+ notify(forChange: .preferences) -+ } -+ - func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { - var oldValue: LoopSettings! - let newValue = lockedSettings.mutate { settings in -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 996d1470..17cff75d 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -263,6 +263,38 @@ extension NotificationManager { - } - } - -+ static func sendRemoteProfileNotification(name: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfile", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteProfileFailureNotification(name: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfileFailure", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ private static func remoteProfileNotificationBody(name: String) -> String { -+ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) -+ } -+ - private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { - return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) - } -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 3fb788f9..c61eda65 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -28,6 +28,7 @@ class ServicesManager { - - weak var servicesManagerDelegate: ServicesManagerDelegate? - weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? -+ weak var deviceDataManager: DeviceDataManager? - - private var services = [Service]() - -@@ -412,3 +413,58 @@ extension Service { - ] - } - } -+ -+// MARK: - RemoteProfileActionDelegate -+ -+extension ServicesManager: RemoteProfileActionDelegate { -+ @MainActor -+ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { -+ do { -+ guard let deviceDataManager else { -+ throw RemoteProfileActionError.unavailable -+ } -+ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) -+ guard deviceDataManager.pumpSupportedIncrements() != nil else { -+ throw RemoteProfileActionError.noPump -+ } -+ guard let profile = try viewModel.savedProfile(named: name) else { -+ throw RemoteProfileActionError.profileNotFound(name) -+ } -+ try profile.verify(with: verify) -+ if case .failure(let error) = viewModel.validateProfile(profile) { -+ throw error -+ } -+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ continuation.resume() -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ NotificationManager.sendRemoteProfileNotification(name: name) -+ } catch { -+ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) -+ throw error -+ } -+ } -+ -+ enum RemoteProfileActionError: LocalizedError { -+ case unavailable -+ case noPump -+ case profileNotFound(String) -+ -+ var errorDescription: String? { -+ switch self { -+ case .unavailable: -+ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") -+ case .noPump: -+ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") -+ case .profileNotFound(let name): -+ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) -+ } -+ } -+ } -+} -diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift -index f31763de..4df43b2c 100644 ---- a/Loop/Loop/Views/SettingsView.swift -+++ b/Loop/Loop/Views/SettingsView.swift -@@ -51,6 +51,7 @@ public struct SettingsView: View { - - case favoriteFoods - case preferences -+ case profiles - } - } - -@@ -125,6 +126,19 @@ public struct SettingsView: View { - FavoriteFoodsView() - case .preferences: - PreferencesView(viewModel: PreferencesViewModel(preferencesProvider: Preferences.shared)).environmentObject(displayGlucosePreference) -+ case .profiles: -+ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), -+ sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, -+ adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, -+ delegate: self.viewModel.therapySettingsViewModelDelegate)) -+ .environmentObject(displayGlucosePreference) -+ .environment(\.dismissAction, self.dismiss) -+ .environment(\.appName, self.appName) -+ .environment(\.chartColorPalette, .primary) -+ .environment(\.carbTintColor, self.carbTintColor) -+ .environment(\.glucoseTintColor, self.glucoseTintColor) -+ .environment(\.guidanceColors, self.guidanceColors) -+ .environment(\.insulinTintColor, self.insulinTintColor) - } - } - } -@@ -285,6 +299,11 @@ extension SettingsView { - descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) - } - -+ LargeButton(action: { sheet = .profiles }, -+ includeArrow: true, -+ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), -+ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), -+ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) - ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in - item.view - } -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj -index c36bfda3..5e9ec17d 100644 ---- a/LoopKit/LoopKit.xcodeproj/project.pbxproj -+++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj -@@ -873,6 +873,12 @@ - D1A0B000000000000000000C /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; - D1A0B000000000000000000D /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; - D1A0B0000000000000000081 /* PersistenceControllerMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */; }; -+ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; -+ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; -+ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; -+ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; -+ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; -+ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; - DD13BC702C3C71A70062313B /* BasalLockEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC6F2C3C71A70062313B /* BasalLockEditor.swift */; }; - DD13BC722C3C74CD0062313B /* PreferencesGuardrailWarning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC712C3C74CD0062313B /* PreferencesGuardrailWarning.swift */; }; - DD28B3712B80C074001044D4 /* PreferencesSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD28B3702B80C074001044D4 /* PreferencesSetting.swift */; }; -@@ -1820,6 +1826,12 @@ - D1A0B0000000000000000006 /* Modelv5ToModelv6.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = Modelv5ToModelv6.xcmappingmodel; sourceTree = ""; }; - D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelMappingv4v5Tov6Policy.swift; sourceTree = ""; }; - D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; -+ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; -+ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; -+ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; -+ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; -+ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; -+ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; - DD13BC6F2C3C71A70062313B /* BasalLockEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalLockEditor.swift; sourceTree = ""; }; - DD13BC712C3C74CD0062313B /* PreferencesGuardrailWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesGuardrailWarning.swift; sourceTree = ""; }; - DD28B3702B80C074001044D4 /* PreferencesSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesSetting.swift; sourceTree = ""; }; -@@ -3027,12 +3039,14 @@ - isa = PBXGroup; - children = ( - B455F3A325FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift */, -+ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, - B4B7C1BB2604E3FC007379F6 /* CorrectionRangeScheduleEditorViewModel.swift */, - B4D4C20C25F95A8700DA809D /* DisplayGlucosePreference.swift */, - B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, - B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, - 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, - DD545A5E2B80EFA900915F95 /* PreferencesViewModel.swift */, -+ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, - ); - path = ViewModels; - sourceTree = ""; -@@ -3230,6 +3244,10 @@ - E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, - 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, - 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, -+ DDAF746629F422C600719F0A /* ProfileView.swift */, -+ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, -+ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, -+ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, - ); - path = "Settings Editors"; - sourceTree = ""; -@@ -3940,6 +3958,7 @@ - E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, - B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, - 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, -+ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, - 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, - E9E5E56A24D5CCE800B5DFFE /* OverrideViewCell.swift in Sources */, - 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, -@@ -3949,6 +3968,7 @@ - B43D69D62A26642300DF0925 /* DemoPlaceHolderView.swift in Sources */, - E949E38F24B3711E00024DA0 /* InsulinModelInformationView.swift in Sources */, - 895FE08B22011F0C00FCF18A /* EmojiInputHeaderView.swift in Sources */, -+ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, - 895FE08322011F0C00FCF18A /* OverrideSelectionFooterView.swift in Sources */, - 89AF78C22447E353002B4FCC /* Splat.swift in Sources */, - 893C9F8C2447DBD900CD4185 /* CardBuilder.swift in Sources */, -@@ -4000,9 +4020,11 @@ - E9C58A6E24DA65E400487A17 /* HistoricalOverrideDetailView.swift in Sources */, - 895FE08222011F0C00FCF18A /* LabeledTextFieldTableViewCell.swift in Sources */, - B43DA44224D9CD8500CAFF4E /* Environment+Colors.swift in Sources */, -+ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, - 892A5DB22231E191008961AB /* LoadingTableViewCell.swift in Sources */, - B4D3D4AD25B8A94E0085BA0F /* DisplayGlucoseUnitObserver.swift in Sources */, - A9D3FF102A6C19CA000C891D /* ChartAxisValueDoubleLog.swift in Sources */, -+ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, - E96DCB5A24AF74AC007117BC /* SuspendThresholdEditor.swift in Sources */, - E96DCB5824AEF50F007117BC /* SuspendThresholdInformationView.swift in Sources */, - A9D3FF0C2A6C198C000C891D /* CollectionType.swift in Sources */, -@@ -4079,6 +4101,7 @@ - E93C86B624D08CAD0073089B /* InsulinSensitivityInformationView.swift in Sources */, - C18733AF29B9492300519CDF /* Collection.swift in Sources */, - 892A5DA22231E137008961AB /* HUDProvider.swift in Sources */, -+ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, - 898E6E6C224194060019E459 /* UIColor.swift in Sources */, - 43BA717D201EE7090058961E /* GlucoseRangeTableViewCell.swift in Sources */, - B455F3A425FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift in Sources */, -@@ -4106,6 +4129,7 @@ - 1D1065E9282DC54700026A70 /* VideoPlayView.swift in Sources */, - E99A132E2557548300D3F5B3 /* SegmentedGaugeBar.swift in Sources */, - B455F31825FBEC5F000ED456 /* SuspendThresholdEditorViewModel.swift in Sources */, -+ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, - 892A5DB32231E191008961AB /* LevelHUDView.swift in Sources */, - B41A60B223D1DBC700636320 /* UIFont.swift in Sources */, - B41A60AF23D1DB5B00636320 /* TableViewTitleLabel.swift in Sources */, -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 7fdc53ad..00fe25ab 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -8,6 +8,12 @@ - - import Foundation - -+/// Switches to a saved therapy profile on a remote command. -+public protocol RemoteProfileActionDelegate: AnyObject { -+ /// `verify` receives the schedules about to be activated and throws to reject them. -+ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws -+} -+ - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws - func cancelRemoteOverride() async throws -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -new file mode 100644 -index 00000000..b571cf7b ---- /dev/null -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,466 @@ -+// -+// ProfileViewModel+FileManagement.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-19. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import LoopKit -+import HealthKit -+ -+public enum ProfileValidationError: Error, LocalizedError { -+ case fileLoadError -+ case unsupportedIncrement -+ case correctionRangeError -+ case insulinSensitivityError -+ case carbRatioError -+ case basalRateError -+ case maxBasalRateNotSet -+ -+ public var errorDescription: String? { -+ switch self { -+ case .fileLoadError: -+ return "Unable to load the profile file." -+ case .unsupportedIncrement: -+ return "The profile contains unsupported increments." -+ case .correctionRangeError: -+ return "Correction Range values are out of bounds." -+ case .insulinSensitivityError: -+ return "Insulin Sensitivity values are out of bounds." -+ case .carbRatioError: -+ return "Carb Ratio values are out of bounds." -+ case .basalRateError: -+ return "Basal Rate values are out of bounds." -+ case .maxBasalRateNotSet: -+ return "Maximum Basal Rate is not set." -+ } -+ } -+} -+ -+public enum LoadProfileResult { -+ case success -+ case failure(Error) -+} -+ -+// MARK: File management -+extension ProfileViewModel { -+ private func setCurrentProfile(name: String, syncChange: Bool = false) { -+ let sanitizedProfileName = sanitizeProfileName(name) -+ currentProfileName = sanitizedProfileName -+ if syncChange { -+ triggerProfileSync() -+ } -+ } -+ -+ private func clearCurrentProfile() { -+ currentProfileName = nil -+ triggerProfileSync() -+ } -+ -+ private func getCurrentProfileName() -> String? { -+ return currentProfileName -+ } -+ -+ private var profilesDirectory: URL { -+ let fileManager = FileManager.default -+ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] -+ return documentsDirectory.appendingPathComponent("LoopProfile") -+ } -+ -+ private func createProfileDirectoryIfNotExists() throws { -+ let fileManager = FileManager.default -+ var isDir : ObjCBool = false -+ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { -+ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) -+ } else if !isDir.boolValue { -+ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) -+ } -+ } -+ -+ private func getAllProfileFiles() throws -> [URL] { -+ let fileManager = FileManager.default -+ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) -+ return files.filter { $0.pathExtension == "json" } -+ } -+ -+ private func encodeProfile(_ profile: Profile) throws -> Data { -+ return try JSONEncoder().encode(profile) -+ } -+ -+ private func decodeProfile(from data: Data) throws -> Profile { -+ return try JSONDecoder().decode(Profile.self, from: data) -+ } -+ -+ public func sanitizeProfileName(_ name: String) -> String { -+ // Replace problematic characters -+ var sanitized = name.replacingOccurrences(of: ".", with: "_") -+ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") -+ -+ // Limit the name to 32 characters -+ if sanitized.count > 32 { -+ sanitized = String(sanitized.prefix(32)) -+ } -+ -+ return sanitized -+ } -+ -+ public func saveProfile(withName name: String) { -+ let sanitizedName = sanitizeProfileName(name) -+ -+ do { -+ try createProfileDirectoryIfNotExists() -+ -+ if let existingProfile = getProfileReference(withName: sanitizedName) { -+ removeProfile(profileReference: existingProfile) -+ } -+ -+ let profile = Profile( -+ name: sanitizedName, -+ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, -+ carbRatioSchedule: therapySettings.carbRatioSchedule!, -+ basalRateSchedule: therapySettings.basalRateSchedule!, -+ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, -+ sortOrder: -1 -+ ) -+ -+ let jsonData = try encodeProfile(profile) -+ -+ let dateFormatter = DateFormatter() -+ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" -+ let fileName = dateFormatter.string(from: Date()) + ".json" -+ let fileURL = profilesDirectory.appendingPathComponent(fileName) -+ -+ try jsonData.write(to: fileURL) -+ setCurrentProfile(name: sanitizedName, syncChange: true) -+ -+ self.loadProfiles() -+ } catch { -+ print("An error occurred while saving profile: \(error)") -+ } -+ } -+ -+ public func renameProfile(oldName: String, newName: String) { -+ let sanitizedProfileName = sanitizeProfileName(newName) -+ -+ if currentProfileName == oldName { -+ setCurrentProfile(name: sanitizedProfileName, syncChange: true) -+ } -+ -+ do { -+ guard let profileReference = getProfileReference(withName: oldName) else { -+ print("Profile with old name not found.") -+ return -+ } -+ -+ let profile = try getProfile(from: profileReference) -+ -+ let newProfile = Profile( -+ name: sanitizedProfileName, -+ correctionRange: profile.correctionRange, -+ carbRatioSchedule: profile.carbRatioSchedule, -+ basalRateSchedule: profile.basalRateSchedule, -+ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, -+ sortOrder: profile.sortOrder -+ ) -+ -+ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { -+ removeProfile(profileReference: existingProfile) -+ } -+ -+ let jsonData = try encodeProfile(newProfile) -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ -+ try jsonData.write(to: fileURL) -+ -+ self.loadProfiles() -+ triggerProfileSync() -+ } catch { -+ print("An error occurred while renaming profile: \(error)") -+ } -+ } -+ -+ public func updateProfilesOrder() { -+ for (index, profileReference) in profiles.enumerated() { -+ do { -+ var profile = try getProfile(from: profileReference) -+ let newProfile = Profile( -+ name: profile.name, -+ correctionRange: profile.correctionRange, -+ carbRatioSchedule: profile.carbRatioSchedule, -+ basalRateSchedule: profile.basalRateSchedule, -+ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, -+ sortOrder: index -+ ) -+ -+ let jsonData = try encodeProfile(newProfile) -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ try jsonData.write(to: fileURL) -+ -+ } catch { -+ print("An error occurred while updating profile order: \(error)") -+ } -+ } -+ loadProfiles() -+ } -+ -+ public func loadProfiles() { -+ do { -+ let profileFiles = try getAllProfileFiles() -+ -+ var newProfiles = [ProfileReference]() -+ for fileURL in profileFiles { -+ let data = try Data(contentsOf: fileURL) -+ let profile = try decodeProfile(from: data) -+ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) -+ newProfiles.append(profileRef) -+ } -+ -+ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } -+ -+ self.profiles = newProfiles -+ } catch { -+ print("An error occurred while loading profiles: \(error)") -+ } -+ } -+ -+ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { -+ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && -+ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && -+ therapySettings.basalRateSchedule == profile.basalRateSchedule && -+ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { -+ self.setCurrentProfile(name: profile.name, syncChange: true) -+ completion(.success) -+ } -+ else -+ { -+ do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in -+ switch result { -+ case .success(let syncedSchedule): -+ DispatchQueue.main.async { -+ let previousSettings = self.therapySettings -+ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! -+ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule -+ self.therapySettings.basalRateSchedule = syncedSchedule -+ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! -+ // One settings change, so the stored and uploaded settings never mix two profiles. -+ self.setCurrentProfile(name: profile.name, syncChange: false) -+ self.delegate?.saveCompletion(therapySettings: self.therapySettings) -+ if self.therapySettings == previousSettings { -+ // Nothing changed, so only the profile name needs to reach the uploaded settings. -+ self.triggerProfileSync() -+ } -+ print("New profile loaded") -+ completion(.success) -+ } -+ case .failure(let error): -+ print("An error occurred while syncing basal rates: \(error)") -+ completion(.failure(error)) -+ } -+ }) -+ } -+ } -+ } -+ -+ func getProfileReference(withName name: String) -> ProfileReference? { -+ let sanitizedInputName = sanitizeProfileName(name) -+ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) -+ } -+ -+ public func removeProfile(profile: Profile) { -+ guard let profileReference = getProfileReference(withName: profile.name) else { -+ print("No ProfileReference found for given Profile") -+ return -+ } -+ -+ removeProfile(profileReference: profileReference) -+ if getCurrentProfileName() == profile.name { -+ clearCurrentProfile() -+ } else { -+ triggerProfileSync() -+ } -+ } -+ -+ public func removeProfile(profileReference: ProfileReference) { -+ do { -+ let fileManager = FileManager.default -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ try fileManager.removeItem(at: fileURL) -+ loadProfiles() -+ } catch { -+ print("An error occurred while removing profile: \(error)") -+ } -+ } -+ -+ func doesProfileExist(withName name: String) -> Bool { -+ return profiles.contains(where: { $0.name == name }) -+ } -+ -+ public func getProfile(from profileReference: ProfileReference) throws -> Profile { -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ let data = try Data(contentsOf: fileURL) -+ let getProfile = try decodeProfile(from: data) -+ return getProfile -+ } -+ -+ /// The saved profile named `name`, or nil when there is none. -+ public func savedProfile(named name: String) throws -> Profile? { -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ return nil -+ } -+ return try getProfile(from: profileReference) -+ } -+ -+ public func validateProfile(_ profile: Profile) -> Result { -+ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { -+ return .failure(.fileLoadError) -+ } -+ -+ // Checking Correction Range Schedule -+ for item in profile.correctionRange.items { -+ let minValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) -+ let maxValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) -+ -+ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { -+ return .failure(.correctionRangeError) -+ } -+ } -+ -+ // Checking Insulin Sensitivity Schedule -+ for item in profile.insulinSensitivitySchedule.items { -+ let value = HKQuantity(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: .internationalUnit()), doubleValue: item.value) -+ -+ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { -+ return .failure(.insulinSensitivityError) -+ } -+ } -+ -+ // Checking Carb Ratio Schedule -+ for item in profile.carbRatioSchedule.items { -+ let value = HKQuantity(unit: .gramsPerUnit, doubleValue: item.value) -+ -+ if !Guardrail.carbRatio.absoluteBounds.contains(value) { -+ return .failure(.carbRatioError) -+ } -+ } -+ -+ // Checking Basal Rate Schedule -+ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { -+ for item in profile.basalRateSchedule.items { -+ let value = item.value -+ -+ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { -+ return .failure(.basalRateError) -+ } -+ } -+ } else { -+ return .failure(.maxBasalRateNotSet) -+ } -+ -+ return .success(()) -+ } -+ -+ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { -+ guard a.items.count == b.items.count else { -+ return false -+ } -+ -+ for (item1, item2) in zip(a.items, b.items) { -+ if item1.startTime != item2.startTime || -+ abs(item1.value.minValue - item2.value.minValue) > 0.01 || -+ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { -+ return false -+ } -+ } -+ -+ return true -+ } -+ -+ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { -+ guard a.items.count == b.items.count else { -+ return false -+ } -+ -+ for (item1, item2) in zip(a.items, b.items) { -+ if item1.startTime != item2.startTime || -+ abs(item1.value - item2.value) > 0.01 { -+ return false -+ } -+ } -+ -+ return true -+ } -+ -+ public func isCurrentProfileOutOfSync() -> Bool { -+ guard let currentProfileName = self.currentProfileName, -+ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), -+ let profile = try? getProfile(from: currentProfileReference), -+ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: HKUnit.milligramsPerDeciliter), -+ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: HKUnit.milligramsPerDeciliter) else { -+ return false // No current profile set or profile not found or schedules not available -+ } -+ -+ var isOutOfSync = false -+ -+ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: HKUnit.milligramsPerDeciliter)!) { -+ print("Glucose target range schedule differs") -+ isOutOfSync = true -+ } -+ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { -+ print("Carb ratio schedule differs") -+ isOutOfSync = true -+ } -+ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { -+ print("Basal rate schedule differs") -+ isOutOfSync = true -+ } -+ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: HKUnit.milligramsPerDeciliter)!) { -+ print("Insulin sensitivity schedule differs") -+ isOutOfSync = true -+ } -+ -+ return isOutOfSync -+ } -+} -+ -+extension Profile { -+ /// Passes the schedules this profile activates to `check`. -+ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { -+ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) -+ } -+} -+ -+extension ProfileViewModel { -+ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. -+ func therapySettings(applying profile: Profile) -> TherapySettings { -+ var settings = therapySettings -+ let unit = settings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) -+ settings.carbRatioSchedule = profile.carbRatioSchedule -+ settings.basalRateSchedule = profile.basalRateSchedule -+ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) -+ return settings -+ } -+ -+ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. -+ func updateProfile(named name: String, from settings: TherapySettings) throws { -+ loadProfiles() -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ throw ProfileValidationError.fileLoadError -+ } -+ let profile = try getProfile(from: profileReference) -+ let updatedProfile = Profile( -+ name: profile.name, -+ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, -+ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, -+ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, -+ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, -+ sortOrder: profile.sortOrder -+ ) -+ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) -+ loadProfiles() -+ triggerProfileSync() -+ } -+} -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift -new file mode 100644 -index 00000000..a48fa667 ---- /dev/null -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift -@@ -0,0 +1,86 @@ -+// -+// ProfileViewModel.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-04-22. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import Combine -+import LoopKit -+import HealthKit -+import SwiftUI -+ -+public class ProfileViewModel: ObservableObject { -+ -+ @Published public var therapySettings: TherapySettings -+ @Published public var profiles = [ProfileReference]() -+ @Published public var currentProfileName: String? { -+ didSet { -+ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") -+ } -+ } -+ -+ internal weak var delegate: TherapySettingsViewModelDelegate? -+ -+ public init(therapySettings: TherapySettings, -+ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, -+ sensitivityOverridesEnabled: Bool = false, -+ adultChildInsulinModelSelectionEnabled: Bool = false, -+ prescription: Prescription? = nil, -+ delegate: TherapySettingsViewModelDelegate? = nil) { -+ self.therapySettings = therapySettings -+ self.delegate = delegate -+ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") -+ self.loadProfiles() -+ } -+ -+ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { -+ return delegate?.pumpSupportedIncrements() -+ } -+} -+ -+public struct Profile: Codable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ var sortOrder: Int? -+} -+ -+public struct ProfileReference: Codable, Equatable { -+ var name: String -+ var fileName: String -+ var sortOrder: Int? -+} -+ -+ -+// MARK: Saving -+extension ProfileViewModel { -+ -+ public func saveCorrectionRange(range: GlucoseRangeSchedule) { -+ therapySettings.glucoseTargetRangeSchedule = range -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveBasalRates(basalRates: BasalRateSchedule) { -+ therapySettings.basalRateSchedule = basalRates -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { -+ therapySettings.carbRatioSchedule = carbRatioSchedule -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { -+ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func triggerProfileSync() { -+ delegate?.updateCurrentProfileName() -+ } -+} -diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -index 3650ef60..c5c1dc01 100644 ---- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -+++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { - func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) - func saveCompletion(therapySettings: TherapySettings) - func pumpSupportedIncrements() -> PumpSupportedIncrements? -+ func updateCurrentProfileName() - } - - public class TherapySettingsViewModel: ObservableObject { -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift -new file mode 100644 -index 00000000..446910e4 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift -@@ -0,0 +1,79 @@ -+// -+// NewProfileEditor.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-19. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+ -+struct NewProfileEditor: View { -+ @Binding var isPresented: Bool -+ @State var newProfileName: String -+ var viewModel: ProfileViewModel -+ @State private var showAlert = false -+ -+ var body: some View { -+ VStack(spacing: 0) { -+ ModalHeaderButtonBar( -+ leading: { cancelButton }, -+ center: { -+ Text("New Profile") -+ .font(.headline) -+ }, -+ trailing: { addButton } -+ ) -+ -+ TextField("Profile Name", text: $newProfileName) -+ .textFieldStyle(RoundedBorderTextFieldStyle()) -+ .padding() -+ .background( -+ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) -+ .fill(Color(.secondarySystemGroupedBackground)) -+ ) -+ } -+ .padding(.horizontal) -+ .alert(isPresented: $showAlert) { -+ Alert( -+ title: Text("Overwrite Existing Profile"), -+ message: Text("A profile with this name already exists. Would you like to overwrite it?"), -+ primaryButton: .destructive(Text("Overwrite")) { -+ self.viewModel.saveProfile(withName: self.newProfileName) -+ self.isPresented = false -+ }, -+ secondaryButton: .cancel() -+ ) -+ } -+ } -+ -+ var addButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ if viewModel.doesProfileExist(withName: self.newProfileName) { -+ self.showAlert = true -+ } else { -+ self.viewModel.saveProfile(withName: self.newProfileName) -+ self.isPresented = false -+ } -+ } -+ }, label: { -+ Text("Add") -+ } -+ ) -+ } -+ -+ var cancelButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ self.isPresented = false -+ } -+ }, label: { -+ Text("Cancel") -+ } -+ ) -+ } -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -new file mode 100644 -index 00000000..a956b6d5 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,423 @@ -+// -+// ProfilePreviewView.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-23. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+import HealthKit -+import LoopKit -+ -+enum ActiveAlert { -+ case load -+ case delete -+ case error -+ case loadEdited -+} -+ -+struct ProfilePreviewView: View { -+ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference -+ @ObservedObject var viewModel: ProfileViewModel -+ @Environment(\.dismissAction) var dismissAction -+ @Environment(\.presentationMode) var presentationMode -+ @Environment(\.appName) private var appName -+ @State private var profile: Profile -+ @State private var editingDelegate: ProfileEditingDelegate -+ @State private var therapySettingsViewModel: TherapySettingsViewModel -+ -+ @State private var showingAlert = false -+ @State private var activeAlert: ActiveAlert = .load -+ @State private var errorText: String? -+ @State private var newProfileName: String = "" -+ @State private var isRenamingProfile = false -+ @State private var shouldDismissSelf: Bool = false -+ -+ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } -+ -+ init(viewModel: ProfileViewModel, profile: Profile) { -+ self.viewModel = viewModel -+ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) -+ _profile = State(initialValue: profile) -+ _editingDelegate = State(initialValue: editingDelegate) -+ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( -+ therapySettings: viewModel.therapySettings(applying: profile), -+ delegate: editingDelegate)) -+ } -+ -+ var body: some View { -+ ZStack { -+ ScrollView { -+ VStack(alignment: .leading) { -+ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") -+ .font(.body) -+ .foregroundColor(.primary) -+ .padding([.leading, .trailing]) -+ .fixedSize(horizontal: false, vertical: true) -+ .textCase(nil) -+ -+ profileCardStack -+ -+ Button(action: { -+ activeAlert = .load -+ showingAlert = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Load") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.blue) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ Button(action: { -+ isRenamingProfile = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Rename") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.orange) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ Button(action: { -+ activeAlert = .delete -+ showingAlert = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Delete") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.red) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ } -+ } -+ .navigationBarHidden(false) -+ .background(Color(.systemGroupedBackground)) -+ .navigationTitle(Text(profile.name)) -+ .alert(isPresented: $showingAlert) { -+ switch activeAlert { -+ case .load: -+ return Alert( -+ title: Text("Load Profile"), -+ message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: load), -+ secondaryButton: .cancel() -+ ) -+ case .delete: -+ return Alert( -+ title: Text("Delete Profile"), -+ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), -+ primaryButton: .destructive(Text("Delete"), action: { -+ viewModel.removeProfile(profile: profile) -+ self.presentationMode.wrappedValue.dismiss() -+ }), -+ secondaryButton: .cancel() -+ ) -+ case .error: -+ return Alert( -+ title: Text("Error loading profile"), -+ message: Text(errorText ?? "Unknown error"), -+ dismissButton: .default(Text("OK")) -+ ) -+ case .loadEdited: -+ return Alert( -+ title: Text("Load Edited Profile?"), -+ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), -+ primaryButton: .default(Text("Load"), action: load), -+ secondaryButton: .cancel(Text("Later")) -+ ) -+ } -+ } -+ if isRenamingProfile { -+ // Some hacks here to mimic Alert behaviour. -+ // This function should be migrateed to Alert when iOS 16 is required. -+ Color.black.opacity(0.5) -+ .edgesIgnoringSafeArea(.all) -+ .onTapGesture { -+ isRenamingProfile = false -+ } -+ -+ RenameProfileEditor( -+ isPresented: $isRenamingProfile, -+ currentProfileName: profile.name, -+ viewModel: viewModel, -+ shouldDismissParent: $shouldDismissSelf -+ ) -+ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) -+ .offset(y: -22) -+ } -+ } -+ .onAppear { -+ // Runs again when an editor closes. -+ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } -+ profile = saved -+ // The editors show the saved values, also after a save that failed. -+ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) -+ if let error = editingDelegate.saveError { -+ editingDelegate.saveError = nil -+ presentAlertLater(.error, errorText: error.localizedDescription) -+ } else if editingDelegate.savedEdits { -+ editingDelegate.savedEdits = false -+ if viewModel.currentProfileName == profile.name { -+ presentAlertLater(.loadEdited) -+ } -+ } -+ } -+ .onChange(of: shouldDismissSelf) { shouldDismiss in -+ if shouldDismiss { -+ self.presentationMode.wrappedValue.dismiss() -+ } -+ } -+ } -+ -+ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ guard !showingAlert else { return } -+ self.errorText = errorText -+ activeAlert = alert -+ showingAlert = true -+ } -+ } -+ -+ private func load() { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ -+ private var profileCardStack: CardStack { -+ var cards: [Card] = [] -+ -+ cards.append(correctionRangeSection) -+ cards.append(carbRatioSection) -+ cards.append(basalRatesSection) -+ cards.append(insulinSensitivitiesSection) -+ -+ return CardStack(cards: cards) -+ } -+ -+ private var correctionRangeSection: Card { -+ card(for: .glucoseTargetRange) { -+ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { -+ SectionDivider() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleRangeItem(time: items[index].startTime, -+ range: items[index].value, -+ unit: glucoseUnit, -+ guardrail: .correctionRange) -+ } -+ } -+ } -+ } -+ -+ private var carbRatioSection: Card { -+ card(for: .carbRatio) { -+ SectionDivider() -+ let items = profile.carbRatioSchedule.items -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: .gramsPerUnit, -+ guardrail: .carbRatio) -+ } -+ } -+ } -+ -+ private var basalRatesSection: Card { -+ card(for: .basalRate) { -+ let items = profile.basalRateSchedule.items -+ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates -+ { -+ SectionDivider() -+ let total = profile.basalRateSchedule.total() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: .internationalUnitsPerHour, -+ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) -+ } -+ HStack { -+ Text("Total") -+ .bold() -+ .foregroundColor(.primary) -+ Spacer() -+ Text(String(format: "%.2f ",total)) -+ .foregroundColor(.primary) + -+ Text("U/day") -+ .foregroundColor(.secondary) -+ } -+ } -+ } -+ } -+ -+ private var insulinSensitivitiesSection: Card { -+ card(for: .insulinSensitivity) { -+ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items -+ { -+ SectionDivider() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: sensitivityUnit, -+ guardrail: .insulinSensitivity) -+ } -+ } -+ } -+ } -+} -+ -+ -+// MARK: Utilities -+extension ProfilePreviewView { -+ -+ private var glucoseUnit: HKUnit { -+ displayGlucosePreference.unit -+ } -+ -+ private var sensitivityUnit: HKUnit { -+ glucoseUnit.unitDivided(by: .internationalUnit()) -+ } -+ -+ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { -+ Card { -+ SectionWithTapToEdit( -+ // The basal editor needs the pump's supported rates. -+ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, -+ title: therapySetting.title, -+ descriptiveText: therapySetting.descriptiveText(appName: appName), -+ destination: { dismiss in -+ editor(for: therapySetting, dismiss: dismiss) -+ .environment(\.dismissAction, dismiss) -+ }, -+ content: content -+ ) -+ } -+ } -+ -+ /// The therapy settings editors, saving to this profile. -+ @ViewBuilder -+ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { -+ switch therapySetting { -+ case .glucoseTargetRange: -+ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .carbRatio: -+ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .basalRate: -+ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .insulinSensitivity: -+ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ default: -+ EmptyView() -+ } -+ } -+} -+ -+fileprivate struct SectionDivider: View { -+ var body: some View { -+ Divider() -+ .padding(.trailing, -16) -+ } -+} -+ -+fileprivate struct SettingsDivider: View { -+ var body: some View { -+ Divider() -+ .padding(.trailing, -8) -+ } -+} -+ -+/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. -+final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { -+ private let viewModel: ProfileViewModel -+ private let profileName: String -+ private let basalTimeZone: TimeZone -+ /// Set when edits were saved, so the view can offer to load the edited active profile. -+ var savedEdits = false -+ /// Set when saving edits to the profile file failed. -+ var saveError: Error? -+ -+ init(viewModel: ProfileViewModel, profile: Profile) { -+ self.viewModel = viewModel -+ self.profileName = profile.name -+ self.basalTimeZone = profile.basalRateSchedule.timeZone -+ } -+ -+ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { -+ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { -+ completion(.failure(ProfileValidationError.basalRateError)) -+ return -+ } -+ completion(.success(schedule)) -+ } -+ -+ func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { -+ completion(.success(deliveryLimits)) -+ } -+ -+ func saveCompletion(therapySettings: TherapySettings) { -+ do { -+ try viewModel.updateProfile(named: profileName, from: therapySettings) -+ savedEdits = true -+ } catch { -+ saveError = error -+ } -+ } -+ -+ func pumpSupportedIncrements() -> PumpSupportedIncrements? { -+ return viewModel.pumpSupportedIncrements() -+ } -+ -+ func updateCurrentProfileName() {} -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift -new file mode 100644 -index 00000000..f816492b ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift -@@ -0,0 +1,173 @@ -+// -+// ProfileView.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-04-22. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import AVFoundation -+import HealthKit -+import LoopKit -+import SwiftUI -+ -+public struct ProfileView: View { -+ @ObservedObject public var viewModel: ProfileViewModel -+ @Environment(\.dismissAction) var dismissAction -+ @State private var newProfileName: String = "" -+ @State private var isAddingNewProfile = false -+ @State private var selectedProfileIndex: Int? = nil -+ @State private var refreshID = UUID() -+ -+ enum ActiveAlert: String, Identifiable { -+ case update, info -+ -+ var id: String { -+ return self.rawValue -+ } -+ } -+ @State private var activeAlert: ActiveAlert? -+ -+ public init(viewModel: ProfileViewModel) { -+ self.viewModel = viewModel -+ } -+ -+ private var dismissButton: some View { -+ Button(action: dismissAction) { -+ Text(LocalizedString("Done", comment: "Text for dismiss button")) -+ .bold() -+ } -+ } -+ -+ public var body: some View { -+ ZStack { -+ NavigationView { -+ ConfigurationPageScrollView( -+ content: { -+ VStack(alignment: .leading) { -+ -+ if !viewModel.profiles.isEmpty { -+ List { -+ ForEach(viewModel.profiles.indices, id: \.self) { index in -+ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { -+ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { -+ HStack { -+ if viewModel.currentProfileName == viewModel.profiles[index].name { -+ Image(systemName: "checkmark") -+ .foregroundColor(Color.blue) -+ } else { -+ // Add indentation to align with the checkmark for the active profile -+ Image(systemName: "checkmark") -+ .opacity(0) // Invisible -+ } -+ -+ Text(viewModel.profiles[index].name) -+ } -+ } -+ } -+ } -+ .onMove(perform: moveProfile) -+ }.id(refreshID) -+ -+ } else { -+ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") -+ .font(.subheadline) -+ .foregroundColor(.secondary) -+ .padding([.leading, .trailing]) -+ .multilineTextAlignment(.leading) -+ .lineLimit(nil) -+ .textCase(nil) -+ } -+ } -+ }, -+ actionArea: { EmptyView() } // no action area in this case -+ ) -+ .navigationBarItems( -+ leading: HStack { -+ Button(action: { withAnimation { isAddingNewProfile = true } }) { -+ Image(systemName: "plus") -+ } -+ -+ Button(action: { activeAlert = .info }) { -+ Image(systemName: "info.circle") -+ .resizable() -+ .frame(width: 24, height: 24) -+ } -+ }, -+ trailing: dismissButton -+ ) -+ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) -+ } -+ .navigationBarHidden(false) -+ .background(Color(.systemGroupedBackground)) -+ .onAppear { -+ if viewModel.isCurrentProfileOutOfSync() { -+ activeAlert = .update -+ } -+ } -+ -+ .alert(item: $activeAlert) { alertType in -+ switch alertType { -+ case .update: -+ return Alert( -+ title: Text("Profile Outdated"), -+ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), -+ primaryButton: .default(Text("Save Current Settings")) { -+ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") -+ }, -+ secondaryButton: .cancel(Text("Keep Saved Profile")) -+ ) -+ case .info: -+ return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), -+ dismissButton: .default(Text("Got it!"))) -+ } -+ } -+ -+ if isAddingNewProfile { -+ DarkenedOverlay() -+ -+ NewProfileEditor( -+ isPresented: $isAddingNewProfile, -+ newProfileName: newProfileName, -+ viewModel: viewModel -+ ) -+ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) -+ } -+ } -+ } -+ -+ func moveProfile(from source: IndexSet, to destination: Int) { -+ viewModel.profiles.move(fromOffsets: source, toOffset: destination) -+ refreshID = UUID() -+ viewModel.updateProfilesOrder() -+ } -+} -+ -+struct ProfileView_Previews: PreviewProvider { -+ static let preview_glucoseScheduleItems = [ -+ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), -+ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), -+ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) -+ ] -+ -+ static let preview_therapySettings = TherapySettings( -+ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), -+ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), -+ workout: DoubleRange(99...111), -+ unit: .milligramsPerDeciliter), -+ maximumBolus: 4, -+ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), -+ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: HKUnit.internationalUnit()), dailyItems: []), -+ carbRatioSchedule: nil, -+ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) -+ -+ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] -+ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] -+ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] -+ -+ static var previews: some View { -+ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) -+ } -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift -new file mode 100644 -index 00000000..8fd26665 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift -@@ -0,0 +1,92 @@ -+// -+// RenameProfileEditor.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-09-07. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+ -+struct RenameProfileEditor: View { -+ @Binding var isPresented: Bool -+ @State var currentProfileName: String -+ @State var newProfileName: String = "" -+ var viewModel: ProfileViewModel -+ @State private var showAlert = false -+ @Binding var shouldDismissParent: Bool -+ -+ var body: some View { -+ VStack(spacing: 0) { -+ ModalHeaderButtonBar( -+ leading: { cancelButton }, -+ center: { -+ Text("Rename Profile") -+ .font(.subheadline) -+ -+ }, -+ trailing: { renameButton } -+ ) -+ .onAppear { -+ self.newProfileName = currentProfileName -+ } -+ -+ TextField("Profile Name", text: $newProfileName) -+ .textFieldStyle(RoundedBorderTextFieldStyle()) -+ .padding() -+ .background( -+ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) -+ .fill(Color(.secondarySystemGroupedBackground)) -+ ) -+ } -+ .padding(.horizontal) -+ .alert(isPresented: $showAlert) { -+ Alert( -+ title: Text("Overwrite Existing Profile"), -+ message: Text("A profile with this name already exists. Would you like to overwrite it?"), -+ primaryButton: .destructive(Text("Overwrite")) { -+ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) -+ self.shouldDismissParent = true -+ self.isPresented = false -+ }, -+ secondaryButton: .cancel() -+ ) -+ } -+ } -+ -+ var renameButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ guard newProfileName != currentProfileName else { -+ self.isPresented = false -+ return -+ } -+ -+ if viewModel.doesProfileExist(withName: self.newProfileName) { -+ self.showAlert = true -+ } else { -+ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) -+ self.shouldDismissParent = true -+ self.isPresented = false -+ } -+ } -+ }, label: { -+ Text("Rename") -+ } -+ ) -+ } -+ -+ var cancelButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ self.isPresented = false -+ } -+ }, label: { -+ Text("Cancel") -+ } -+ ) -+ } -+} -Submodule LoopOnboarding contains modified content -diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -index cc61f2d..6462e2c 100644 ---- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -+++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -@@ -379,6 +379,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { - maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount - ) - } -+ -+ func updateCurrentProfileName() { -+ } - } - - -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index 69f7a26..14a7691 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -24,6 +24,10 @@ - A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; -@@ -158,6 +162,10 @@ - A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; - A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; -@@ -268,6 +276,7 @@ - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, - A934B6172358F49300949C8B /* StoredSettings.swift */, -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, - A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, - A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, -@@ -324,6 +333,7 @@ - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, - A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, - ); - path = NightscoutServiceKitTests; -@@ -364,6 +374,7 @@ - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, - ); - path = Notifications; - sourceTree = ""; -@@ -412,6 +423,7 @@ - A9AA6E5729EB06AA008FFA78 /* Actions */ = { - isa = PBXGroup; - children = ( -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -@@ -744,6 +756,9 @@ - C1398D2227C434FB00416AD6 /* Data.swift in Sources */, - A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, - A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, - C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, - ); -@@ -757,6 +772,7 @@ - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, - A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -new file mode 100644 -index 0000000..de73ca9 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -@@ -0,0 +1,104 @@ -+// -+// SavedProfile.swift -+// NightscoutServiceKit -+// -+ -+import CryptoKit -+import Foundation -+import HealthKit -+import LoopKit -+import NightscoutKit -+ -+/// A profile saved with the Profiles settings screen, read from the file it was saved to. -+struct SavedProfile: Decodable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ -+ private static var directory: URL { -+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") -+ } -+ -+ static func loadAll() -> [SavedProfile] { -+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] -+ return files -+ .filter { $0.pathExtension == "json" } -+ .compactMap { try? Data(contentsOf: $0) } -+ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } -+ } -+ -+ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. -+ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { -+ var store: [String: ProfileSet.Profile] = [:] -+ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. -+ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { -+ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) -+ } -+ return store -+ } -+ -+ /// True when this profile uploads with this fingerprint, in either glucose unit. -+ func matches(fingerprint: String) -> Bool { -+ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { -+ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint -+ } -+ } -+ -+ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { -+ guard let correctionRange = correctionRange.schedule(for: unit), -+ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) -+ else { -+ return nil -+ } -+ return ProfileSet.Profile( -+ timezone: basalRateSchedule.timeZone, -+ dia: .hours(6), -+ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, -+ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, -+ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) -+ } -+} -+ -+extension ProfileSet.Profile { -+ /// Followers compute the same value from the uploaded profile and send it with a switch command, -+ /// so Loop only activates the values the follower previewed. -+ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` -+ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. -+ var remoteFingerprint: String { -+ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { -+ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") -+ } -+ let canonical = [ -+ "basal=" + items(basal), -+ "carbratio=" + items(carbratio), -+ "sens=" + items(sensitivity), -+ "target_low=" + items(targetLow), -+ "target_high=" + items(targetHigh), -+ ].joined(separator: ";") -+ let digest = SHA256.hash(data: Data(canonical.utf8)) -+ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) -+ } -+} -+ -+enum RemoteProfileSwitchError: LocalizedError { -+ case profileChanged -+ case unavailable -+ case notActivated(String, Error) -+ -+ /// Followers read the profile name from this text to match the reply to their command. -+ var errorDescription: String? { -+ switch self { -+ case .profileChanged: -+ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") -+ case .unavailable: -+ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") -+ case .notActivated(let name, let error): -+ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index 4e1d49e..b66fb97 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -159,7 +159,8 @@ extension StoredDosingDecision { - automaticDoseRecommendation: loopStatusAutomaticDoseRecommendation, - recommendedBolus: loopStatusRecommendedBolus, - enacted: automaticDoseDecision?.loopStatusEnacted, -- failureReason: automaticDoseDecision?.loopStatusFailureReason), -+ failureReason: automaticDoseDecision?.loopStatusFailureReason, -+ testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]]), - overrideStatus: overrideStatus) - } - -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -index 2adadb9..dd7ac28 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -@@ -94,12 +94,21 @@ extension StoredSettings { - return nil - } - -+ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) -+ store["Default"] = profile -+ var defaultProfile = "Default" -+ -+ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { -+ defaultProfile = name -+ store[name] = profile -+ } -+ - return ProfileSet( - startDate: date, - units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), - enteredBy: "Loop", -- defaultProfile: "Default", -- store: ["Default": profile], -+ defaultProfile: defaultProfile, -+ store: store, - settings: loopSettings, - syncIdentifier: syncIdentifier.uuidString, - isAPNSProduction: Bundle.main.isAPNSProductionEnvironment) -diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index 4626630..ef69236 100644 ---- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift -+++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -436,6 +436,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - success = true - message = "Override '\(overrideCommand.name)' enacted successfully" - -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ success = true -+ message = "Profile '\(profileSwitchCommand.name)' activated" -+ - case .cancelTemporaryOverride: - commandType = .cancelOverride - try await self.serviceDelegate?.cancelRemoteOverride() -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index a92b509..29313b6 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -9,6 +9,7 @@ - import Foundation - - public enum Action: Codable { -+ case profileSwitch(ProfileSwitchAction) - case temporaryScheduleOverride(OverrideAction) - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) -@@ -28,6 +29,8 @@ public enum Action: Codable { - return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") - case .temporaryScheduleOverride: - return LocalizedString("Override", comment: "The remote action name for Override") -+ case .profileSwitch: -+ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") - } - } - -@@ -43,6 +46,8 @@ public enum Action: Codable { - return "" - case .temporaryScheduleOverride(let overrideAction): - return overrideAction.name -+ case .profileSwitch(let profileSwitchAction): -+ return profileSwitchAction.name - } - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -new file mode 100644 -index 0000000..ce03ded ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -@@ -0,0 +1,17 @@ -+// -+// ProfileSwitchAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct ProfileSwitchAction: Codable { -+ -+ public let name: String -+ public let fingerprint: String -+ -+ public init(name: String, fingerprint: String) { -+ self.name = name -+ self.fingerprint = fingerprint -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 95274e4..4457f80 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -45,6 +45,7 @@ class RemoteNotificationResponseManager { - case carbs = "carbs" - case override = "override" - case cancelOverride = "cancel_override" -+ case profileSwitch = "profile_switch" - } - - func sendResponseNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -new file mode 100644 -index 0000000..487c620 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -0,0 +1,50 @@ -+// -+// ProfileSwitchRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Switches to a saved profile. `expiration` is required: decoding fails without it. -+public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { -+ -+ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. -+ static let commandKey = "profile-switch" -+ -+ public let profileName: String -+ public let fingerprint: String -+ public let remoteAddress: String -+ public let expirationDate: Date -+ public let sentAt: Date? -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ public var expiration: Date? { -+ return expirationDate -+ } -+ -+ enum CodingKeys: String, CodingKey { -+ case profileName = "profile-switch" -+ case fingerprint = "profile-switch-fingerprint" -+ case remoteAddress = "remote-address" -+ case expirationDate = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification[commandKey] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index 0c5f8e3..3362bf7 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -108,6 +108,8 @@ extension Dictionary { - return try OverrideRemoteNotification(dictionary: self) - } else if OverrideCancelRemoteNotification.includedInNotification(self) { - return try OverrideCancelRemoteNotification(dictionary: self) -+ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { -+ return try ProfileSwitchRemoteNotification(dictionary: self) - } else { - throw RemoteNotificationError.unhandledNotification(self) - } -diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..2fcec94 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -@@ -0,0 +1,71 @@ -+// -+// ProfileSwitchRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+import LoopKit -+import NightscoutKit -+@testable import NightscoutServiceKit -+ -+final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "sent-at": "2023-02-25T20:46:35.778Z", -+ "expiration": "2023-02-25T20:51:35.778Z", -+ "otp": "123456" -+ ] -+ -+ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) -+ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) -+ -+ XCTAssertEqual(profileSwitch.profileName, "Sport") -+ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") -+ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) -+ XCTAssertTrue(profileSwitch.otpValidationRequired()) -+ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { -+ return XCTFail("Expected a profile switch action") -+ } -+ XCTAssertEqual(action.name, "Sport") -+ } -+ -+ func testParseProfileSwitchNotification_MissingExpiration_Fails() { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "otp": "123456" -+ ] -+ -+ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) -+ } -+ -+ func testFingerprint_MatchesFollowerVector() { -+ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { -+ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } -+ } -+ let profile = ProfileSet.Profile( -+ timezone: TimeZone(secondsFromGMT: 3600)!, -+ dia: .hours(6), -+ sensitivity: items([(0, 45)]), -+ carbratio: items([(0, 10)]), -+ basal: items([(0, 0.85), (21600, 1.1)]), -+ targetLow: items([(0, 100)]), -+ targetHigh: items([(0, 110)]), -+ units: "mg/dL") -+ -+ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") -+ } -+} -+ -+private extension ISO8601DateFormatter { -+ static let fractional: ISO8601DateFormatter = { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ }() -+} -diff --git a/profiles.loopcustomization b/profiles.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/profiles/dev_profiles_basal_lock_remote_carb_edit.patch b/profiles/dev_profiles_basal_lock_remote_carb_edit.patch deleted file mode 100644 index f04b4ef..0000000 --- a/profiles/dev_profiles_basal_lock_remote_carb_edit.patch +++ /dev/null @@ -1,2168 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index cabca1e6..3b5f1de2 100644 ---- a/Loop/Loop/Managers/DeviceDataManager.swift -+++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -433,6 +433,7 @@ final class DeviceDataManager { - ) - - statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) -+ servicesManager.deviceDataManager = self - - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] - criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, -@@ -1673,6 +1674,10 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { - } - } - -+ func updateCurrentProfileName() { -+ loopManager.updateCurrentProfileName() -+ } -+ - func saveCompletion(therapySettings: TherapySettings) { - - loopManager.mutateSettings { settings in -diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift -index 16959e56..42c17bb1 100644 ---- a/Loop/Loop/Managers/LoopDataManager.swift -+++ b/Loop/Loop/Managers/LoopDataManager.swift -@@ -244,6 +244,10 @@ final class LoopDataManager { - lockedSettings.value - } - -+ func updateCurrentProfileName () { -+ notify(forChange: .preferences) -+ } -+ - func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { - var oldValue: LoopSettings! - let newValue = lockedSettings.mutate { settings in -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 3eb3154b..6cefc092 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -293,6 +293,38 @@ extension NotificationManager { - } - } - -+ static func sendRemoteProfileNotification(name: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfile", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteProfileFailureNotification(name: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfileFailure", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ private static func remoteProfileNotificationBody(name: String) -> String { -+ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) -+ } -+ - private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { - return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) - } -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 3b74fd9d..7a8dc92f 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -28,6 +28,7 @@ class ServicesManager { - - weak var servicesManagerDelegate: ServicesManagerDelegate? - weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? -+ weak var deviceDataManager: DeviceDataManager? - - private var services = [Service]() - -@@ -456,3 +457,58 @@ extension Service { - ] - } - } -+ -+// MARK: - RemoteProfileActionDelegate -+ -+extension ServicesManager: RemoteProfileActionDelegate { -+ @MainActor -+ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { -+ do { -+ guard let deviceDataManager else { -+ throw RemoteProfileActionError.unavailable -+ } -+ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) -+ guard deviceDataManager.pumpSupportedIncrements() != nil else { -+ throw RemoteProfileActionError.noPump -+ } -+ guard let profile = try viewModel.savedProfile(named: name) else { -+ throw RemoteProfileActionError.profileNotFound(name) -+ } -+ try profile.verify(with: verify) -+ if case .failure(let error) = viewModel.validateProfile(profile) { -+ throw error -+ } -+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ continuation.resume() -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ NotificationManager.sendRemoteProfileNotification(name: name) -+ } catch { -+ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) -+ throw error -+ } -+ } -+ -+ enum RemoteProfileActionError: LocalizedError { -+ case unavailable -+ case noPump -+ case profileNotFound(String) -+ -+ var errorDescription: String? { -+ switch self { -+ case .unavailable: -+ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") -+ case .noPump: -+ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") -+ case .profileNotFound(let name): -+ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) -+ } -+ } -+ } -+} -diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift -index f31763de..4df43b2c 100644 ---- a/Loop/Loop/Views/SettingsView.swift -+++ b/Loop/Loop/Views/SettingsView.swift -@@ -51,6 +51,7 @@ public struct SettingsView: View { - - case favoriteFoods - case preferences -+ case profiles - } - } - -@@ -125,6 +126,19 @@ public struct SettingsView: View { - FavoriteFoodsView() - case .preferences: - PreferencesView(viewModel: PreferencesViewModel(preferencesProvider: Preferences.shared)).environmentObject(displayGlucosePreference) -+ case .profiles: -+ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), -+ sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, -+ adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, -+ delegate: self.viewModel.therapySettingsViewModelDelegate)) -+ .environmentObject(displayGlucosePreference) -+ .environment(\.dismissAction, self.dismiss) -+ .environment(\.appName, self.appName) -+ .environment(\.chartColorPalette, .primary) -+ .environment(\.carbTintColor, self.carbTintColor) -+ .environment(\.glucoseTintColor, self.glucoseTintColor) -+ .environment(\.guidanceColors, self.guidanceColors) -+ .environment(\.insulinTintColor, self.insulinTintColor) - } - } - } -@@ -285,6 +299,11 @@ extension SettingsView { - descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) - } - -+ LargeButton(action: { sheet = .profiles }, -+ includeArrow: true, -+ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), -+ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), -+ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) - ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in - item.view - } -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj -index c36bfda3..5e9ec17d 100644 ---- a/LoopKit/LoopKit.xcodeproj/project.pbxproj -+++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj -@@ -873,6 +873,12 @@ - D1A0B000000000000000000C /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; - D1A0B000000000000000000D /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; - D1A0B0000000000000000081 /* PersistenceControllerMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */; }; -+ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; -+ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; -+ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; -+ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; -+ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; -+ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; - DD13BC702C3C71A70062313B /* BasalLockEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC6F2C3C71A70062313B /* BasalLockEditor.swift */; }; - DD13BC722C3C74CD0062313B /* PreferencesGuardrailWarning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC712C3C74CD0062313B /* PreferencesGuardrailWarning.swift */; }; - DD28B3712B80C074001044D4 /* PreferencesSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD28B3702B80C074001044D4 /* PreferencesSetting.swift */; }; -@@ -1820,6 +1826,12 @@ - D1A0B0000000000000000006 /* Modelv5ToModelv6.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = Modelv5ToModelv6.xcmappingmodel; sourceTree = ""; }; - D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelMappingv4v5Tov6Policy.swift; sourceTree = ""; }; - D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; -+ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; -+ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; -+ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; -+ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; -+ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; -+ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; - DD13BC6F2C3C71A70062313B /* BasalLockEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalLockEditor.swift; sourceTree = ""; }; - DD13BC712C3C74CD0062313B /* PreferencesGuardrailWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesGuardrailWarning.swift; sourceTree = ""; }; - DD28B3702B80C074001044D4 /* PreferencesSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesSetting.swift; sourceTree = ""; }; -@@ -3027,12 +3039,14 @@ - isa = PBXGroup; - children = ( - B455F3A325FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift */, -+ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, - B4B7C1BB2604E3FC007379F6 /* CorrectionRangeScheduleEditorViewModel.swift */, - B4D4C20C25F95A8700DA809D /* DisplayGlucosePreference.swift */, - B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, - B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, - 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, - DD545A5E2B80EFA900915F95 /* PreferencesViewModel.swift */, -+ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, - ); - path = ViewModels; - sourceTree = ""; -@@ -3230,6 +3244,10 @@ - E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, - 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, - 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, -+ DDAF746629F422C600719F0A /* ProfileView.swift */, -+ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, -+ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, -+ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, - ); - path = "Settings Editors"; - sourceTree = ""; -@@ -3940,6 +3958,7 @@ - E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, - B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, - 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, -+ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, - 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, - E9E5E56A24D5CCE800B5DFFE /* OverrideViewCell.swift in Sources */, - 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, -@@ -3949,6 +3968,7 @@ - B43D69D62A26642300DF0925 /* DemoPlaceHolderView.swift in Sources */, - E949E38F24B3711E00024DA0 /* InsulinModelInformationView.swift in Sources */, - 895FE08B22011F0C00FCF18A /* EmojiInputHeaderView.swift in Sources */, -+ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, - 895FE08322011F0C00FCF18A /* OverrideSelectionFooterView.swift in Sources */, - 89AF78C22447E353002B4FCC /* Splat.swift in Sources */, - 893C9F8C2447DBD900CD4185 /* CardBuilder.swift in Sources */, -@@ -4000,9 +4020,11 @@ - E9C58A6E24DA65E400487A17 /* HistoricalOverrideDetailView.swift in Sources */, - 895FE08222011F0C00FCF18A /* LabeledTextFieldTableViewCell.swift in Sources */, - B43DA44224D9CD8500CAFF4E /* Environment+Colors.swift in Sources */, -+ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, - 892A5DB22231E191008961AB /* LoadingTableViewCell.swift in Sources */, - B4D3D4AD25B8A94E0085BA0F /* DisplayGlucoseUnitObserver.swift in Sources */, - A9D3FF102A6C19CA000C891D /* ChartAxisValueDoubleLog.swift in Sources */, -+ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, - E96DCB5A24AF74AC007117BC /* SuspendThresholdEditor.swift in Sources */, - E96DCB5824AEF50F007117BC /* SuspendThresholdInformationView.swift in Sources */, - A9D3FF0C2A6C198C000C891D /* CollectionType.swift in Sources */, -@@ -4079,6 +4101,7 @@ - E93C86B624D08CAD0073089B /* InsulinSensitivityInformationView.swift in Sources */, - C18733AF29B9492300519CDF /* Collection.swift in Sources */, - 892A5DA22231E137008961AB /* HUDProvider.swift in Sources */, -+ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, - 898E6E6C224194060019E459 /* UIColor.swift in Sources */, - 43BA717D201EE7090058961E /* GlucoseRangeTableViewCell.swift in Sources */, - B455F3A425FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift in Sources */, -@@ -4106,6 +4129,7 @@ - 1D1065E9282DC54700026A70 /* VideoPlayView.swift in Sources */, - E99A132E2557548300D3F5B3 /* SegmentedGaugeBar.swift in Sources */, - B455F31825FBEC5F000ED456 /* SuspendThresholdEditorViewModel.swift in Sources */, -+ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, - 892A5DB32231E191008961AB /* LevelHUDView.swift in Sources */, - B41A60B223D1DBC700636320 /* UIFont.swift in Sources */, - B41A60AF23D1DB5B00636320 /* TableViewTitleLabel.swift in Sources */, -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 4b2cecf3..56742c96 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -8,6 +8,12 @@ - - import Foundation - -+/// Switches to a saved therapy profile on a remote command. -+public protocol RemoteProfileActionDelegate: AnyObject { -+ /// `verify` receives the schedules about to be activated and throws to reject them. -+ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws -+} -+ - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws - func cancelRemoteOverride() async throws -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -new file mode 100644 -index 00000000..b571cf7b ---- /dev/null -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,466 @@ -+// -+// ProfileViewModel+FileManagement.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-19. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import LoopKit -+import HealthKit -+ -+public enum ProfileValidationError: Error, LocalizedError { -+ case fileLoadError -+ case unsupportedIncrement -+ case correctionRangeError -+ case insulinSensitivityError -+ case carbRatioError -+ case basalRateError -+ case maxBasalRateNotSet -+ -+ public var errorDescription: String? { -+ switch self { -+ case .fileLoadError: -+ return "Unable to load the profile file." -+ case .unsupportedIncrement: -+ return "The profile contains unsupported increments." -+ case .correctionRangeError: -+ return "Correction Range values are out of bounds." -+ case .insulinSensitivityError: -+ return "Insulin Sensitivity values are out of bounds." -+ case .carbRatioError: -+ return "Carb Ratio values are out of bounds." -+ case .basalRateError: -+ return "Basal Rate values are out of bounds." -+ case .maxBasalRateNotSet: -+ return "Maximum Basal Rate is not set." -+ } -+ } -+} -+ -+public enum LoadProfileResult { -+ case success -+ case failure(Error) -+} -+ -+// MARK: File management -+extension ProfileViewModel { -+ private func setCurrentProfile(name: String, syncChange: Bool = false) { -+ let sanitizedProfileName = sanitizeProfileName(name) -+ currentProfileName = sanitizedProfileName -+ if syncChange { -+ triggerProfileSync() -+ } -+ } -+ -+ private func clearCurrentProfile() { -+ currentProfileName = nil -+ triggerProfileSync() -+ } -+ -+ private func getCurrentProfileName() -> String? { -+ return currentProfileName -+ } -+ -+ private var profilesDirectory: URL { -+ let fileManager = FileManager.default -+ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] -+ return documentsDirectory.appendingPathComponent("LoopProfile") -+ } -+ -+ private func createProfileDirectoryIfNotExists() throws { -+ let fileManager = FileManager.default -+ var isDir : ObjCBool = false -+ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { -+ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) -+ } else if !isDir.boolValue { -+ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) -+ } -+ } -+ -+ private func getAllProfileFiles() throws -> [URL] { -+ let fileManager = FileManager.default -+ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) -+ return files.filter { $0.pathExtension == "json" } -+ } -+ -+ private func encodeProfile(_ profile: Profile) throws -> Data { -+ return try JSONEncoder().encode(profile) -+ } -+ -+ private func decodeProfile(from data: Data) throws -> Profile { -+ return try JSONDecoder().decode(Profile.self, from: data) -+ } -+ -+ public func sanitizeProfileName(_ name: String) -> String { -+ // Replace problematic characters -+ var sanitized = name.replacingOccurrences(of: ".", with: "_") -+ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") -+ -+ // Limit the name to 32 characters -+ if sanitized.count > 32 { -+ sanitized = String(sanitized.prefix(32)) -+ } -+ -+ return sanitized -+ } -+ -+ public func saveProfile(withName name: String) { -+ let sanitizedName = sanitizeProfileName(name) -+ -+ do { -+ try createProfileDirectoryIfNotExists() -+ -+ if let existingProfile = getProfileReference(withName: sanitizedName) { -+ removeProfile(profileReference: existingProfile) -+ } -+ -+ let profile = Profile( -+ name: sanitizedName, -+ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, -+ carbRatioSchedule: therapySettings.carbRatioSchedule!, -+ basalRateSchedule: therapySettings.basalRateSchedule!, -+ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, -+ sortOrder: -1 -+ ) -+ -+ let jsonData = try encodeProfile(profile) -+ -+ let dateFormatter = DateFormatter() -+ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" -+ let fileName = dateFormatter.string(from: Date()) + ".json" -+ let fileURL = profilesDirectory.appendingPathComponent(fileName) -+ -+ try jsonData.write(to: fileURL) -+ setCurrentProfile(name: sanitizedName, syncChange: true) -+ -+ self.loadProfiles() -+ } catch { -+ print("An error occurred while saving profile: \(error)") -+ } -+ } -+ -+ public func renameProfile(oldName: String, newName: String) { -+ let sanitizedProfileName = sanitizeProfileName(newName) -+ -+ if currentProfileName == oldName { -+ setCurrentProfile(name: sanitizedProfileName, syncChange: true) -+ } -+ -+ do { -+ guard let profileReference = getProfileReference(withName: oldName) else { -+ print("Profile with old name not found.") -+ return -+ } -+ -+ let profile = try getProfile(from: profileReference) -+ -+ let newProfile = Profile( -+ name: sanitizedProfileName, -+ correctionRange: profile.correctionRange, -+ carbRatioSchedule: profile.carbRatioSchedule, -+ basalRateSchedule: profile.basalRateSchedule, -+ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, -+ sortOrder: profile.sortOrder -+ ) -+ -+ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { -+ removeProfile(profileReference: existingProfile) -+ } -+ -+ let jsonData = try encodeProfile(newProfile) -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ -+ try jsonData.write(to: fileURL) -+ -+ self.loadProfiles() -+ triggerProfileSync() -+ } catch { -+ print("An error occurred while renaming profile: \(error)") -+ } -+ } -+ -+ public func updateProfilesOrder() { -+ for (index, profileReference) in profiles.enumerated() { -+ do { -+ var profile = try getProfile(from: profileReference) -+ let newProfile = Profile( -+ name: profile.name, -+ correctionRange: profile.correctionRange, -+ carbRatioSchedule: profile.carbRatioSchedule, -+ basalRateSchedule: profile.basalRateSchedule, -+ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, -+ sortOrder: index -+ ) -+ -+ let jsonData = try encodeProfile(newProfile) -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ try jsonData.write(to: fileURL) -+ -+ } catch { -+ print("An error occurred while updating profile order: \(error)") -+ } -+ } -+ loadProfiles() -+ } -+ -+ public func loadProfiles() { -+ do { -+ let profileFiles = try getAllProfileFiles() -+ -+ var newProfiles = [ProfileReference]() -+ for fileURL in profileFiles { -+ let data = try Data(contentsOf: fileURL) -+ let profile = try decodeProfile(from: data) -+ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) -+ newProfiles.append(profileRef) -+ } -+ -+ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } -+ -+ self.profiles = newProfiles -+ } catch { -+ print("An error occurred while loading profiles: \(error)") -+ } -+ } -+ -+ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { -+ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && -+ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && -+ therapySettings.basalRateSchedule == profile.basalRateSchedule && -+ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { -+ self.setCurrentProfile(name: profile.name, syncChange: true) -+ completion(.success) -+ } -+ else -+ { -+ do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in -+ switch result { -+ case .success(let syncedSchedule): -+ DispatchQueue.main.async { -+ let previousSettings = self.therapySettings -+ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! -+ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule -+ self.therapySettings.basalRateSchedule = syncedSchedule -+ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! -+ // One settings change, so the stored and uploaded settings never mix two profiles. -+ self.setCurrentProfile(name: profile.name, syncChange: false) -+ self.delegate?.saveCompletion(therapySettings: self.therapySettings) -+ if self.therapySettings == previousSettings { -+ // Nothing changed, so only the profile name needs to reach the uploaded settings. -+ self.triggerProfileSync() -+ } -+ print("New profile loaded") -+ completion(.success) -+ } -+ case .failure(let error): -+ print("An error occurred while syncing basal rates: \(error)") -+ completion(.failure(error)) -+ } -+ }) -+ } -+ } -+ } -+ -+ func getProfileReference(withName name: String) -> ProfileReference? { -+ let sanitizedInputName = sanitizeProfileName(name) -+ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) -+ } -+ -+ public func removeProfile(profile: Profile) { -+ guard let profileReference = getProfileReference(withName: profile.name) else { -+ print("No ProfileReference found for given Profile") -+ return -+ } -+ -+ removeProfile(profileReference: profileReference) -+ if getCurrentProfileName() == profile.name { -+ clearCurrentProfile() -+ } else { -+ triggerProfileSync() -+ } -+ } -+ -+ public func removeProfile(profileReference: ProfileReference) { -+ do { -+ let fileManager = FileManager.default -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ try fileManager.removeItem(at: fileURL) -+ loadProfiles() -+ } catch { -+ print("An error occurred while removing profile: \(error)") -+ } -+ } -+ -+ func doesProfileExist(withName name: String) -> Bool { -+ return profiles.contains(where: { $0.name == name }) -+ } -+ -+ public func getProfile(from profileReference: ProfileReference) throws -> Profile { -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ let data = try Data(contentsOf: fileURL) -+ let getProfile = try decodeProfile(from: data) -+ return getProfile -+ } -+ -+ /// The saved profile named `name`, or nil when there is none. -+ public func savedProfile(named name: String) throws -> Profile? { -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ return nil -+ } -+ return try getProfile(from: profileReference) -+ } -+ -+ public func validateProfile(_ profile: Profile) -> Result { -+ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { -+ return .failure(.fileLoadError) -+ } -+ -+ // Checking Correction Range Schedule -+ for item in profile.correctionRange.items { -+ let minValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) -+ let maxValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) -+ -+ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { -+ return .failure(.correctionRangeError) -+ } -+ } -+ -+ // Checking Insulin Sensitivity Schedule -+ for item in profile.insulinSensitivitySchedule.items { -+ let value = HKQuantity(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: .internationalUnit()), doubleValue: item.value) -+ -+ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { -+ return .failure(.insulinSensitivityError) -+ } -+ } -+ -+ // Checking Carb Ratio Schedule -+ for item in profile.carbRatioSchedule.items { -+ let value = HKQuantity(unit: .gramsPerUnit, doubleValue: item.value) -+ -+ if !Guardrail.carbRatio.absoluteBounds.contains(value) { -+ return .failure(.carbRatioError) -+ } -+ } -+ -+ // Checking Basal Rate Schedule -+ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { -+ for item in profile.basalRateSchedule.items { -+ let value = item.value -+ -+ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { -+ return .failure(.basalRateError) -+ } -+ } -+ } else { -+ return .failure(.maxBasalRateNotSet) -+ } -+ -+ return .success(()) -+ } -+ -+ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { -+ guard a.items.count == b.items.count else { -+ return false -+ } -+ -+ for (item1, item2) in zip(a.items, b.items) { -+ if item1.startTime != item2.startTime || -+ abs(item1.value.minValue - item2.value.minValue) > 0.01 || -+ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { -+ return false -+ } -+ } -+ -+ return true -+ } -+ -+ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { -+ guard a.items.count == b.items.count else { -+ return false -+ } -+ -+ for (item1, item2) in zip(a.items, b.items) { -+ if item1.startTime != item2.startTime || -+ abs(item1.value - item2.value) > 0.01 { -+ return false -+ } -+ } -+ -+ return true -+ } -+ -+ public func isCurrentProfileOutOfSync() -> Bool { -+ guard let currentProfileName = self.currentProfileName, -+ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), -+ let profile = try? getProfile(from: currentProfileReference), -+ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: HKUnit.milligramsPerDeciliter), -+ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: HKUnit.milligramsPerDeciliter) else { -+ return false // No current profile set or profile not found or schedules not available -+ } -+ -+ var isOutOfSync = false -+ -+ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: HKUnit.milligramsPerDeciliter)!) { -+ print("Glucose target range schedule differs") -+ isOutOfSync = true -+ } -+ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { -+ print("Carb ratio schedule differs") -+ isOutOfSync = true -+ } -+ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { -+ print("Basal rate schedule differs") -+ isOutOfSync = true -+ } -+ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: HKUnit.milligramsPerDeciliter)!) { -+ print("Insulin sensitivity schedule differs") -+ isOutOfSync = true -+ } -+ -+ return isOutOfSync -+ } -+} -+ -+extension Profile { -+ /// Passes the schedules this profile activates to `check`. -+ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { -+ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) -+ } -+} -+ -+extension ProfileViewModel { -+ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. -+ func therapySettings(applying profile: Profile) -> TherapySettings { -+ var settings = therapySettings -+ let unit = settings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) -+ settings.carbRatioSchedule = profile.carbRatioSchedule -+ settings.basalRateSchedule = profile.basalRateSchedule -+ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) -+ return settings -+ } -+ -+ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. -+ func updateProfile(named name: String, from settings: TherapySettings) throws { -+ loadProfiles() -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ throw ProfileValidationError.fileLoadError -+ } -+ let profile = try getProfile(from: profileReference) -+ let updatedProfile = Profile( -+ name: profile.name, -+ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, -+ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, -+ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, -+ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, -+ sortOrder: profile.sortOrder -+ ) -+ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) -+ loadProfiles() -+ triggerProfileSync() -+ } -+} -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift -new file mode 100644 -index 00000000..a48fa667 ---- /dev/null -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift -@@ -0,0 +1,86 @@ -+// -+// ProfileViewModel.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-04-22. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import Combine -+import LoopKit -+import HealthKit -+import SwiftUI -+ -+public class ProfileViewModel: ObservableObject { -+ -+ @Published public var therapySettings: TherapySettings -+ @Published public var profiles = [ProfileReference]() -+ @Published public var currentProfileName: String? { -+ didSet { -+ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") -+ } -+ } -+ -+ internal weak var delegate: TherapySettingsViewModelDelegate? -+ -+ public init(therapySettings: TherapySettings, -+ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, -+ sensitivityOverridesEnabled: Bool = false, -+ adultChildInsulinModelSelectionEnabled: Bool = false, -+ prescription: Prescription? = nil, -+ delegate: TherapySettingsViewModelDelegate? = nil) { -+ self.therapySettings = therapySettings -+ self.delegate = delegate -+ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") -+ self.loadProfiles() -+ } -+ -+ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { -+ return delegate?.pumpSupportedIncrements() -+ } -+} -+ -+public struct Profile: Codable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ var sortOrder: Int? -+} -+ -+public struct ProfileReference: Codable, Equatable { -+ var name: String -+ var fileName: String -+ var sortOrder: Int? -+} -+ -+ -+// MARK: Saving -+extension ProfileViewModel { -+ -+ public func saveCorrectionRange(range: GlucoseRangeSchedule) { -+ therapySettings.glucoseTargetRangeSchedule = range -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveBasalRates(basalRates: BasalRateSchedule) { -+ therapySettings.basalRateSchedule = basalRates -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { -+ therapySettings.carbRatioSchedule = carbRatioSchedule -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { -+ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func triggerProfileSync() { -+ delegate?.updateCurrentProfileName() -+ } -+} -diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -index 3650ef60..c5c1dc01 100644 ---- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -+++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { - func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) - func saveCompletion(therapySettings: TherapySettings) - func pumpSupportedIncrements() -> PumpSupportedIncrements? -+ func updateCurrentProfileName() - } - - public class TherapySettingsViewModel: ObservableObject { -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift -new file mode 100644 -index 00000000..446910e4 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift -@@ -0,0 +1,79 @@ -+// -+// NewProfileEditor.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-19. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+ -+struct NewProfileEditor: View { -+ @Binding var isPresented: Bool -+ @State var newProfileName: String -+ var viewModel: ProfileViewModel -+ @State private var showAlert = false -+ -+ var body: some View { -+ VStack(spacing: 0) { -+ ModalHeaderButtonBar( -+ leading: { cancelButton }, -+ center: { -+ Text("New Profile") -+ .font(.headline) -+ }, -+ trailing: { addButton } -+ ) -+ -+ TextField("Profile Name", text: $newProfileName) -+ .textFieldStyle(RoundedBorderTextFieldStyle()) -+ .padding() -+ .background( -+ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) -+ .fill(Color(.secondarySystemGroupedBackground)) -+ ) -+ } -+ .padding(.horizontal) -+ .alert(isPresented: $showAlert) { -+ Alert( -+ title: Text("Overwrite Existing Profile"), -+ message: Text("A profile with this name already exists. Would you like to overwrite it?"), -+ primaryButton: .destructive(Text("Overwrite")) { -+ self.viewModel.saveProfile(withName: self.newProfileName) -+ self.isPresented = false -+ }, -+ secondaryButton: .cancel() -+ ) -+ } -+ } -+ -+ var addButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ if viewModel.doesProfileExist(withName: self.newProfileName) { -+ self.showAlert = true -+ } else { -+ self.viewModel.saveProfile(withName: self.newProfileName) -+ self.isPresented = false -+ } -+ } -+ }, label: { -+ Text("Add") -+ } -+ ) -+ } -+ -+ var cancelButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ self.isPresented = false -+ } -+ }, label: { -+ Text("Cancel") -+ } -+ ) -+ } -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -new file mode 100644 -index 00000000..a956b6d5 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,423 @@ -+// -+// ProfilePreviewView.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-23. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+import HealthKit -+import LoopKit -+ -+enum ActiveAlert { -+ case load -+ case delete -+ case error -+ case loadEdited -+} -+ -+struct ProfilePreviewView: View { -+ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference -+ @ObservedObject var viewModel: ProfileViewModel -+ @Environment(\.dismissAction) var dismissAction -+ @Environment(\.presentationMode) var presentationMode -+ @Environment(\.appName) private var appName -+ @State private var profile: Profile -+ @State private var editingDelegate: ProfileEditingDelegate -+ @State private var therapySettingsViewModel: TherapySettingsViewModel -+ -+ @State private var showingAlert = false -+ @State private var activeAlert: ActiveAlert = .load -+ @State private var errorText: String? -+ @State private var newProfileName: String = "" -+ @State private var isRenamingProfile = false -+ @State private var shouldDismissSelf: Bool = false -+ -+ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } -+ -+ init(viewModel: ProfileViewModel, profile: Profile) { -+ self.viewModel = viewModel -+ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) -+ _profile = State(initialValue: profile) -+ _editingDelegate = State(initialValue: editingDelegate) -+ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( -+ therapySettings: viewModel.therapySettings(applying: profile), -+ delegate: editingDelegate)) -+ } -+ -+ var body: some View { -+ ZStack { -+ ScrollView { -+ VStack(alignment: .leading) { -+ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") -+ .font(.body) -+ .foregroundColor(.primary) -+ .padding([.leading, .trailing]) -+ .fixedSize(horizontal: false, vertical: true) -+ .textCase(nil) -+ -+ profileCardStack -+ -+ Button(action: { -+ activeAlert = .load -+ showingAlert = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Load") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.blue) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ Button(action: { -+ isRenamingProfile = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Rename") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.orange) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ Button(action: { -+ activeAlert = .delete -+ showingAlert = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Delete") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.red) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ } -+ } -+ .navigationBarHidden(false) -+ .background(Color(.systemGroupedBackground)) -+ .navigationTitle(Text(profile.name)) -+ .alert(isPresented: $showingAlert) { -+ switch activeAlert { -+ case .load: -+ return Alert( -+ title: Text("Load Profile"), -+ message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: load), -+ secondaryButton: .cancel() -+ ) -+ case .delete: -+ return Alert( -+ title: Text("Delete Profile"), -+ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), -+ primaryButton: .destructive(Text("Delete"), action: { -+ viewModel.removeProfile(profile: profile) -+ self.presentationMode.wrappedValue.dismiss() -+ }), -+ secondaryButton: .cancel() -+ ) -+ case .error: -+ return Alert( -+ title: Text("Error loading profile"), -+ message: Text(errorText ?? "Unknown error"), -+ dismissButton: .default(Text("OK")) -+ ) -+ case .loadEdited: -+ return Alert( -+ title: Text("Load Edited Profile?"), -+ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), -+ primaryButton: .default(Text("Load"), action: load), -+ secondaryButton: .cancel(Text("Later")) -+ ) -+ } -+ } -+ if isRenamingProfile { -+ // Some hacks here to mimic Alert behaviour. -+ // This function should be migrateed to Alert when iOS 16 is required. -+ Color.black.opacity(0.5) -+ .edgesIgnoringSafeArea(.all) -+ .onTapGesture { -+ isRenamingProfile = false -+ } -+ -+ RenameProfileEditor( -+ isPresented: $isRenamingProfile, -+ currentProfileName: profile.name, -+ viewModel: viewModel, -+ shouldDismissParent: $shouldDismissSelf -+ ) -+ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) -+ .offset(y: -22) -+ } -+ } -+ .onAppear { -+ // Runs again when an editor closes. -+ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } -+ profile = saved -+ // The editors show the saved values, also after a save that failed. -+ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) -+ if let error = editingDelegate.saveError { -+ editingDelegate.saveError = nil -+ presentAlertLater(.error, errorText: error.localizedDescription) -+ } else if editingDelegate.savedEdits { -+ editingDelegate.savedEdits = false -+ if viewModel.currentProfileName == profile.name { -+ presentAlertLater(.loadEdited) -+ } -+ } -+ } -+ .onChange(of: shouldDismissSelf) { shouldDismiss in -+ if shouldDismiss { -+ self.presentationMode.wrappedValue.dismiss() -+ } -+ } -+ } -+ -+ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ guard !showingAlert else { return } -+ self.errorText = errorText -+ activeAlert = alert -+ showingAlert = true -+ } -+ } -+ -+ private func load() { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ -+ private var profileCardStack: CardStack { -+ var cards: [Card] = [] -+ -+ cards.append(correctionRangeSection) -+ cards.append(carbRatioSection) -+ cards.append(basalRatesSection) -+ cards.append(insulinSensitivitiesSection) -+ -+ return CardStack(cards: cards) -+ } -+ -+ private var correctionRangeSection: Card { -+ card(for: .glucoseTargetRange) { -+ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { -+ SectionDivider() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleRangeItem(time: items[index].startTime, -+ range: items[index].value, -+ unit: glucoseUnit, -+ guardrail: .correctionRange) -+ } -+ } -+ } -+ } -+ -+ private var carbRatioSection: Card { -+ card(for: .carbRatio) { -+ SectionDivider() -+ let items = profile.carbRatioSchedule.items -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: .gramsPerUnit, -+ guardrail: .carbRatio) -+ } -+ } -+ } -+ -+ private var basalRatesSection: Card { -+ card(for: .basalRate) { -+ let items = profile.basalRateSchedule.items -+ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates -+ { -+ SectionDivider() -+ let total = profile.basalRateSchedule.total() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: .internationalUnitsPerHour, -+ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) -+ } -+ HStack { -+ Text("Total") -+ .bold() -+ .foregroundColor(.primary) -+ Spacer() -+ Text(String(format: "%.2f ",total)) -+ .foregroundColor(.primary) + -+ Text("U/day") -+ .foregroundColor(.secondary) -+ } -+ } -+ } -+ } -+ -+ private var insulinSensitivitiesSection: Card { -+ card(for: .insulinSensitivity) { -+ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items -+ { -+ SectionDivider() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: sensitivityUnit, -+ guardrail: .insulinSensitivity) -+ } -+ } -+ } -+ } -+} -+ -+ -+// MARK: Utilities -+extension ProfilePreviewView { -+ -+ private var glucoseUnit: HKUnit { -+ displayGlucosePreference.unit -+ } -+ -+ private var sensitivityUnit: HKUnit { -+ glucoseUnit.unitDivided(by: .internationalUnit()) -+ } -+ -+ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { -+ Card { -+ SectionWithTapToEdit( -+ // The basal editor needs the pump's supported rates. -+ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, -+ title: therapySetting.title, -+ descriptiveText: therapySetting.descriptiveText(appName: appName), -+ destination: { dismiss in -+ editor(for: therapySetting, dismiss: dismiss) -+ .environment(\.dismissAction, dismiss) -+ }, -+ content: content -+ ) -+ } -+ } -+ -+ /// The therapy settings editors, saving to this profile. -+ @ViewBuilder -+ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { -+ switch therapySetting { -+ case .glucoseTargetRange: -+ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .carbRatio: -+ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .basalRate: -+ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .insulinSensitivity: -+ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ default: -+ EmptyView() -+ } -+ } -+} -+ -+fileprivate struct SectionDivider: View { -+ var body: some View { -+ Divider() -+ .padding(.trailing, -16) -+ } -+} -+ -+fileprivate struct SettingsDivider: View { -+ var body: some View { -+ Divider() -+ .padding(.trailing, -8) -+ } -+} -+ -+/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. -+final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { -+ private let viewModel: ProfileViewModel -+ private let profileName: String -+ private let basalTimeZone: TimeZone -+ /// Set when edits were saved, so the view can offer to load the edited active profile. -+ var savedEdits = false -+ /// Set when saving edits to the profile file failed. -+ var saveError: Error? -+ -+ init(viewModel: ProfileViewModel, profile: Profile) { -+ self.viewModel = viewModel -+ self.profileName = profile.name -+ self.basalTimeZone = profile.basalRateSchedule.timeZone -+ } -+ -+ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { -+ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { -+ completion(.failure(ProfileValidationError.basalRateError)) -+ return -+ } -+ completion(.success(schedule)) -+ } -+ -+ func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { -+ completion(.success(deliveryLimits)) -+ } -+ -+ func saveCompletion(therapySettings: TherapySettings) { -+ do { -+ try viewModel.updateProfile(named: profileName, from: therapySettings) -+ savedEdits = true -+ } catch { -+ saveError = error -+ } -+ } -+ -+ func pumpSupportedIncrements() -> PumpSupportedIncrements? { -+ return viewModel.pumpSupportedIncrements() -+ } -+ -+ func updateCurrentProfileName() {} -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift -new file mode 100644 -index 00000000..f816492b ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift -@@ -0,0 +1,173 @@ -+// -+// ProfileView.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-04-22. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import AVFoundation -+import HealthKit -+import LoopKit -+import SwiftUI -+ -+public struct ProfileView: View { -+ @ObservedObject public var viewModel: ProfileViewModel -+ @Environment(\.dismissAction) var dismissAction -+ @State private var newProfileName: String = "" -+ @State private var isAddingNewProfile = false -+ @State private var selectedProfileIndex: Int? = nil -+ @State private var refreshID = UUID() -+ -+ enum ActiveAlert: String, Identifiable { -+ case update, info -+ -+ var id: String { -+ return self.rawValue -+ } -+ } -+ @State private var activeAlert: ActiveAlert? -+ -+ public init(viewModel: ProfileViewModel) { -+ self.viewModel = viewModel -+ } -+ -+ private var dismissButton: some View { -+ Button(action: dismissAction) { -+ Text(LocalizedString("Done", comment: "Text for dismiss button")) -+ .bold() -+ } -+ } -+ -+ public var body: some View { -+ ZStack { -+ NavigationView { -+ ConfigurationPageScrollView( -+ content: { -+ VStack(alignment: .leading) { -+ -+ if !viewModel.profiles.isEmpty { -+ List { -+ ForEach(viewModel.profiles.indices, id: \.self) { index in -+ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { -+ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { -+ HStack { -+ if viewModel.currentProfileName == viewModel.profiles[index].name { -+ Image(systemName: "checkmark") -+ .foregroundColor(Color.blue) -+ } else { -+ // Add indentation to align with the checkmark for the active profile -+ Image(systemName: "checkmark") -+ .opacity(0) // Invisible -+ } -+ -+ Text(viewModel.profiles[index].name) -+ } -+ } -+ } -+ } -+ .onMove(perform: moveProfile) -+ }.id(refreshID) -+ -+ } else { -+ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") -+ .font(.subheadline) -+ .foregroundColor(.secondary) -+ .padding([.leading, .trailing]) -+ .multilineTextAlignment(.leading) -+ .lineLimit(nil) -+ .textCase(nil) -+ } -+ } -+ }, -+ actionArea: { EmptyView() } // no action area in this case -+ ) -+ .navigationBarItems( -+ leading: HStack { -+ Button(action: { withAnimation { isAddingNewProfile = true } }) { -+ Image(systemName: "plus") -+ } -+ -+ Button(action: { activeAlert = .info }) { -+ Image(systemName: "info.circle") -+ .resizable() -+ .frame(width: 24, height: 24) -+ } -+ }, -+ trailing: dismissButton -+ ) -+ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) -+ } -+ .navigationBarHidden(false) -+ .background(Color(.systemGroupedBackground)) -+ .onAppear { -+ if viewModel.isCurrentProfileOutOfSync() { -+ activeAlert = .update -+ } -+ } -+ -+ .alert(item: $activeAlert) { alertType in -+ switch alertType { -+ case .update: -+ return Alert( -+ title: Text("Profile Outdated"), -+ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), -+ primaryButton: .default(Text("Save Current Settings")) { -+ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") -+ }, -+ secondaryButton: .cancel(Text("Keep Saved Profile")) -+ ) -+ case .info: -+ return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), -+ dismissButton: .default(Text("Got it!"))) -+ } -+ } -+ -+ if isAddingNewProfile { -+ DarkenedOverlay() -+ -+ NewProfileEditor( -+ isPresented: $isAddingNewProfile, -+ newProfileName: newProfileName, -+ viewModel: viewModel -+ ) -+ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) -+ } -+ } -+ } -+ -+ func moveProfile(from source: IndexSet, to destination: Int) { -+ viewModel.profiles.move(fromOffsets: source, toOffset: destination) -+ refreshID = UUID() -+ viewModel.updateProfilesOrder() -+ } -+} -+ -+struct ProfileView_Previews: PreviewProvider { -+ static let preview_glucoseScheduleItems = [ -+ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), -+ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), -+ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) -+ ] -+ -+ static let preview_therapySettings = TherapySettings( -+ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), -+ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), -+ workout: DoubleRange(99...111), -+ unit: .milligramsPerDeciliter), -+ maximumBolus: 4, -+ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), -+ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: HKUnit.internationalUnit()), dailyItems: []), -+ carbRatioSchedule: nil, -+ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) -+ -+ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] -+ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] -+ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] -+ -+ static var previews: some View { -+ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) -+ } -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift -new file mode 100644 -index 00000000..8fd26665 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift -@@ -0,0 +1,92 @@ -+// -+// RenameProfileEditor.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-09-07. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+ -+struct RenameProfileEditor: View { -+ @Binding var isPresented: Bool -+ @State var currentProfileName: String -+ @State var newProfileName: String = "" -+ var viewModel: ProfileViewModel -+ @State private var showAlert = false -+ @Binding var shouldDismissParent: Bool -+ -+ var body: some View { -+ VStack(spacing: 0) { -+ ModalHeaderButtonBar( -+ leading: { cancelButton }, -+ center: { -+ Text("Rename Profile") -+ .font(.subheadline) -+ -+ }, -+ trailing: { renameButton } -+ ) -+ .onAppear { -+ self.newProfileName = currentProfileName -+ } -+ -+ TextField("Profile Name", text: $newProfileName) -+ .textFieldStyle(RoundedBorderTextFieldStyle()) -+ .padding() -+ .background( -+ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) -+ .fill(Color(.secondarySystemGroupedBackground)) -+ ) -+ } -+ .padding(.horizontal) -+ .alert(isPresented: $showAlert) { -+ Alert( -+ title: Text("Overwrite Existing Profile"), -+ message: Text("A profile with this name already exists. Would you like to overwrite it?"), -+ primaryButton: .destructive(Text("Overwrite")) { -+ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) -+ self.shouldDismissParent = true -+ self.isPresented = false -+ }, -+ secondaryButton: .cancel() -+ ) -+ } -+ } -+ -+ var renameButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ guard newProfileName != currentProfileName else { -+ self.isPresented = false -+ return -+ } -+ -+ if viewModel.doesProfileExist(withName: self.newProfileName) { -+ self.showAlert = true -+ } else { -+ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) -+ self.shouldDismissParent = true -+ self.isPresented = false -+ } -+ } -+ }, label: { -+ Text("Rename") -+ } -+ ) -+ } -+ -+ var cancelButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ self.isPresented = false -+ } -+ }, label: { -+ Text("Cancel") -+ } -+ ) -+ } -+} -Submodule LoopOnboarding contains modified content -diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -index cc61f2d..6462e2c 100644 ---- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -+++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -@@ -379,6 +379,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { - maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount - ) - } -+ -+ func updateCurrentProfileName() { -+ } - } - - -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index 9f75373..18143ab 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -24,6 +24,10 @@ - A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; -@@ -165,6 +169,10 @@ - A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; - A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; -@@ -282,6 +290,7 @@ - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, - A934B6172358F49300949C8B /* StoredSettings.swift */, -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, - A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, - A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, -@@ -341,6 +350,7 @@ - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, - A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, - ); - path = NightscoutServiceKitTests; -@@ -383,6 +393,7 @@ - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, - ); - path = Notifications; - sourceTree = ""; -@@ -431,6 +442,7 @@ - A9AA6E5729EB06AA008FFA78 /* Actions */ = { - isa = PBXGroup; - children = ( -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -@@ -769,6 +781,9 @@ - C1398D2227C434FB00416AD6 /* Data.swift in Sources */, - A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, - A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, - C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, - ); -@@ -785,6 +800,7 @@ - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, - A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -new file mode 100644 -index 0000000..de73ca9 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -@@ -0,0 +1,104 @@ -+// -+// SavedProfile.swift -+// NightscoutServiceKit -+// -+ -+import CryptoKit -+import Foundation -+import HealthKit -+import LoopKit -+import NightscoutKit -+ -+/// A profile saved with the Profiles settings screen, read from the file it was saved to. -+struct SavedProfile: Decodable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ -+ private static var directory: URL { -+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") -+ } -+ -+ static func loadAll() -> [SavedProfile] { -+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] -+ return files -+ .filter { $0.pathExtension == "json" } -+ .compactMap { try? Data(contentsOf: $0) } -+ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } -+ } -+ -+ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. -+ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { -+ var store: [String: ProfileSet.Profile] = [:] -+ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. -+ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { -+ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) -+ } -+ return store -+ } -+ -+ /// True when this profile uploads with this fingerprint, in either glucose unit. -+ func matches(fingerprint: String) -> Bool { -+ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { -+ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint -+ } -+ } -+ -+ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { -+ guard let correctionRange = correctionRange.schedule(for: unit), -+ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) -+ else { -+ return nil -+ } -+ return ProfileSet.Profile( -+ timezone: basalRateSchedule.timeZone, -+ dia: .hours(6), -+ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, -+ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, -+ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) -+ } -+} -+ -+extension ProfileSet.Profile { -+ /// Followers compute the same value from the uploaded profile and send it with a switch command, -+ /// so Loop only activates the values the follower previewed. -+ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` -+ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. -+ var remoteFingerprint: String { -+ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { -+ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") -+ } -+ let canonical = [ -+ "basal=" + items(basal), -+ "carbratio=" + items(carbratio), -+ "sens=" + items(sensitivity), -+ "target_low=" + items(targetLow), -+ "target_high=" + items(targetHigh), -+ ].joined(separator: ";") -+ let digest = SHA256.hash(data: Data(canonical.utf8)) -+ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) -+ } -+} -+ -+enum RemoteProfileSwitchError: LocalizedError { -+ case profileChanged -+ case unavailable -+ case notActivated(String, Error) -+ -+ /// Followers read the profile name from this text to match the reply to their command. -+ var errorDescription: String? { -+ switch self { -+ case .profileChanged: -+ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") -+ case .unavailable: -+ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") -+ case .notActivated(let name, let error): -+ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index 637988b..7c780ea 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -160,7 +160,7 @@ extension StoredDosingDecision { - recommendedBolus: loopStatusRecommendedBolus, - enacted: automaticDoseDecision?.loopStatusEnacted, - failureReason: automaticDoseDecision?.loopStatusFailureReason, -- testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported]), -+ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]]), - overrideStatus: overrideStatus) - } - -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -index 2adadb9..dd7ac28 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -@@ -94,12 +94,21 @@ extension StoredSettings { - return nil - } - -+ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) -+ store["Default"] = profile -+ var defaultProfile = "Default" -+ -+ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { -+ defaultProfile = name -+ store[name] = profile -+ } -+ - return ProfileSet( - startDate: date, - units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), - enteredBy: "Loop", -- defaultProfile: "Default", -- store: ["Default": profile], -+ defaultProfile: defaultProfile, -+ store: store, - settings: loopSettings, - syncIdentifier: syncIdentifier.uuidString, - isAPNSProduction: Bundle.main.isAPNSProductionEnvironment) -diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index 169fb86..18ac515 100644 ---- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift -+++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -437,6 +437,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - success = true - message = "Override '\(overrideCommand.name)' enacted successfully" - -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ success = true -+ message = "Profile '\(profileSwitchCommand.name)' activated" -+ - case .cancelTemporaryOverride: - commandType = .cancelOverride - try await self.serviceDelegate?.cancelRemoteOverride() -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index 91ad56c..29d3563 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -9,6 +9,7 @@ - import Foundation - - public enum Action: Codable { -+ case profileSwitch(ProfileSwitchAction) - case temporaryScheduleOverride(OverrideAction) - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) -@@ -34,6 +35,8 @@ public enum Action: Codable { - return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") - case .temporaryScheduleOverride: - return LocalizedString("Override", comment: "The remote action name for Override") -+ case .profileSwitch: -+ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") - } - } - -@@ -54,6 +57,8 @@ public enum Action: Codable { - return "" - case .temporaryScheduleOverride(let overrideAction): - return overrideAction.name -+ case .profileSwitch(let profileSwitchAction): -+ return profileSwitchAction.name - } - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -new file mode 100644 -index 0000000..ce03ded ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -@@ -0,0 +1,17 @@ -+// -+// ProfileSwitchAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct ProfileSwitchAction: Codable { -+ -+ public let name: String -+ public let fingerprint: String -+ -+ public init(name: String, fingerprint: String) { -+ self.name = name -+ self.fingerprint = fingerprint -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 6092bc9..aabb47f 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -49,6 +49,7 @@ class RemoteNotificationResponseManager { - case carbsEdit = "carbs_edit" - case override = "override" - case cancelOverride = "cancel_override" -+ case profileSwitch = "profile_switch" - } - - func sendResponseNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -new file mode 100644 -index 0000000..487c620 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -0,0 +1,50 @@ -+// -+// ProfileSwitchRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Switches to a saved profile. `expiration` is required: decoding fails without it. -+public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { -+ -+ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. -+ static let commandKey = "profile-switch" -+ -+ public let profileName: String -+ public let fingerprint: String -+ public let remoteAddress: String -+ public let expirationDate: Date -+ public let sentAt: Date? -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ public var expiration: Date? { -+ return expirationDate -+ } -+ -+ enum CodingKeys: String, CodingKey { -+ case profileName = "profile-switch" -+ case fingerprint = "profile-switch-fingerprint" -+ case remoteAddress = "remote-address" -+ case expirationDate = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification[commandKey] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index 6f0524d..87f1e92 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -122,6 +122,8 @@ extension Dictionary { - return try OverrideRemoteNotification(dictionary: self) - } else if OverrideCancelRemoteNotification.includedInNotification(self) { - return try OverrideCancelRemoteNotification(dictionary: self) -+ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { -+ return try ProfileSwitchRemoteNotification(dictionary: self) - } else { - throw RemoteNotificationError.unhandledNotification(self) - } -diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..2fcec94 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -@@ -0,0 +1,71 @@ -+// -+// ProfileSwitchRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+import LoopKit -+import NightscoutKit -+@testable import NightscoutServiceKit -+ -+final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "sent-at": "2023-02-25T20:46:35.778Z", -+ "expiration": "2023-02-25T20:51:35.778Z", -+ "otp": "123456" -+ ] -+ -+ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) -+ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) -+ -+ XCTAssertEqual(profileSwitch.profileName, "Sport") -+ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") -+ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) -+ XCTAssertTrue(profileSwitch.otpValidationRequired()) -+ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { -+ return XCTFail("Expected a profile switch action") -+ } -+ XCTAssertEqual(action.name, "Sport") -+ } -+ -+ func testParseProfileSwitchNotification_MissingExpiration_Fails() { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "otp": "123456" -+ ] -+ -+ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) -+ } -+ -+ func testFingerprint_MatchesFollowerVector() { -+ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { -+ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } -+ } -+ let profile = ProfileSet.Profile( -+ timezone: TimeZone(secondsFromGMT: 3600)!, -+ dia: .hours(6), -+ sensitivity: items([(0, 45)]), -+ carbratio: items([(0, 10)]), -+ basal: items([(0, 0.85), (21600, 1.1)]), -+ targetLow: items([(0, 100)]), -+ targetHigh: items([(0, 110)]), -+ units: "mg/dL") -+ -+ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") -+ } -+} -+ -+private extension ISO8601DateFormatter { -+ static let fractional: ISO8601DateFormatter = { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ }() -+} -diff --git a/profiles.loopcustomization b/profiles.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/profiles/dev_profiles_basal_lock_remote_carb_edit_remote_window.patch b/profiles/dev_profiles_basal_lock_remote_carb_edit_remote_window.patch deleted file mode 100644 index 45fcceb..0000000 --- a/profiles/dev_profiles_basal_lock_remote_carb_edit_remote_window.patch +++ /dev/null @@ -1,2231 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index 8cc5714a..c253e88e 100644 ---- a/Loop/Loop/Managers/DeviceDataManager.swift -+++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -433,6 +433,7 @@ final class DeviceDataManager { - ) - - statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) -+ servicesManager.deviceDataManager = self - - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] - criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, -@@ -1725,6 +1726,10 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { - } - } - -+ func updateCurrentProfileName() { -+ loopManager.updateCurrentProfileName() -+ } -+ - func saveCompletion(therapySettings: TherapySettings) { - - loopManager.mutateSettings { settings in -diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift -index 0e1ee6c0..01fdf61f 100644 ---- a/Loop/Loop/Managers/LoopDataManager.swift -+++ b/Loop/Loop/Managers/LoopDataManager.swift -@@ -244,6 +244,10 @@ final class LoopDataManager { - lockedSettings.value - } - -+ func updateCurrentProfileName () { -+ notify(forChange: .preferences) -+ } -+ - func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { - var oldValue: LoopSettings! - let newValue = lockedSettings.mutate { settings in -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 3eb3154b..6cefc092 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -293,6 +293,38 @@ extension NotificationManager { - } - } - -+ static func sendRemoteProfileNotification(name: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfile", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteProfileFailureNotification(name: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfileFailure", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ private static func remoteProfileNotificationBody(name: String) -> String { -+ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) -+ } -+ - private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { - return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) - } -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 1e272bb2..d804456f 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -28,6 +28,7 @@ class ServicesManager { - - weak var servicesManagerDelegate: ServicesManagerDelegate? - weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? -+ weak var deviceDataManager: DeviceDataManager? - - private var services = [Service]() - -@@ -482,3 +483,58 @@ extension Service { - ] - } - } -+ -+// MARK: - RemoteProfileActionDelegate -+ -+extension ServicesManager: RemoteProfileActionDelegate { -+ @MainActor -+ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { -+ do { -+ guard let deviceDataManager else { -+ throw RemoteProfileActionError.unavailable -+ } -+ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) -+ guard deviceDataManager.pumpSupportedIncrements() != nil else { -+ throw RemoteProfileActionError.noPump -+ } -+ guard let profile = try viewModel.savedProfile(named: name) else { -+ throw RemoteProfileActionError.profileNotFound(name) -+ } -+ try profile.verify(with: verify) -+ if case .failure(let error) = viewModel.validateProfile(profile) { -+ throw error -+ } -+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ continuation.resume() -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ NotificationManager.sendRemoteProfileNotification(name: name) -+ } catch { -+ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) -+ throw error -+ } -+ } -+ -+ enum RemoteProfileActionError: LocalizedError { -+ case unavailable -+ case noPump -+ case profileNotFound(String) -+ -+ var errorDescription: String? { -+ switch self { -+ case .unavailable: -+ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") -+ case .noPump: -+ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") -+ case .profileNotFound(let name): -+ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) -+ } -+ } -+ } -+} -diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift -index f31763de..4df43b2c 100644 ---- a/Loop/Loop/Views/SettingsView.swift -+++ b/Loop/Loop/Views/SettingsView.swift -@@ -51,6 +51,7 @@ public struct SettingsView: View { - - case favoriteFoods - case preferences -+ case profiles - } - } - -@@ -125,6 +126,19 @@ public struct SettingsView: View { - FavoriteFoodsView() - case .preferences: - PreferencesView(viewModel: PreferencesViewModel(preferencesProvider: Preferences.shared)).environmentObject(displayGlucosePreference) -+ case .profiles: -+ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), -+ sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, -+ adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, -+ delegate: self.viewModel.therapySettingsViewModelDelegate)) -+ .environmentObject(displayGlucosePreference) -+ .environment(\.dismissAction, self.dismiss) -+ .environment(\.appName, self.appName) -+ .environment(\.chartColorPalette, .primary) -+ .environment(\.carbTintColor, self.carbTintColor) -+ .environment(\.glucoseTintColor, self.glucoseTintColor) -+ .environment(\.guidanceColors, self.guidanceColors) -+ .environment(\.insulinTintColor, self.insulinTintColor) - } - } - } -@@ -285,6 +299,11 @@ extension SettingsView { - descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) - } - -+ LargeButton(action: { sheet = .profiles }, -+ includeArrow: true, -+ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), -+ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), -+ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) - ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in - item.view - } -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj -index c36bfda3..5e9ec17d 100644 ---- a/LoopKit/LoopKit.xcodeproj/project.pbxproj -+++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj -@@ -873,6 +873,12 @@ - D1A0B000000000000000000C /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; - D1A0B000000000000000000D /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; - D1A0B0000000000000000081 /* PersistenceControllerMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */; }; -+ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; -+ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; -+ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; -+ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; -+ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; -+ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; - DD13BC702C3C71A70062313B /* BasalLockEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC6F2C3C71A70062313B /* BasalLockEditor.swift */; }; - DD13BC722C3C74CD0062313B /* PreferencesGuardrailWarning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC712C3C74CD0062313B /* PreferencesGuardrailWarning.swift */; }; - DD28B3712B80C074001044D4 /* PreferencesSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD28B3702B80C074001044D4 /* PreferencesSetting.swift */; }; -@@ -1820,6 +1826,12 @@ - D1A0B0000000000000000006 /* Modelv5ToModelv6.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = Modelv5ToModelv6.xcmappingmodel; sourceTree = ""; }; - D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelMappingv4v5Tov6Policy.swift; sourceTree = ""; }; - D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; -+ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; -+ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; -+ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; -+ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; -+ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; -+ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; - DD13BC6F2C3C71A70062313B /* BasalLockEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalLockEditor.swift; sourceTree = ""; }; - DD13BC712C3C74CD0062313B /* PreferencesGuardrailWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesGuardrailWarning.swift; sourceTree = ""; }; - DD28B3702B80C074001044D4 /* PreferencesSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesSetting.swift; sourceTree = ""; }; -@@ -3027,12 +3039,14 @@ - isa = PBXGroup; - children = ( - B455F3A325FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift */, -+ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, - B4B7C1BB2604E3FC007379F6 /* CorrectionRangeScheduleEditorViewModel.swift */, - B4D4C20C25F95A8700DA809D /* DisplayGlucosePreference.swift */, - B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, - B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, - 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, - DD545A5E2B80EFA900915F95 /* PreferencesViewModel.swift */, -+ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, - ); - path = ViewModels; - sourceTree = ""; -@@ -3230,6 +3244,10 @@ - E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, - 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, - 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, -+ DDAF746629F422C600719F0A /* ProfileView.swift */, -+ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, -+ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, -+ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, - ); - path = "Settings Editors"; - sourceTree = ""; -@@ -3940,6 +3958,7 @@ - E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, - B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, - 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, -+ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, - 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, - E9E5E56A24D5CCE800B5DFFE /* OverrideViewCell.swift in Sources */, - 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, -@@ -3949,6 +3968,7 @@ - B43D69D62A26642300DF0925 /* DemoPlaceHolderView.swift in Sources */, - E949E38F24B3711E00024DA0 /* InsulinModelInformationView.swift in Sources */, - 895FE08B22011F0C00FCF18A /* EmojiInputHeaderView.swift in Sources */, -+ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, - 895FE08322011F0C00FCF18A /* OverrideSelectionFooterView.swift in Sources */, - 89AF78C22447E353002B4FCC /* Splat.swift in Sources */, - 893C9F8C2447DBD900CD4185 /* CardBuilder.swift in Sources */, -@@ -4000,9 +4020,11 @@ - E9C58A6E24DA65E400487A17 /* HistoricalOverrideDetailView.swift in Sources */, - 895FE08222011F0C00FCF18A /* LabeledTextFieldTableViewCell.swift in Sources */, - B43DA44224D9CD8500CAFF4E /* Environment+Colors.swift in Sources */, -+ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, - 892A5DB22231E191008961AB /* LoadingTableViewCell.swift in Sources */, - B4D3D4AD25B8A94E0085BA0F /* DisplayGlucoseUnitObserver.swift in Sources */, - A9D3FF102A6C19CA000C891D /* ChartAxisValueDoubleLog.swift in Sources */, -+ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, - E96DCB5A24AF74AC007117BC /* SuspendThresholdEditor.swift in Sources */, - E96DCB5824AEF50F007117BC /* SuspendThresholdInformationView.swift in Sources */, - A9D3FF0C2A6C198C000C891D /* CollectionType.swift in Sources */, -@@ -4079,6 +4101,7 @@ - E93C86B624D08CAD0073089B /* InsulinSensitivityInformationView.swift in Sources */, - C18733AF29B9492300519CDF /* Collection.swift in Sources */, - 892A5DA22231E137008961AB /* HUDProvider.swift in Sources */, -+ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, - 898E6E6C224194060019E459 /* UIColor.swift in Sources */, - 43BA717D201EE7090058961E /* GlucoseRangeTableViewCell.swift in Sources */, - B455F3A425FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift in Sources */, -@@ -4106,6 +4129,7 @@ - 1D1065E9282DC54700026A70 /* VideoPlayView.swift in Sources */, - E99A132E2557548300D3F5B3 /* SegmentedGaugeBar.swift in Sources */, - B455F31825FBEC5F000ED456 /* SuspendThresholdEditorViewModel.swift in Sources */, -+ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, - 892A5DB32231E191008961AB /* LevelHUDView.swift in Sources */, - B41A60B223D1DBC700636320 /* UIFont.swift in Sources */, - B41A60AF23D1DB5B00636320 /* TableViewTitleLabel.swift in Sources */, -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 8fb254b6..858a5621 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -8,6 +8,12 @@ - - import Foundation - -+/// Switches to a saved therapy profile on a remote command. -+public protocol RemoteProfileActionDelegate: AnyObject { -+ /// `verify` receives the schedules about to be activated and throws to reject them. -+ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws -+} -+ - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride - func cancelRemoteOverride() async throws -> TemporaryScheduleOverride -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -new file mode 100644 -index 00000000..b571cf7b ---- /dev/null -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,466 @@ -+// -+// ProfileViewModel+FileManagement.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-19. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import LoopKit -+import HealthKit -+ -+public enum ProfileValidationError: Error, LocalizedError { -+ case fileLoadError -+ case unsupportedIncrement -+ case correctionRangeError -+ case insulinSensitivityError -+ case carbRatioError -+ case basalRateError -+ case maxBasalRateNotSet -+ -+ public var errorDescription: String? { -+ switch self { -+ case .fileLoadError: -+ return "Unable to load the profile file." -+ case .unsupportedIncrement: -+ return "The profile contains unsupported increments." -+ case .correctionRangeError: -+ return "Correction Range values are out of bounds." -+ case .insulinSensitivityError: -+ return "Insulin Sensitivity values are out of bounds." -+ case .carbRatioError: -+ return "Carb Ratio values are out of bounds." -+ case .basalRateError: -+ return "Basal Rate values are out of bounds." -+ case .maxBasalRateNotSet: -+ return "Maximum Basal Rate is not set." -+ } -+ } -+} -+ -+public enum LoadProfileResult { -+ case success -+ case failure(Error) -+} -+ -+// MARK: File management -+extension ProfileViewModel { -+ private func setCurrentProfile(name: String, syncChange: Bool = false) { -+ let sanitizedProfileName = sanitizeProfileName(name) -+ currentProfileName = sanitizedProfileName -+ if syncChange { -+ triggerProfileSync() -+ } -+ } -+ -+ private func clearCurrentProfile() { -+ currentProfileName = nil -+ triggerProfileSync() -+ } -+ -+ private func getCurrentProfileName() -> String? { -+ return currentProfileName -+ } -+ -+ private var profilesDirectory: URL { -+ let fileManager = FileManager.default -+ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] -+ return documentsDirectory.appendingPathComponent("LoopProfile") -+ } -+ -+ private func createProfileDirectoryIfNotExists() throws { -+ let fileManager = FileManager.default -+ var isDir : ObjCBool = false -+ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { -+ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) -+ } else if !isDir.boolValue { -+ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) -+ } -+ } -+ -+ private func getAllProfileFiles() throws -> [URL] { -+ let fileManager = FileManager.default -+ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) -+ return files.filter { $0.pathExtension == "json" } -+ } -+ -+ private func encodeProfile(_ profile: Profile) throws -> Data { -+ return try JSONEncoder().encode(profile) -+ } -+ -+ private func decodeProfile(from data: Data) throws -> Profile { -+ return try JSONDecoder().decode(Profile.self, from: data) -+ } -+ -+ public func sanitizeProfileName(_ name: String) -> String { -+ // Replace problematic characters -+ var sanitized = name.replacingOccurrences(of: ".", with: "_") -+ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") -+ -+ // Limit the name to 32 characters -+ if sanitized.count > 32 { -+ sanitized = String(sanitized.prefix(32)) -+ } -+ -+ return sanitized -+ } -+ -+ public func saveProfile(withName name: String) { -+ let sanitizedName = sanitizeProfileName(name) -+ -+ do { -+ try createProfileDirectoryIfNotExists() -+ -+ if let existingProfile = getProfileReference(withName: sanitizedName) { -+ removeProfile(profileReference: existingProfile) -+ } -+ -+ let profile = Profile( -+ name: sanitizedName, -+ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, -+ carbRatioSchedule: therapySettings.carbRatioSchedule!, -+ basalRateSchedule: therapySettings.basalRateSchedule!, -+ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, -+ sortOrder: -1 -+ ) -+ -+ let jsonData = try encodeProfile(profile) -+ -+ let dateFormatter = DateFormatter() -+ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" -+ let fileName = dateFormatter.string(from: Date()) + ".json" -+ let fileURL = profilesDirectory.appendingPathComponent(fileName) -+ -+ try jsonData.write(to: fileURL) -+ setCurrentProfile(name: sanitizedName, syncChange: true) -+ -+ self.loadProfiles() -+ } catch { -+ print("An error occurred while saving profile: \(error)") -+ } -+ } -+ -+ public func renameProfile(oldName: String, newName: String) { -+ let sanitizedProfileName = sanitizeProfileName(newName) -+ -+ if currentProfileName == oldName { -+ setCurrentProfile(name: sanitizedProfileName, syncChange: true) -+ } -+ -+ do { -+ guard let profileReference = getProfileReference(withName: oldName) else { -+ print("Profile with old name not found.") -+ return -+ } -+ -+ let profile = try getProfile(from: profileReference) -+ -+ let newProfile = Profile( -+ name: sanitizedProfileName, -+ correctionRange: profile.correctionRange, -+ carbRatioSchedule: profile.carbRatioSchedule, -+ basalRateSchedule: profile.basalRateSchedule, -+ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, -+ sortOrder: profile.sortOrder -+ ) -+ -+ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { -+ removeProfile(profileReference: existingProfile) -+ } -+ -+ let jsonData = try encodeProfile(newProfile) -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ -+ try jsonData.write(to: fileURL) -+ -+ self.loadProfiles() -+ triggerProfileSync() -+ } catch { -+ print("An error occurred while renaming profile: \(error)") -+ } -+ } -+ -+ public func updateProfilesOrder() { -+ for (index, profileReference) in profiles.enumerated() { -+ do { -+ var profile = try getProfile(from: profileReference) -+ let newProfile = Profile( -+ name: profile.name, -+ correctionRange: profile.correctionRange, -+ carbRatioSchedule: profile.carbRatioSchedule, -+ basalRateSchedule: profile.basalRateSchedule, -+ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, -+ sortOrder: index -+ ) -+ -+ let jsonData = try encodeProfile(newProfile) -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ try jsonData.write(to: fileURL) -+ -+ } catch { -+ print("An error occurred while updating profile order: \(error)") -+ } -+ } -+ loadProfiles() -+ } -+ -+ public func loadProfiles() { -+ do { -+ let profileFiles = try getAllProfileFiles() -+ -+ var newProfiles = [ProfileReference]() -+ for fileURL in profileFiles { -+ let data = try Data(contentsOf: fileURL) -+ let profile = try decodeProfile(from: data) -+ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) -+ newProfiles.append(profileRef) -+ } -+ -+ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } -+ -+ self.profiles = newProfiles -+ } catch { -+ print("An error occurred while loading profiles: \(error)") -+ } -+ } -+ -+ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { -+ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && -+ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && -+ therapySettings.basalRateSchedule == profile.basalRateSchedule && -+ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { -+ self.setCurrentProfile(name: profile.name, syncChange: true) -+ completion(.success) -+ } -+ else -+ { -+ do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in -+ switch result { -+ case .success(let syncedSchedule): -+ DispatchQueue.main.async { -+ let previousSettings = self.therapySettings -+ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! -+ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule -+ self.therapySettings.basalRateSchedule = syncedSchedule -+ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! -+ // One settings change, so the stored and uploaded settings never mix two profiles. -+ self.setCurrentProfile(name: profile.name, syncChange: false) -+ self.delegate?.saveCompletion(therapySettings: self.therapySettings) -+ if self.therapySettings == previousSettings { -+ // Nothing changed, so only the profile name needs to reach the uploaded settings. -+ self.triggerProfileSync() -+ } -+ print("New profile loaded") -+ completion(.success) -+ } -+ case .failure(let error): -+ print("An error occurred while syncing basal rates: \(error)") -+ completion(.failure(error)) -+ } -+ }) -+ } -+ } -+ } -+ -+ func getProfileReference(withName name: String) -> ProfileReference? { -+ let sanitizedInputName = sanitizeProfileName(name) -+ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) -+ } -+ -+ public func removeProfile(profile: Profile) { -+ guard let profileReference = getProfileReference(withName: profile.name) else { -+ print("No ProfileReference found for given Profile") -+ return -+ } -+ -+ removeProfile(profileReference: profileReference) -+ if getCurrentProfileName() == profile.name { -+ clearCurrentProfile() -+ } else { -+ triggerProfileSync() -+ } -+ } -+ -+ public func removeProfile(profileReference: ProfileReference) { -+ do { -+ let fileManager = FileManager.default -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ try fileManager.removeItem(at: fileURL) -+ loadProfiles() -+ } catch { -+ print("An error occurred while removing profile: \(error)") -+ } -+ } -+ -+ func doesProfileExist(withName name: String) -> Bool { -+ return profiles.contains(where: { $0.name == name }) -+ } -+ -+ public func getProfile(from profileReference: ProfileReference) throws -> Profile { -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ let data = try Data(contentsOf: fileURL) -+ let getProfile = try decodeProfile(from: data) -+ return getProfile -+ } -+ -+ /// The saved profile named `name`, or nil when there is none. -+ public func savedProfile(named name: String) throws -> Profile? { -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ return nil -+ } -+ return try getProfile(from: profileReference) -+ } -+ -+ public func validateProfile(_ profile: Profile) -> Result { -+ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { -+ return .failure(.fileLoadError) -+ } -+ -+ // Checking Correction Range Schedule -+ for item in profile.correctionRange.items { -+ let minValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) -+ let maxValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) -+ -+ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { -+ return .failure(.correctionRangeError) -+ } -+ } -+ -+ // Checking Insulin Sensitivity Schedule -+ for item in profile.insulinSensitivitySchedule.items { -+ let value = HKQuantity(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: .internationalUnit()), doubleValue: item.value) -+ -+ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { -+ return .failure(.insulinSensitivityError) -+ } -+ } -+ -+ // Checking Carb Ratio Schedule -+ for item in profile.carbRatioSchedule.items { -+ let value = HKQuantity(unit: .gramsPerUnit, doubleValue: item.value) -+ -+ if !Guardrail.carbRatio.absoluteBounds.contains(value) { -+ return .failure(.carbRatioError) -+ } -+ } -+ -+ // Checking Basal Rate Schedule -+ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { -+ for item in profile.basalRateSchedule.items { -+ let value = item.value -+ -+ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { -+ return .failure(.basalRateError) -+ } -+ } -+ } else { -+ return .failure(.maxBasalRateNotSet) -+ } -+ -+ return .success(()) -+ } -+ -+ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { -+ guard a.items.count == b.items.count else { -+ return false -+ } -+ -+ for (item1, item2) in zip(a.items, b.items) { -+ if item1.startTime != item2.startTime || -+ abs(item1.value.minValue - item2.value.minValue) > 0.01 || -+ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { -+ return false -+ } -+ } -+ -+ return true -+ } -+ -+ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { -+ guard a.items.count == b.items.count else { -+ return false -+ } -+ -+ for (item1, item2) in zip(a.items, b.items) { -+ if item1.startTime != item2.startTime || -+ abs(item1.value - item2.value) > 0.01 { -+ return false -+ } -+ } -+ -+ return true -+ } -+ -+ public func isCurrentProfileOutOfSync() -> Bool { -+ guard let currentProfileName = self.currentProfileName, -+ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), -+ let profile = try? getProfile(from: currentProfileReference), -+ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: HKUnit.milligramsPerDeciliter), -+ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: HKUnit.milligramsPerDeciliter) else { -+ return false // No current profile set or profile not found or schedules not available -+ } -+ -+ var isOutOfSync = false -+ -+ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: HKUnit.milligramsPerDeciliter)!) { -+ print("Glucose target range schedule differs") -+ isOutOfSync = true -+ } -+ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { -+ print("Carb ratio schedule differs") -+ isOutOfSync = true -+ } -+ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { -+ print("Basal rate schedule differs") -+ isOutOfSync = true -+ } -+ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: HKUnit.milligramsPerDeciliter)!) { -+ print("Insulin sensitivity schedule differs") -+ isOutOfSync = true -+ } -+ -+ return isOutOfSync -+ } -+} -+ -+extension Profile { -+ /// Passes the schedules this profile activates to `check`. -+ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { -+ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) -+ } -+} -+ -+extension ProfileViewModel { -+ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. -+ func therapySettings(applying profile: Profile) -> TherapySettings { -+ var settings = therapySettings -+ let unit = settings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) -+ settings.carbRatioSchedule = profile.carbRatioSchedule -+ settings.basalRateSchedule = profile.basalRateSchedule -+ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) -+ return settings -+ } -+ -+ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. -+ func updateProfile(named name: String, from settings: TherapySettings) throws { -+ loadProfiles() -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ throw ProfileValidationError.fileLoadError -+ } -+ let profile = try getProfile(from: profileReference) -+ let updatedProfile = Profile( -+ name: profile.name, -+ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, -+ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, -+ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, -+ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, -+ sortOrder: profile.sortOrder -+ ) -+ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) -+ loadProfiles() -+ triggerProfileSync() -+ } -+} -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift -new file mode 100644 -index 00000000..a48fa667 ---- /dev/null -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift -@@ -0,0 +1,86 @@ -+// -+// ProfileViewModel.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-04-22. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import Combine -+import LoopKit -+import HealthKit -+import SwiftUI -+ -+public class ProfileViewModel: ObservableObject { -+ -+ @Published public var therapySettings: TherapySettings -+ @Published public var profiles = [ProfileReference]() -+ @Published public var currentProfileName: String? { -+ didSet { -+ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") -+ } -+ } -+ -+ internal weak var delegate: TherapySettingsViewModelDelegate? -+ -+ public init(therapySettings: TherapySettings, -+ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, -+ sensitivityOverridesEnabled: Bool = false, -+ adultChildInsulinModelSelectionEnabled: Bool = false, -+ prescription: Prescription? = nil, -+ delegate: TherapySettingsViewModelDelegate? = nil) { -+ self.therapySettings = therapySettings -+ self.delegate = delegate -+ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") -+ self.loadProfiles() -+ } -+ -+ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { -+ return delegate?.pumpSupportedIncrements() -+ } -+} -+ -+public struct Profile: Codable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ var sortOrder: Int? -+} -+ -+public struct ProfileReference: Codable, Equatable { -+ var name: String -+ var fileName: String -+ var sortOrder: Int? -+} -+ -+ -+// MARK: Saving -+extension ProfileViewModel { -+ -+ public func saveCorrectionRange(range: GlucoseRangeSchedule) { -+ therapySettings.glucoseTargetRangeSchedule = range -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveBasalRates(basalRates: BasalRateSchedule) { -+ therapySettings.basalRateSchedule = basalRates -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { -+ therapySettings.carbRatioSchedule = carbRatioSchedule -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { -+ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func triggerProfileSync() { -+ delegate?.updateCurrentProfileName() -+ } -+} -diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -index 3650ef60..c5c1dc01 100644 ---- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -+++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { - func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) - func saveCompletion(therapySettings: TherapySettings) - func pumpSupportedIncrements() -> PumpSupportedIncrements? -+ func updateCurrentProfileName() - } - - public class TherapySettingsViewModel: ObservableObject { -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift -new file mode 100644 -index 00000000..446910e4 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift -@@ -0,0 +1,79 @@ -+// -+// NewProfileEditor.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-19. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+ -+struct NewProfileEditor: View { -+ @Binding var isPresented: Bool -+ @State var newProfileName: String -+ var viewModel: ProfileViewModel -+ @State private var showAlert = false -+ -+ var body: some View { -+ VStack(spacing: 0) { -+ ModalHeaderButtonBar( -+ leading: { cancelButton }, -+ center: { -+ Text("New Profile") -+ .font(.headline) -+ }, -+ trailing: { addButton } -+ ) -+ -+ TextField("Profile Name", text: $newProfileName) -+ .textFieldStyle(RoundedBorderTextFieldStyle()) -+ .padding() -+ .background( -+ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) -+ .fill(Color(.secondarySystemGroupedBackground)) -+ ) -+ } -+ .padding(.horizontal) -+ .alert(isPresented: $showAlert) { -+ Alert( -+ title: Text("Overwrite Existing Profile"), -+ message: Text("A profile with this name already exists. Would you like to overwrite it?"), -+ primaryButton: .destructive(Text("Overwrite")) { -+ self.viewModel.saveProfile(withName: self.newProfileName) -+ self.isPresented = false -+ }, -+ secondaryButton: .cancel() -+ ) -+ } -+ } -+ -+ var addButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ if viewModel.doesProfileExist(withName: self.newProfileName) { -+ self.showAlert = true -+ } else { -+ self.viewModel.saveProfile(withName: self.newProfileName) -+ self.isPresented = false -+ } -+ } -+ }, label: { -+ Text("Add") -+ } -+ ) -+ } -+ -+ var cancelButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ self.isPresented = false -+ } -+ }, label: { -+ Text("Cancel") -+ } -+ ) -+ } -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -new file mode 100644 -index 00000000..a956b6d5 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,423 @@ -+// -+// ProfilePreviewView.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-23. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+import HealthKit -+import LoopKit -+ -+enum ActiveAlert { -+ case load -+ case delete -+ case error -+ case loadEdited -+} -+ -+struct ProfilePreviewView: View { -+ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference -+ @ObservedObject var viewModel: ProfileViewModel -+ @Environment(\.dismissAction) var dismissAction -+ @Environment(\.presentationMode) var presentationMode -+ @Environment(\.appName) private var appName -+ @State private var profile: Profile -+ @State private var editingDelegate: ProfileEditingDelegate -+ @State private var therapySettingsViewModel: TherapySettingsViewModel -+ -+ @State private var showingAlert = false -+ @State private var activeAlert: ActiveAlert = .load -+ @State private var errorText: String? -+ @State private var newProfileName: String = "" -+ @State private var isRenamingProfile = false -+ @State private var shouldDismissSelf: Bool = false -+ -+ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } -+ -+ init(viewModel: ProfileViewModel, profile: Profile) { -+ self.viewModel = viewModel -+ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) -+ _profile = State(initialValue: profile) -+ _editingDelegate = State(initialValue: editingDelegate) -+ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( -+ therapySettings: viewModel.therapySettings(applying: profile), -+ delegate: editingDelegate)) -+ } -+ -+ var body: some View { -+ ZStack { -+ ScrollView { -+ VStack(alignment: .leading) { -+ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") -+ .font(.body) -+ .foregroundColor(.primary) -+ .padding([.leading, .trailing]) -+ .fixedSize(horizontal: false, vertical: true) -+ .textCase(nil) -+ -+ profileCardStack -+ -+ Button(action: { -+ activeAlert = .load -+ showingAlert = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Load") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.blue) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ Button(action: { -+ isRenamingProfile = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Rename") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.orange) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ Button(action: { -+ activeAlert = .delete -+ showingAlert = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Delete") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.red) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ } -+ } -+ .navigationBarHidden(false) -+ .background(Color(.systemGroupedBackground)) -+ .navigationTitle(Text(profile.name)) -+ .alert(isPresented: $showingAlert) { -+ switch activeAlert { -+ case .load: -+ return Alert( -+ title: Text("Load Profile"), -+ message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: load), -+ secondaryButton: .cancel() -+ ) -+ case .delete: -+ return Alert( -+ title: Text("Delete Profile"), -+ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), -+ primaryButton: .destructive(Text("Delete"), action: { -+ viewModel.removeProfile(profile: profile) -+ self.presentationMode.wrappedValue.dismiss() -+ }), -+ secondaryButton: .cancel() -+ ) -+ case .error: -+ return Alert( -+ title: Text("Error loading profile"), -+ message: Text(errorText ?? "Unknown error"), -+ dismissButton: .default(Text("OK")) -+ ) -+ case .loadEdited: -+ return Alert( -+ title: Text("Load Edited Profile?"), -+ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), -+ primaryButton: .default(Text("Load"), action: load), -+ secondaryButton: .cancel(Text("Later")) -+ ) -+ } -+ } -+ if isRenamingProfile { -+ // Some hacks here to mimic Alert behaviour. -+ // This function should be migrateed to Alert when iOS 16 is required. -+ Color.black.opacity(0.5) -+ .edgesIgnoringSafeArea(.all) -+ .onTapGesture { -+ isRenamingProfile = false -+ } -+ -+ RenameProfileEditor( -+ isPresented: $isRenamingProfile, -+ currentProfileName: profile.name, -+ viewModel: viewModel, -+ shouldDismissParent: $shouldDismissSelf -+ ) -+ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) -+ .offset(y: -22) -+ } -+ } -+ .onAppear { -+ // Runs again when an editor closes. -+ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } -+ profile = saved -+ // The editors show the saved values, also after a save that failed. -+ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) -+ if let error = editingDelegate.saveError { -+ editingDelegate.saveError = nil -+ presentAlertLater(.error, errorText: error.localizedDescription) -+ } else if editingDelegate.savedEdits { -+ editingDelegate.savedEdits = false -+ if viewModel.currentProfileName == profile.name { -+ presentAlertLater(.loadEdited) -+ } -+ } -+ } -+ .onChange(of: shouldDismissSelf) { shouldDismiss in -+ if shouldDismiss { -+ self.presentationMode.wrappedValue.dismiss() -+ } -+ } -+ } -+ -+ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ guard !showingAlert else { return } -+ self.errorText = errorText -+ activeAlert = alert -+ showingAlert = true -+ } -+ } -+ -+ private func load() { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ -+ private var profileCardStack: CardStack { -+ var cards: [Card] = [] -+ -+ cards.append(correctionRangeSection) -+ cards.append(carbRatioSection) -+ cards.append(basalRatesSection) -+ cards.append(insulinSensitivitiesSection) -+ -+ return CardStack(cards: cards) -+ } -+ -+ private var correctionRangeSection: Card { -+ card(for: .glucoseTargetRange) { -+ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { -+ SectionDivider() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleRangeItem(time: items[index].startTime, -+ range: items[index].value, -+ unit: glucoseUnit, -+ guardrail: .correctionRange) -+ } -+ } -+ } -+ } -+ -+ private var carbRatioSection: Card { -+ card(for: .carbRatio) { -+ SectionDivider() -+ let items = profile.carbRatioSchedule.items -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: .gramsPerUnit, -+ guardrail: .carbRatio) -+ } -+ } -+ } -+ -+ private var basalRatesSection: Card { -+ card(for: .basalRate) { -+ let items = profile.basalRateSchedule.items -+ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates -+ { -+ SectionDivider() -+ let total = profile.basalRateSchedule.total() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: .internationalUnitsPerHour, -+ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) -+ } -+ HStack { -+ Text("Total") -+ .bold() -+ .foregroundColor(.primary) -+ Spacer() -+ Text(String(format: "%.2f ",total)) -+ .foregroundColor(.primary) + -+ Text("U/day") -+ .foregroundColor(.secondary) -+ } -+ } -+ } -+ } -+ -+ private var insulinSensitivitiesSection: Card { -+ card(for: .insulinSensitivity) { -+ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items -+ { -+ SectionDivider() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: sensitivityUnit, -+ guardrail: .insulinSensitivity) -+ } -+ } -+ } -+ } -+} -+ -+ -+// MARK: Utilities -+extension ProfilePreviewView { -+ -+ private var glucoseUnit: HKUnit { -+ displayGlucosePreference.unit -+ } -+ -+ private var sensitivityUnit: HKUnit { -+ glucoseUnit.unitDivided(by: .internationalUnit()) -+ } -+ -+ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { -+ Card { -+ SectionWithTapToEdit( -+ // The basal editor needs the pump's supported rates. -+ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, -+ title: therapySetting.title, -+ descriptiveText: therapySetting.descriptiveText(appName: appName), -+ destination: { dismiss in -+ editor(for: therapySetting, dismiss: dismiss) -+ .environment(\.dismissAction, dismiss) -+ }, -+ content: content -+ ) -+ } -+ } -+ -+ /// The therapy settings editors, saving to this profile. -+ @ViewBuilder -+ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { -+ switch therapySetting { -+ case .glucoseTargetRange: -+ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .carbRatio: -+ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .basalRate: -+ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .insulinSensitivity: -+ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ default: -+ EmptyView() -+ } -+ } -+} -+ -+fileprivate struct SectionDivider: View { -+ var body: some View { -+ Divider() -+ .padding(.trailing, -16) -+ } -+} -+ -+fileprivate struct SettingsDivider: View { -+ var body: some View { -+ Divider() -+ .padding(.trailing, -8) -+ } -+} -+ -+/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. -+final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { -+ private let viewModel: ProfileViewModel -+ private let profileName: String -+ private let basalTimeZone: TimeZone -+ /// Set when edits were saved, so the view can offer to load the edited active profile. -+ var savedEdits = false -+ /// Set when saving edits to the profile file failed. -+ var saveError: Error? -+ -+ init(viewModel: ProfileViewModel, profile: Profile) { -+ self.viewModel = viewModel -+ self.profileName = profile.name -+ self.basalTimeZone = profile.basalRateSchedule.timeZone -+ } -+ -+ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { -+ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { -+ completion(.failure(ProfileValidationError.basalRateError)) -+ return -+ } -+ completion(.success(schedule)) -+ } -+ -+ func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { -+ completion(.success(deliveryLimits)) -+ } -+ -+ func saveCompletion(therapySettings: TherapySettings) { -+ do { -+ try viewModel.updateProfile(named: profileName, from: therapySettings) -+ savedEdits = true -+ } catch { -+ saveError = error -+ } -+ } -+ -+ func pumpSupportedIncrements() -> PumpSupportedIncrements? { -+ return viewModel.pumpSupportedIncrements() -+ } -+ -+ func updateCurrentProfileName() {} -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift -new file mode 100644 -index 00000000..f816492b ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift -@@ -0,0 +1,173 @@ -+// -+// ProfileView.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-04-22. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import AVFoundation -+import HealthKit -+import LoopKit -+import SwiftUI -+ -+public struct ProfileView: View { -+ @ObservedObject public var viewModel: ProfileViewModel -+ @Environment(\.dismissAction) var dismissAction -+ @State private var newProfileName: String = "" -+ @State private var isAddingNewProfile = false -+ @State private var selectedProfileIndex: Int? = nil -+ @State private var refreshID = UUID() -+ -+ enum ActiveAlert: String, Identifiable { -+ case update, info -+ -+ var id: String { -+ return self.rawValue -+ } -+ } -+ @State private var activeAlert: ActiveAlert? -+ -+ public init(viewModel: ProfileViewModel) { -+ self.viewModel = viewModel -+ } -+ -+ private var dismissButton: some View { -+ Button(action: dismissAction) { -+ Text(LocalizedString("Done", comment: "Text for dismiss button")) -+ .bold() -+ } -+ } -+ -+ public var body: some View { -+ ZStack { -+ NavigationView { -+ ConfigurationPageScrollView( -+ content: { -+ VStack(alignment: .leading) { -+ -+ if !viewModel.profiles.isEmpty { -+ List { -+ ForEach(viewModel.profiles.indices, id: \.self) { index in -+ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { -+ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { -+ HStack { -+ if viewModel.currentProfileName == viewModel.profiles[index].name { -+ Image(systemName: "checkmark") -+ .foregroundColor(Color.blue) -+ } else { -+ // Add indentation to align with the checkmark for the active profile -+ Image(systemName: "checkmark") -+ .opacity(0) // Invisible -+ } -+ -+ Text(viewModel.profiles[index].name) -+ } -+ } -+ } -+ } -+ .onMove(perform: moveProfile) -+ }.id(refreshID) -+ -+ } else { -+ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") -+ .font(.subheadline) -+ .foregroundColor(.secondary) -+ .padding([.leading, .trailing]) -+ .multilineTextAlignment(.leading) -+ .lineLimit(nil) -+ .textCase(nil) -+ } -+ } -+ }, -+ actionArea: { EmptyView() } // no action area in this case -+ ) -+ .navigationBarItems( -+ leading: HStack { -+ Button(action: { withAnimation { isAddingNewProfile = true } }) { -+ Image(systemName: "plus") -+ } -+ -+ Button(action: { activeAlert = .info }) { -+ Image(systemName: "info.circle") -+ .resizable() -+ .frame(width: 24, height: 24) -+ } -+ }, -+ trailing: dismissButton -+ ) -+ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) -+ } -+ .navigationBarHidden(false) -+ .background(Color(.systemGroupedBackground)) -+ .onAppear { -+ if viewModel.isCurrentProfileOutOfSync() { -+ activeAlert = .update -+ } -+ } -+ -+ .alert(item: $activeAlert) { alertType in -+ switch alertType { -+ case .update: -+ return Alert( -+ title: Text("Profile Outdated"), -+ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), -+ primaryButton: .default(Text("Save Current Settings")) { -+ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") -+ }, -+ secondaryButton: .cancel(Text("Keep Saved Profile")) -+ ) -+ case .info: -+ return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), -+ dismissButton: .default(Text("Got it!"))) -+ } -+ } -+ -+ if isAddingNewProfile { -+ DarkenedOverlay() -+ -+ NewProfileEditor( -+ isPresented: $isAddingNewProfile, -+ newProfileName: newProfileName, -+ viewModel: viewModel -+ ) -+ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) -+ } -+ } -+ } -+ -+ func moveProfile(from source: IndexSet, to destination: Int) { -+ viewModel.profiles.move(fromOffsets: source, toOffset: destination) -+ refreshID = UUID() -+ viewModel.updateProfilesOrder() -+ } -+} -+ -+struct ProfileView_Previews: PreviewProvider { -+ static let preview_glucoseScheduleItems = [ -+ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), -+ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), -+ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) -+ ] -+ -+ static let preview_therapySettings = TherapySettings( -+ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), -+ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), -+ workout: DoubleRange(99...111), -+ unit: .milligramsPerDeciliter), -+ maximumBolus: 4, -+ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), -+ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: HKUnit.internationalUnit()), dailyItems: []), -+ carbRatioSchedule: nil, -+ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) -+ -+ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] -+ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] -+ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] -+ -+ static var previews: some View { -+ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) -+ } -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift -new file mode 100644 -index 00000000..8fd26665 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift -@@ -0,0 +1,92 @@ -+// -+// RenameProfileEditor.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-09-07. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+ -+struct RenameProfileEditor: View { -+ @Binding var isPresented: Bool -+ @State var currentProfileName: String -+ @State var newProfileName: String = "" -+ var viewModel: ProfileViewModel -+ @State private var showAlert = false -+ @Binding var shouldDismissParent: Bool -+ -+ var body: some View { -+ VStack(spacing: 0) { -+ ModalHeaderButtonBar( -+ leading: { cancelButton }, -+ center: { -+ Text("Rename Profile") -+ .font(.subheadline) -+ -+ }, -+ trailing: { renameButton } -+ ) -+ .onAppear { -+ self.newProfileName = currentProfileName -+ } -+ -+ TextField("Profile Name", text: $newProfileName) -+ .textFieldStyle(RoundedBorderTextFieldStyle()) -+ .padding() -+ .background( -+ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) -+ .fill(Color(.secondarySystemGroupedBackground)) -+ ) -+ } -+ .padding(.horizontal) -+ .alert(isPresented: $showAlert) { -+ Alert( -+ title: Text("Overwrite Existing Profile"), -+ message: Text("A profile with this name already exists. Would you like to overwrite it?"), -+ primaryButton: .destructive(Text("Overwrite")) { -+ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) -+ self.shouldDismissParent = true -+ self.isPresented = false -+ }, -+ secondaryButton: .cancel() -+ ) -+ } -+ } -+ -+ var renameButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ guard newProfileName != currentProfileName else { -+ self.isPresented = false -+ return -+ } -+ -+ if viewModel.doesProfileExist(withName: self.newProfileName) { -+ self.showAlert = true -+ } else { -+ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) -+ self.shouldDismissParent = true -+ self.isPresented = false -+ } -+ } -+ }, label: { -+ Text("Rename") -+ } -+ ) -+ } -+ -+ var cancelButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ self.isPresented = false -+ } -+ }, label: { -+ Text("Cancel") -+ } -+ ) -+ } -+} -Submodule LoopOnboarding contains modified content -diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -index cc61f2d..6462e2c 100644 ---- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -+++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -@@ -379,6 +379,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { - maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount - ) - } -+ -+ func updateCurrentProfileName() { -+ } - } - - -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index 9f75373..18143ab 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -24,6 +24,10 @@ - A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; -@@ -165,6 +169,10 @@ - A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; - A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; -@@ -282,6 +290,7 @@ - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, - A934B6172358F49300949C8B /* StoredSettings.swift */, -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, - A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, - A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, -@@ -341,6 +350,7 @@ - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, - A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, - ); - path = NightscoutServiceKitTests; -@@ -383,6 +393,7 @@ - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, - ); - path = Notifications; - sourceTree = ""; -@@ -431,6 +442,7 @@ - A9AA6E5729EB06AA008FFA78 /* Actions */ = { - isa = PBXGroup; - children = ( -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -@@ -769,6 +781,9 @@ - C1398D2227C434FB00416AD6 /* Data.swift in Sources */, - A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, - A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, - C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, - ); -@@ -785,6 +800,7 @@ - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, - A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -new file mode 100644 -index 0000000..de73ca9 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -@@ -0,0 +1,104 @@ -+// -+// SavedProfile.swift -+// NightscoutServiceKit -+// -+ -+import CryptoKit -+import Foundation -+import HealthKit -+import LoopKit -+import NightscoutKit -+ -+/// A profile saved with the Profiles settings screen, read from the file it was saved to. -+struct SavedProfile: Decodable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ -+ private static var directory: URL { -+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") -+ } -+ -+ static func loadAll() -> [SavedProfile] { -+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] -+ return files -+ .filter { $0.pathExtension == "json" } -+ .compactMap { try? Data(contentsOf: $0) } -+ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } -+ } -+ -+ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. -+ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { -+ var store: [String: ProfileSet.Profile] = [:] -+ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. -+ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { -+ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) -+ } -+ return store -+ } -+ -+ /// True when this profile uploads with this fingerprint, in either glucose unit. -+ func matches(fingerprint: String) -> Bool { -+ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { -+ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint -+ } -+ } -+ -+ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { -+ guard let correctionRange = correctionRange.schedule(for: unit), -+ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) -+ else { -+ return nil -+ } -+ return ProfileSet.Profile( -+ timezone: basalRateSchedule.timeZone, -+ dia: .hours(6), -+ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, -+ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, -+ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) -+ } -+} -+ -+extension ProfileSet.Profile { -+ /// Followers compute the same value from the uploaded profile and send it with a switch command, -+ /// so Loop only activates the values the follower previewed. -+ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` -+ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. -+ var remoteFingerprint: String { -+ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { -+ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") -+ } -+ let canonical = [ -+ "basal=" + items(basal), -+ "carbratio=" + items(carbratio), -+ "sens=" + items(sensitivity), -+ "target_low=" + items(targetLow), -+ "target_high=" + items(targetHigh), -+ ].joined(separator: ";") -+ let digest = SHA256.hash(data: Data(canonical.utf8)) -+ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) -+ } -+} -+ -+enum RemoteProfileSwitchError: LocalizedError { -+ case profileChanged -+ case unavailable -+ case notActivated(String, Error) -+ -+ /// Followers read the profile name from this text to match the reply to their command. -+ var errorDescription: String? { -+ switch self { -+ case .profileChanged: -+ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") -+ case .unavailable: -+ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") -+ case .notActivated(let name, let error): -+ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index 637988b..7c780ea 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -160,7 +160,7 @@ extension StoredDosingDecision { - recommendedBolus: loopStatusRecommendedBolus, - enacted: automaticDoseDecision?.loopStatusEnacted, - failureReason: automaticDoseDecision?.loopStatusFailureReason, -- testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported]), -+ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]]), - overrideStatus: overrideStatus) - } - -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -index 2adadb9..dd7ac28 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -@@ -94,12 +94,21 @@ extension StoredSettings { - return nil - } - -+ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) -+ store["Default"] = profile -+ var defaultProfile = "Default" -+ -+ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { -+ defaultProfile = name -+ store[name] = profile -+ } -+ - return ProfileSet( - startDate: date, - units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), - enteredBy: "Loop", -- defaultProfile: "Default", -- store: ["Default": profile], -+ defaultProfile: defaultProfile, -+ store: store, - settings: loopSettings, - syncIdentifier: syncIdentifier.uuidString, - isAPNSProduction: Bundle.main.isAPNSProductionEnvironment) -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index 91ad56c..29d3563 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -9,6 +9,7 @@ - import Foundation - - public enum Action: Codable { -+ case profileSwitch(ProfileSwitchAction) - case temporaryScheduleOverride(OverrideAction) - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) -@@ -34,6 +35,8 @@ public enum Action: Codable { - return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") - case .temporaryScheduleOverride: - return LocalizedString("Override", comment: "The remote action name for Override") -+ case .profileSwitch: -+ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") - } - } - -@@ -54,6 +57,8 @@ public enum Action: Codable { - return "" - case .temporaryScheduleOverride(let overrideAction): - return overrideAction.name -+ case .profileSwitch(let profileSwitchAction): -+ return profileSwitchAction.name - } - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -new file mode 100644 -index 0000000..ce03ded ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -@@ -0,0 +1,17 @@ -+// -+// ProfileSwitchAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct ProfileSwitchAction: Codable { -+ -+ public let name: String -+ public let fingerprint: String -+ -+ public init(name: String, fingerprint: String) { -+ self.name = name -+ self.fingerprint = fingerprint -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 6092bc9..aabb47f 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -49,6 +49,7 @@ class RemoteNotificationResponseManager { - case carbsEdit = "carbs_edit" - case override = "override" - case cancelOverride = "cancel_override" -+ case profileSwitch = "profile_switch" - } - - func sendResponseNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -new file mode 100644 -index 0000000..ebcd6ef ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -0,0 +1,50 @@ -+// -+// ProfileSwitchRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Switches to a saved profile. `expiration` is required: decoding fails without it. -+public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { -+ -+ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. -+ static let commandKey = "profile-switch" -+ -+ public let profileName: String -+ public let fingerprint: String -+ public let remoteAddress: String -+ public let expirationDate: Date -+ public let sentAt: Date -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ public var expiration: Date? { -+ return expirationDate -+ } -+ -+ enum CodingKeys: String, CodingKey { -+ case profileName = "profile-switch" -+ case fingerprint = "profile-switch-fingerprint" -+ case remoteAddress = "remote-address" -+ case expirationDate = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification[commandKey] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index be43210..1ea8b96 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -118,6 +118,8 @@ extension Dictionary { - return try OverrideRemoteNotification(dictionary: self) - } else if OverrideCancelRemoteNotification.includedInNotification(self) { - return try OverrideCancelRemoteNotification(dictionary: self) -+ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { -+ return try ProfileSwitchRemoteNotification(dictionary: self) - } else { - throw RemoteNotificationError.unhandledNotification(self) - } -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -index 331ed5c..bff69c8 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -@@ -123,6 +123,27 @@ class RemoteCommandSourceV1: RemoteCommandSource { - status: .success(date: Date(), syncIdentifier: replacementEntry.syncIdentifier ?? "", completionMessage: nil), - notificationJSON: notification - ) -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ successMessage = "Profile '\(profileSwitchCommand.name)' activated" -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .success(date: Date(), syncIdentifier: "", completionMessage: nil), -+ notificationJSON: notification -+ ) - case .temporaryScheduleOverride(let overrideCommand): - commandType = .override - let override = try await serviceDelegate.enactRemoteOverride( -@@ -260,6 +281,10 @@ public class StoredRemoteNotification: NSObject, Codable { - self.init(notificationType: .carbsEdit(carbEditNotification), notificationJSONData: notificationJSONData) - } - -+ convenience init(profileSwitchNotification: ProfileSwitchRemoteNotification, notificationJSONData: Data) { -+ self.init(notificationType: .profileSwitch(profileSwitchNotification), notificationJSONData: notificationJSONData) -+ } -+ - convenience init(overrideNotification: OverrideRemoteNotification, notificationJSONData: Data) { - self.init(notificationType: .override(overrideNotification), notificationJSONData: notificationJSONData) - } -@@ -347,6 +372,8 @@ public class StoredRemoteNotification: NSObject, Codable { - return carbDeleteNotification - case let .carbsEdit(carbEditNotification): - return carbEditNotification -+ case let .profileSwitch(profileSwitchNotification): -+ return profileSwitchNotification - case let .override(overrideNotification): - return overrideNotification - case let .overrideCancel(overrideCancelNotification): -@@ -377,6 +404,7 @@ public class StoredRemoteNotification: NSObject, Codable { - case carbsEdit(CarbEditRemoteNotification) - case override(OverrideRemoteNotification) - case overrideCancel(OverrideCancelRemoteNotification) -+ case profileSwitch(ProfileSwitchRemoteNotification) - } - } - -@@ -433,6 +461,9 @@ actor RecentNotifications { - } else if let carbEditNotification = remoteNotification as? CarbEditRemoteNotification { - let storedNotification = StoredRemoteNotification(carbEditNotification: carbEditNotification, notificationJSONData: data) - try storeNotification(storedNotification) -+ } else if let profileSwitchNotification = remoteNotification as? ProfileSwitchRemoteNotification { -+ let storedNotification = StoredRemoteNotification(profileSwitchNotification: profileSwitchNotification, notificationJSONData: data) -+ try storeNotification(storedNotification) - } else if let overrideNotification = remoteNotification as? OverrideRemoteNotification { - let storedNotification = StoredRemoteNotification(overrideNotification: overrideNotification, notificationJSONData: data) - try storeNotification(storedNotification) -diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..2fcec94 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -@@ -0,0 +1,71 @@ -+// -+// ProfileSwitchRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+import LoopKit -+import NightscoutKit -+@testable import NightscoutServiceKit -+ -+final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "sent-at": "2023-02-25T20:46:35.778Z", -+ "expiration": "2023-02-25T20:51:35.778Z", -+ "otp": "123456" -+ ] -+ -+ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) -+ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) -+ -+ XCTAssertEqual(profileSwitch.profileName, "Sport") -+ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") -+ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) -+ XCTAssertTrue(profileSwitch.otpValidationRequired()) -+ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { -+ return XCTFail("Expected a profile switch action") -+ } -+ XCTAssertEqual(action.name, "Sport") -+ } -+ -+ func testParseProfileSwitchNotification_MissingExpiration_Fails() { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "otp": "123456" -+ ] -+ -+ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) -+ } -+ -+ func testFingerprint_MatchesFollowerVector() { -+ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { -+ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } -+ } -+ let profile = ProfileSet.Profile( -+ timezone: TimeZone(secondsFromGMT: 3600)!, -+ dia: .hours(6), -+ sensitivity: items([(0, 45)]), -+ carbratio: items([(0, 10)]), -+ basal: items([(0, 0.85), (21600, 1.1)]), -+ targetLow: items([(0, 100)]), -+ targetHigh: items([(0, 110)]), -+ units: "mg/dL") -+ -+ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") -+ } -+} -+ -+private extension ISO8601DateFormatter { -+ static let fractional: ISO8601DateFormatter = { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ }() -+} -diff --git a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -index 0bfd600..bb9423b 100644 ---- a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -+++ b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -@@ -60,6 +60,8 @@ extension StoredRemoteNotification { - return "Edit Carbs" - case .override: - return "Override" -+ case .profileSwitch: -+ return "Profile Switch" - case .overrideCancel: - return "Cancel Override" - } -@@ -84,6 +86,8 @@ extension StoredRemoteNotification { - return String(format: "%.0f g", notification.amount) - case .override(let notification): - return notification.name -+ case .profileSwitch(let notification): -+ return notification.profileName - case .overrideCancel: - return "" - } -diff --git a/profiles.loopcustomization b/profiles.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/profiles/dev_profiles_basal_lock_remote_window.patch b/profiles/dev_profiles_basal_lock_remote_window.patch deleted file mode 100644 index b348c61..0000000 --- a/profiles/dev_profiles_basal_lock_remote_window.patch +++ /dev/null @@ -1,2232 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index 8cc5714a..c253e88e 100644 ---- a/Loop/Loop/Managers/DeviceDataManager.swift -+++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -433,6 +433,7 @@ final class DeviceDataManager { - ) - - statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) -+ servicesManager.deviceDataManager = self - - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] - criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, -@@ -1725,6 +1726,10 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { - } - } - -+ func updateCurrentProfileName() { -+ loopManager.updateCurrentProfileName() -+ } -+ - func saveCompletion(therapySettings: TherapySettings) { - - loopManager.mutateSettings { settings in -diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift -index 1d295ff0..5be078cf 100644 ---- a/Loop/Loop/Managers/LoopDataManager.swift -+++ b/Loop/Loop/Managers/LoopDataManager.swift -@@ -244,6 +244,10 @@ final class LoopDataManager { - lockedSettings.value - } - -+ func updateCurrentProfileName () { -+ notify(forChange: .preferences) -+ } -+ - func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { - var oldValue: LoopSettings! - let newValue = lockedSettings.mutate { settings in -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 996d1470..17cff75d 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -263,6 +263,38 @@ extension NotificationManager { - } - } - -+ static func sendRemoteProfileNotification(name: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfile", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteProfileFailureNotification(name: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfileFailure", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ private static func remoteProfileNotificationBody(name: String) -> String { -+ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) -+ } -+ - private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { - return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) - } -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 0b498420..a9d9177d 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -28,6 +28,7 @@ class ServicesManager { - - weak var servicesManagerDelegate: ServicesManagerDelegate? - weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? -+ weak var deviceDataManager: DeviceDataManager? - - private var services = [Service]() - -@@ -447,3 +448,58 @@ extension Service { - ] - } - } -+ -+// MARK: - RemoteProfileActionDelegate -+ -+extension ServicesManager: RemoteProfileActionDelegate { -+ @MainActor -+ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { -+ do { -+ guard let deviceDataManager else { -+ throw RemoteProfileActionError.unavailable -+ } -+ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) -+ guard deviceDataManager.pumpSupportedIncrements() != nil else { -+ throw RemoteProfileActionError.noPump -+ } -+ guard let profile = try viewModel.savedProfile(named: name) else { -+ throw RemoteProfileActionError.profileNotFound(name) -+ } -+ try profile.verify(with: verify) -+ if case .failure(let error) = viewModel.validateProfile(profile) { -+ throw error -+ } -+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ continuation.resume() -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ NotificationManager.sendRemoteProfileNotification(name: name) -+ } catch { -+ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) -+ throw error -+ } -+ } -+ -+ enum RemoteProfileActionError: LocalizedError { -+ case unavailable -+ case noPump -+ case profileNotFound(String) -+ -+ var errorDescription: String? { -+ switch self { -+ case .unavailable: -+ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") -+ case .noPump: -+ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") -+ case .profileNotFound(let name): -+ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) -+ } -+ } -+ } -+} -diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift -index f31763de..4df43b2c 100644 ---- a/Loop/Loop/Views/SettingsView.swift -+++ b/Loop/Loop/Views/SettingsView.swift -@@ -51,6 +51,7 @@ public struct SettingsView: View { - - case favoriteFoods - case preferences -+ case profiles - } - } - -@@ -125,6 +126,19 @@ public struct SettingsView: View { - FavoriteFoodsView() - case .preferences: - PreferencesView(viewModel: PreferencesViewModel(preferencesProvider: Preferences.shared)).environmentObject(displayGlucosePreference) -+ case .profiles: -+ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), -+ sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, -+ adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, -+ delegate: self.viewModel.therapySettingsViewModelDelegate)) -+ .environmentObject(displayGlucosePreference) -+ .environment(\.dismissAction, self.dismiss) -+ .environment(\.appName, self.appName) -+ .environment(\.chartColorPalette, .primary) -+ .environment(\.carbTintColor, self.carbTintColor) -+ .environment(\.glucoseTintColor, self.glucoseTintColor) -+ .environment(\.guidanceColors, self.guidanceColors) -+ .environment(\.insulinTintColor, self.insulinTintColor) - } - } - } -@@ -285,6 +299,11 @@ extension SettingsView { - descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) - } - -+ LargeButton(action: { sheet = .profiles }, -+ includeArrow: true, -+ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), -+ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), -+ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) - ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in - item.view - } -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj -index c36bfda3..5e9ec17d 100644 ---- a/LoopKit/LoopKit.xcodeproj/project.pbxproj -+++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj -@@ -873,6 +873,12 @@ - D1A0B000000000000000000C /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; - D1A0B000000000000000000D /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; - D1A0B0000000000000000081 /* PersistenceControllerMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */; }; -+ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; -+ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; -+ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; -+ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; -+ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; -+ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; - DD13BC702C3C71A70062313B /* BasalLockEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC6F2C3C71A70062313B /* BasalLockEditor.swift */; }; - DD13BC722C3C74CD0062313B /* PreferencesGuardrailWarning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC712C3C74CD0062313B /* PreferencesGuardrailWarning.swift */; }; - DD28B3712B80C074001044D4 /* PreferencesSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD28B3702B80C074001044D4 /* PreferencesSetting.swift */; }; -@@ -1820,6 +1826,12 @@ - D1A0B0000000000000000006 /* Modelv5ToModelv6.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = Modelv5ToModelv6.xcmappingmodel; sourceTree = ""; }; - D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelMappingv4v5Tov6Policy.swift; sourceTree = ""; }; - D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; -+ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; -+ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; -+ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; -+ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; -+ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; -+ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; - DD13BC6F2C3C71A70062313B /* BasalLockEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalLockEditor.swift; sourceTree = ""; }; - DD13BC712C3C74CD0062313B /* PreferencesGuardrailWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesGuardrailWarning.swift; sourceTree = ""; }; - DD28B3702B80C074001044D4 /* PreferencesSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesSetting.swift; sourceTree = ""; }; -@@ -3027,12 +3039,14 @@ - isa = PBXGroup; - children = ( - B455F3A325FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift */, -+ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, - B4B7C1BB2604E3FC007379F6 /* CorrectionRangeScheduleEditorViewModel.swift */, - B4D4C20C25F95A8700DA809D /* DisplayGlucosePreference.swift */, - B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, - B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, - 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, - DD545A5E2B80EFA900915F95 /* PreferencesViewModel.swift */, -+ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, - ); - path = ViewModels; - sourceTree = ""; -@@ -3230,6 +3244,10 @@ - E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, - 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, - 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, -+ DDAF746629F422C600719F0A /* ProfileView.swift */, -+ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, -+ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, -+ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, - ); - path = "Settings Editors"; - sourceTree = ""; -@@ -3940,6 +3958,7 @@ - E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, - B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, - 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, -+ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, - 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, - E9E5E56A24D5CCE800B5DFFE /* OverrideViewCell.swift in Sources */, - 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, -@@ -3949,6 +3968,7 @@ - B43D69D62A26642300DF0925 /* DemoPlaceHolderView.swift in Sources */, - E949E38F24B3711E00024DA0 /* InsulinModelInformationView.swift in Sources */, - 895FE08B22011F0C00FCF18A /* EmojiInputHeaderView.swift in Sources */, -+ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, - 895FE08322011F0C00FCF18A /* OverrideSelectionFooterView.swift in Sources */, - 89AF78C22447E353002B4FCC /* Splat.swift in Sources */, - 893C9F8C2447DBD900CD4185 /* CardBuilder.swift in Sources */, -@@ -4000,9 +4020,11 @@ - E9C58A6E24DA65E400487A17 /* HistoricalOverrideDetailView.swift in Sources */, - 895FE08222011F0C00FCF18A /* LabeledTextFieldTableViewCell.swift in Sources */, - B43DA44224D9CD8500CAFF4E /* Environment+Colors.swift in Sources */, -+ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, - 892A5DB22231E191008961AB /* LoadingTableViewCell.swift in Sources */, - B4D3D4AD25B8A94E0085BA0F /* DisplayGlucoseUnitObserver.swift in Sources */, - A9D3FF102A6C19CA000C891D /* ChartAxisValueDoubleLog.swift in Sources */, -+ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, - E96DCB5A24AF74AC007117BC /* SuspendThresholdEditor.swift in Sources */, - E96DCB5824AEF50F007117BC /* SuspendThresholdInformationView.swift in Sources */, - A9D3FF0C2A6C198C000C891D /* CollectionType.swift in Sources */, -@@ -4079,6 +4101,7 @@ - E93C86B624D08CAD0073089B /* InsulinSensitivityInformationView.swift in Sources */, - C18733AF29B9492300519CDF /* Collection.swift in Sources */, - 892A5DA22231E137008961AB /* HUDProvider.swift in Sources */, -+ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, - 898E6E6C224194060019E459 /* UIColor.swift in Sources */, - 43BA717D201EE7090058961E /* GlucoseRangeTableViewCell.swift in Sources */, - B455F3A425FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift in Sources */, -@@ -4106,6 +4129,7 @@ - 1D1065E9282DC54700026A70 /* VideoPlayView.swift in Sources */, - E99A132E2557548300D3F5B3 /* SegmentedGaugeBar.swift in Sources */, - B455F31825FBEC5F000ED456 /* SuspendThresholdEditorViewModel.swift in Sources */, -+ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, - 892A5DB32231E191008961AB /* LevelHUDView.swift in Sources */, - B41A60B223D1DBC700636320 /* UIFont.swift in Sources */, - B41A60AF23D1DB5B00636320 /* TableViewTitleLabel.swift in Sources */, -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index e4279746..6f691598 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -8,6 +8,12 @@ - - import Foundation - -+/// Switches to a saved therapy profile on a remote command. -+public protocol RemoteProfileActionDelegate: AnyObject { -+ /// `verify` receives the schedules about to be activated and throws to reject them. -+ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws -+} -+ - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride - func cancelRemoteOverride() async throws -> TemporaryScheduleOverride -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -new file mode 100644 -index 00000000..b571cf7b ---- /dev/null -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,466 @@ -+// -+// ProfileViewModel+FileManagement.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-19. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import LoopKit -+import HealthKit -+ -+public enum ProfileValidationError: Error, LocalizedError { -+ case fileLoadError -+ case unsupportedIncrement -+ case correctionRangeError -+ case insulinSensitivityError -+ case carbRatioError -+ case basalRateError -+ case maxBasalRateNotSet -+ -+ public var errorDescription: String? { -+ switch self { -+ case .fileLoadError: -+ return "Unable to load the profile file." -+ case .unsupportedIncrement: -+ return "The profile contains unsupported increments." -+ case .correctionRangeError: -+ return "Correction Range values are out of bounds." -+ case .insulinSensitivityError: -+ return "Insulin Sensitivity values are out of bounds." -+ case .carbRatioError: -+ return "Carb Ratio values are out of bounds." -+ case .basalRateError: -+ return "Basal Rate values are out of bounds." -+ case .maxBasalRateNotSet: -+ return "Maximum Basal Rate is not set." -+ } -+ } -+} -+ -+public enum LoadProfileResult { -+ case success -+ case failure(Error) -+} -+ -+// MARK: File management -+extension ProfileViewModel { -+ private func setCurrentProfile(name: String, syncChange: Bool = false) { -+ let sanitizedProfileName = sanitizeProfileName(name) -+ currentProfileName = sanitizedProfileName -+ if syncChange { -+ triggerProfileSync() -+ } -+ } -+ -+ private func clearCurrentProfile() { -+ currentProfileName = nil -+ triggerProfileSync() -+ } -+ -+ private func getCurrentProfileName() -> String? { -+ return currentProfileName -+ } -+ -+ private var profilesDirectory: URL { -+ let fileManager = FileManager.default -+ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] -+ return documentsDirectory.appendingPathComponent("LoopProfile") -+ } -+ -+ private func createProfileDirectoryIfNotExists() throws { -+ let fileManager = FileManager.default -+ var isDir : ObjCBool = false -+ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { -+ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) -+ } else if !isDir.boolValue { -+ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) -+ } -+ } -+ -+ private func getAllProfileFiles() throws -> [URL] { -+ let fileManager = FileManager.default -+ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) -+ return files.filter { $0.pathExtension == "json" } -+ } -+ -+ private func encodeProfile(_ profile: Profile) throws -> Data { -+ return try JSONEncoder().encode(profile) -+ } -+ -+ private func decodeProfile(from data: Data) throws -> Profile { -+ return try JSONDecoder().decode(Profile.self, from: data) -+ } -+ -+ public func sanitizeProfileName(_ name: String) -> String { -+ // Replace problematic characters -+ var sanitized = name.replacingOccurrences(of: ".", with: "_") -+ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") -+ -+ // Limit the name to 32 characters -+ if sanitized.count > 32 { -+ sanitized = String(sanitized.prefix(32)) -+ } -+ -+ return sanitized -+ } -+ -+ public func saveProfile(withName name: String) { -+ let sanitizedName = sanitizeProfileName(name) -+ -+ do { -+ try createProfileDirectoryIfNotExists() -+ -+ if let existingProfile = getProfileReference(withName: sanitizedName) { -+ removeProfile(profileReference: existingProfile) -+ } -+ -+ let profile = Profile( -+ name: sanitizedName, -+ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, -+ carbRatioSchedule: therapySettings.carbRatioSchedule!, -+ basalRateSchedule: therapySettings.basalRateSchedule!, -+ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, -+ sortOrder: -1 -+ ) -+ -+ let jsonData = try encodeProfile(profile) -+ -+ let dateFormatter = DateFormatter() -+ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" -+ let fileName = dateFormatter.string(from: Date()) + ".json" -+ let fileURL = profilesDirectory.appendingPathComponent(fileName) -+ -+ try jsonData.write(to: fileURL) -+ setCurrentProfile(name: sanitizedName, syncChange: true) -+ -+ self.loadProfiles() -+ } catch { -+ print("An error occurred while saving profile: \(error)") -+ } -+ } -+ -+ public func renameProfile(oldName: String, newName: String) { -+ let sanitizedProfileName = sanitizeProfileName(newName) -+ -+ if currentProfileName == oldName { -+ setCurrentProfile(name: sanitizedProfileName, syncChange: true) -+ } -+ -+ do { -+ guard let profileReference = getProfileReference(withName: oldName) else { -+ print("Profile with old name not found.") -+ return -+ } -+ -+ let profile = try getProfile(from: profileReference) -+ -+ let newProfile = Profile( -+ name: sanitizedProfileName, -+ correctionRange: profile.correctionRange, -+ carbRatioSchedule: profile.carbRatioSchedule, -+ basalRateSchedule: profile.basalRateSchedule, -+ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, -+ sortOrder: profile.sortOrder -+ ) -+ -+ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { -+ removeProfile(profileReference: existingProfile) -+ } -+ -+ let jsonData = try encodeProfile(newProfile) -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ -+ try jsonData.write(to: fileURL) -+ -+ self.loadProfiles() -+ triggerProfileSync() -+ } catch { -+ print("An error occurred while renaming profile: \(error)") -+ } -+ } -+ -+ public func updateProfilesOrder() { -+ for (index, profileReference) in profiles.enumerated() { -+ do { -+ var profile = try getProfile(from: profileReference) -+ let newProfile = Profile( -+ name: profile.name, -+ correctionRange: profile.correctionRange, -+ carbRatioSchedule: profile.carbRatioSchedule, -+ basalRateSchedule: profile.basalRateSchedule, -+ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, -+ sortOrder: index -+ ) -+ -+ let jsonData = try encodeProfile(newProfile) -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ try jsonData.write(to: fileURL) -+ -+ } catch { -+ print("An error occurred while updating profile order: \(error)") -+ } -+ } -+ loadProfiles() -+ } -+ -+ public func loadProfiles() { -+ do { -+ let profileFiles = try getAllProfileFiles() -+ -+ var newProfiles = [ProfileReference]() -+ for fileURL in profileFiles { -+ let data = try Data(contentsOf: fileURL) -+ let profile = try decodeProfile(from: data) -+ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) -+ newProfiles.append(profileRef) -+ } -+ -+ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } -+ -+ self.profiles = newProfiles -+ } catch { -+ print("An error occurred while loading profiles: \(error)") -+ } -+ } -+ -+ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { -+ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && -+ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && -+ therapySettings.basalRateSchedule == profile.basalRateSchedule && -+ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { -+ self.setCurrentProfile(name: profile.name, syncChange: true) -+ completion(.success) -+ } -+ else -+ { -+ do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in -+ switch result { -+ case .success(let syncedSchedule): -+ DispatchQueue.main.async { -+ let previousSettings = self.therapySettings -+ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! -+ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule -+ self.therapySettings.basalRateSchedule = syncedSchedule -+ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! -+ // One settings change, so the stored and uploaded settings never mix two profiles. -+ self.setCurrentProfile(name: profile.name, syncChange: false) -+ self.delegate?.saveCompletion(therapySettings: self.therapySettings) -+ if self.therapySettings == previousSettings { -+ // Nothing changed, so only the profile name needs to reach the uploaded settings. -+ self.triggerProfileSync() -+ } -+ print("New profile loaded") -+ completion(.success) -+ } -+ case .failure(let error): -+ print("An error occurred while syncing basal rates: \(error)") -+ completion(.failure(error)) -+ } -+ }) -+ } -+ } -+ } -+ -+ func getProfileReference(withName name: String) -> ProfileReference? { -+ let sanitizedInputName = sanitizeProfileName(name) -+ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) -+ } -+ -+ public func removeProfile(profile: Profile) { -+ guard let profileReference = getProfileReference(withName: profile.name) else { -+ print("No ProfileReference found for given Profile") -+ return -+ } -+ -+ removeProfile(profileReference: profileReference) -+ if getCurrentProfileName() == profile.name { -+ clearCurrentProfile() -+ } else { -+ triggerProfileSync() -+ } -+ } -+ -+ public func removeProfile(profileReference: ProfileReference) { -+ do { -+ let fileManager = FileManager.default -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ try fileManager.removeItem(at: fileURL) -+ loadProfiles() -+ } catch { -+ print("An error occurred while removing profile: \(error)") -+ } -+ } -+ -+ func doesProfileExist(withName name: String) -> Bool { -+ return profiles.contains(where: { $0.name == name }) -+ } -+ -+ public func getProfile(from profileReference: ProfileReference) throws -> Profile { -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ let data = try Data(contentsOf: fileURL) -+ let getProfile = try decodeProfile(from: data) -+ return getProfile -+ } -+ -+ /// The saved profile named `name`, or nil when there is none. -+ public func savedProfile(named name: String) throws -> Profile? { -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ return nil -+ } -+ return try getProfile(from: profileReference) -+ } -+ -+ public func validateProfile(_ profile: Profile) -> Result { -+ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { -+ return .failure(.fileLoadError) -+ } -+ -+ // Checking Correction Range Schedule -+ for item in profile.correctionRange.items { -+ let minValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) -+ let maxValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) -+ -+ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { -+ return .failure(.correctionRangeError) -+ } -+ } -+ -+ // Checking Insulin Sensitivity Schedule -+ for item in profile.insulinSensitivitySchedule.items { -+ let value = HKQuantity(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: .internationalUnit()), doubleValue: item.value) -+ -+ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { -+ return .failure(.insulinSensitivityError) -+ } -+ } -+ -+ // Checking Carb Ratio Schedule -+ for item in profile.carbRatioSchedule.items { -+ let value = HKQuantity(unit: .gramsPerUnit, doubleValue: item.value) -+ -+ if !Guardrail.carbRatio.absoluteBounds.contains(value) { -+ return .failure(.carbRatioError) -+ } -+ } -+ -+ // Checking Basal Rate Schedule -+ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { -+ for item in profile.basalRateSchedule.items { -+ let value = item.value -+ -+ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { -+ return .failure(.basalRateError) -+ } -+ } -+ } else { -+ return .failure(.maxBasalRateNotSet) -+ } -+ -+ return .success(()) -+ } -+ -+ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { -+ guard a.items.count == b.items.count else { -+ return false -+ } -+ -+ for (item1, item2) in zip(a.items, b.items) { -+ if item1.startTime != item2.startTime || -+ abs(item1.value.minValue - item2.value.minValue) > 0.01 || -+ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { -+ return false -+ } -+ } -+ -+ return true -+ } -+ -+ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { -+ guard a.items.count == b.items.count else { -+ return false -+ } -+ -+ for (item1, item2) in zip(a.items, b.items) { -+ if item1.startTime != item2.startTime || -+ abs(item1.value - item2.value) > 0.01 { -+ return false -+ } -+ } -+ -+ return true -+ } -+ -+ public func isCurrentProfileOutOfSync() -> Bool { -+ guard let currentProfileName = self.currentProfileName, -+ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), -+ let profile = try? getProfile(from: currentProfileReference), -+ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: HKUnit.milligramsPerDeciliter), -+ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: HKUnit.milligramsPerDeciliter) else { -+ return false // No current profile set or profile not found or schedules not available -+ } -+ -+ var isOutOfSync = false -+ -+ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: HKUnit.milligramsPerDeciliter)!) { -+ print("Glucose target range schedule differs") -+ isOutOfSync = true -+ } -+ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { -+ print("Carb ratio schedule differs") -+ isOutOfSync = true -+ } -+ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { -+ print("Basal rate schedule differs") -+ isOutOfSync = true -+ } -+ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: HKUnit.milligramsPerDeciliter)!) { -+ print("Insulin sensitivity schedule differs") -+ isOutOfSync = true -+ } -+ -+ return isOutOfSync -+ } -+} -+ -+extension Profile { -+ /// Passes the schedules this profile activates to `check`. -+ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { -+ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) -+ } -+} -+ -+extension ProfileViewModel { -+ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. -+ func therapySettings(applying profile: Profile) -> TherapySettings { -+ var settings = therapySettings -+ let unit = settings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) -+ settings.carbRatioSchedule = profile.carbRatioSchedule -+ settings.basalRateSchedule = profile.basalRateSchedule -+ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) -+ return settings -+ } -+ -+ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. -+ func updateProfile(named name: String, from settings: TherapySettings) throws { -+ loadProfiles() -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ throw ProfileValidationError.fileLoadError -+ } -+ let profile = try getProfile(from: profileReference) -+ let updatedProfile = Profile( -+ name: profile.name, -+ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, -+ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, -+ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, -+ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, -+ sortOrder: profile.sortOrder -+ ) -+ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) -+ loadProfiles() -+ triggerProfileSync() -+ } -+} -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift -new file mode 100644 -index 00000000..a48fa667 ---- /dev/null -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift -@@ -0,0 +1,86 @@ -+// -+// ProfileViewModel.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-04-22. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import Combine -+import LoopKit -+import HealthKit -+import SwiftUI -+ -+public class ProfileViewModel: ObservableObject { -+ -+ @Published public var therapySettings: TherapySettings -+ @Published public var profiles = [ProfileReference]() -+ @Published public var currentProfileName: String? { -+ didSet { -+ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") -+ } -+ } -+ -+ internal weak var delegate: TherapySettingsViewModelDelegate? -+ -+ public init(therapySettings: TherapySettings, -+ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, -+ sensitivityOverridesEnabled: Bool = false, -+ adultChildInsulinModelSelectionEnabled: Bool = false, -+ prescription: Prescription? = nil, -+ delegate: TherapySettingsViewModelDelegate? = nil) { -+ self.therapySettings = therapySettings -+ self.delegate = delegate -+ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") -+ self.loadProfiles() -+ } -+ -+ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { -+ return delegate?.pumpSupportedIncrements() -+ } -+} -+ -+public struct Profile: Codable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ var sortOrder: Int? -+} -+ -+public struct ProfileReference: Codable, Equatable { -+ var name: String -+ var fileName: String -+ var sortOrder: Int? -+} -+ -+ -+// MARK: Saving -+extension ProfileViewModel { -+ -+ public func saveCorrectionRange(range: GlucoseRangeSchedule) { -+ therapySettings.glucoseTargetRangeSchedule = range -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveBasalRates(basalRates: BasalRateSchedule) { -+ therapySettings.basalRateSchedule = basalRates -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { -+ therapySettings.carbRatioSchedule = carbRatioSchedule -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { -+ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func triggerProfileSync() { -+ delegate?.updateCurrentProfileName() -+ } -+} -diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -index 3650ef60..c5c1dc01 100644 ---- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -+++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { - func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) - func saveCompletion(therapySettings: TherapySettings) - func pumpSupportedIncrements() -> PumpSupportedIncrements? -+ func updateCurrentProfileName() - } - - public class TherapySettingsViewModel: ObservableObject { -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift -new file mode 100644 -index 00000000..446910e4 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift -@@ -0,0 +1,79 @@ -+// -+// NewProfileEditor.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-19. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+ -+struct NewProfileEditor: View { -+ @Binding var isPresented: Bool -+ @State var newProfileName: String -+ var viewModel: ProfileViewModel -+ @State private var showAlert = false -+ -+ var body: some View { -+ VStack(spacing: 0) { -+ ModalHeaderButtonBar( -+ leading: { cancelButton }, -+ center: { -+ Text("New Profile") -+ .font(.headline) -+ }, -+ trailing: { addButton } -+ ) -+ -+ TextField("Profile Name", text: $newProfileName) -+ .textFieldStyle(RoundedBorderTextFieldStyle()) -+ .padding() -+ .background( -+ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) -+ .fill(Color(.secondarySystemGroupedBackground)) -+ ) -+ } -+ .padding(.horizontal) -+ .alert(isPresented: $showAlert) { -+ Alert( -+ title: Text("Overwrite Existing Profile"), -+ message: Text("A profile with this name already exists. Would you like to overwrite it?"), -+ primaryButton: .destructive(Text("Overwrite")) { -+ self.viewModel.saveProfile(withName: self.newProfileName) -+ self.isPresented = false -+ }, -+ secondaryButton: .cancel() -+ ) -+ } -+ } -+ -+ var addButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ if viewModel.doesProfileExist(withName: self.newProfileName) { -+ self.showAlert = true -+ } else { -+ self.viewModel.saveProfile(withName: self.newProfileName) -+ self.isPresented = false -+ } -+ } -+ }, label: { -+ Text("Add") -+ } -+ ) -+ } -+ -+ var cancelButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ self.isPresented = false -+ } -+ }, label: { -+ Text("Cancel") -+ } -+ ) -+ } -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -new file mode 100644 -index 00000000..a956b6d5 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,423 @@ -+// -+// ProfilePreviewView.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-23. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+import HealthKit -+import LoopKit -+ -+enum ActiveAlert { -+ case load -+ case delete -+ case error -+ case loadEdited -+} -+ -+struct ProfilePreviewView: View { -+ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference -+ @ObservedObject var viewModel: ProfileViewModel -+ @Environment(\.dismissAction) var dismissAction -+ @Environment(\.presentationMode) var presentationMode -+ @Environment(\.appName) private var appName -+ @State private var profile: Profile -+ @State private var editingDelegate: ProfileEditingDelegate -+ @State private var therapySettingsViewModel: TherapySettingsViewModel -+ -+ @State private var showingAlert = false -+ @State private var activeAlert: ActiveAlert = .load -+ @State private var errorText: String? -+ @State private var newProfileName: String = "" -+ @State private var isRenamingProfile = false -+ @State private var shouldDismissSelf: Bool = false -+ -+ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } -+ -+ init(viewModel: ProfileViewModel, profile: Profile) { -+ self.viewModel = viewModel -+ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) -+ _profile = State(initialValue: profile) -+ _editingDelegate = State(initialValue: editingDelegate) -+ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( -+ therapySettings: viewModel.therapySettings(applying: profile), -+ delegate: editingDelegate)) -+ } -+ -+ var body: some View { -+ ZStack { -+ ScrollView { -+ VStack(alignment: .leading) { -+ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") -+ .font(.body) -+ .foregroundColor(.primary) -+ .padding([.leading, .trailing]) -+ .fixedSize(horizontal: false, vertical: true) -+ .textCase(nil) -+ -+ profileCardStack -+ -+ Button(action: { -+ activeAlert = .load -+ showingAlert = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Load") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.blue) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ Button(action: { -+ isRenamingProfile = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Rename") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.orange) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ Button(action: { -+ activeAlert = .delete -+ showingAlert = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Delete") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.red) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ } -+ } -+ .navigationBarHidden(false) -+ .background(Color(.systemGroupedBackground)) -+ .navigationTitle(Text(profile.name)) -+ .alert(isPresented: $showingAlert) { -+ switch activeAlert { -+ case .load: -+ return Alert( -+ title: Text("Load Profile"), -+ message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: load), -+ secondaryButton: .cancel() -+ ) -+ case .delete: -+ return Alert( -+ title: Text("Delete Profile"), -+ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), -+ primaryButton: .destructive(Text("Delete"), action: { -+ viewModel.removeProfile(profile: profile) -+ self.presentationMode.wrappedValue.dismiss() -+ }), -+ secondaryButton: .cancel() -+ ) -+ case .error: -+ return Alert( -+ title: Text("Error loading profile"), -+ message: Text(errorText ?? "Unknown error"), -+ dismissButton: .default(Text("OK")) -+ ) -+ case .loadEdited: -+ return Alert( -+ title: Text("Load Edited Profile?"), -+ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), -+ primaryButton: .default(Text("Load"), action: load), -+ secondaryButton: .cancel(Text("Later")) -+ ) -+ } -+ } -+ if isRenamingProfile { -+ // Some hacks here to mimic Alert behaviour. -+ // This function should be migrateed to Alert when iOS 16 is required. -+ Color.black.opacity(0.5) -+ .edgesIgnoringSafeArea(.all) -+ .onTapGesture { -+ isRenamingProfile = false -+ } -+ -+ RenameProfileEditor( -+ isPresented: $isRenamingProfile, -+ currentProfileName: profile.name, -+ viewModel: viewModel, -+ shouldDismissParent: $shouldDismissSelf -+ ) -+ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) -+ .offset(y: -22) -+ } -+ } -+ .onAppear { -+ // Runs again when an editor closes. -+ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } -+ profile = saved -+ // The editors show the saved values, also after a save that failed. -+ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) -+ if let error = editingDelegate.saveError { -+ editingDelegate.saveError = nil -+ presentAlertLater(.error, errorText: error.localizedDescription) -+ } else if editingDelegate.savedEdits { -+ editingDelegate.savedEdits = false -+ if viewModel.currentProfileName == profile.name { -+ presentAlertLater(.loadEdited) -+ } -+ } -+ } -+ .onChange(of: shouldDismissSelf) { shouldDismiss in -+ if shouldDismiss { -+ self.presentationMode.wrappedValue.dismiss() -+ } -+ } -+ } -+ -+ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ guard !showingAlert else { return } -+ self.errorText = errorText -+ activeAlert = alert -+ showingAlert = true -+ } -+ } -+ -+ private func load() { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ -+ private var profileCardStack: CardStack { -+ var cards: [Card] = [] -+ -+ cards.append(correctionRangeSection) -+ cards.append(carbRatioSection) -+ cards.append(basalRatesSection) -+ cards.append(insulinSensitivitiesSection) -+ -+ return CardStack(cards: cards) -+ } -+ -+ private var correctionRangeSection: Card { -+ card(for: .glucoseTargetRange) { -+ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { -+ SectionDivider() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleRangeItem(time: items[index].startTime, -+ range: items[index].value, -+ unit: glucoseUnit, -+ guardrail: .correctionRange) -+ } -+ } -+ } -+ } -+ -+ private var carbRatioSection: Card { -+ card(for: .carbRatio) { -+ SectionDivider() -+ let items = profile.carbRatioSchedule.items -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: .gramsPerUnit, -+ guardrail: .carbRatio) -+ } -+ } -+ } -+ -+ private var basalRatesSection: Card { -+ card(for: .basalRate) { -+ let items = profile.basalRateSchedule.items -+ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates -+ { -+ SectionDivider() -+ let total = profile.basalRateSchedule.total() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: .internationalUnitsPerHour, -+ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) -+ } -+ HStack { -+ Text("Total") -+ .bold() -+ .foregroundColor(.primary) -+ Spacer() -+ Text(String(format: "%.2f ",total)) -+ .foregroundColor(.primary) + -+ Text("U/day") -+ .foregroundColor(.secondary) -+ } -+ } -+ } -+ } -+ -+ private var insulinSensitivitiesSection: Card { -+ card(for: .insulinSensitivity) { -+ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items -+ { -+ SectionDivider() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: sensitivityUnit, -+ guardrail: .insulinSensitivity) -+ } -+ } -+ } -+ } -+} -+ -+ -+// MARK: Utilities -+extension ProfilePreviewView { -+ -+ private var glucoseUnit: HKUnit { -+ displayGlucosePreference.unit -+ } -+ -+ private var sensitivityUnit: HKUnit { -+ glucoseUnit.unitDivided(by: .internationalUnit()) -+ } -+ -+ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { -+ Card { -+ SectionWithTapToEdit( -+ // The basal editor needs the pump's supported rates. -+ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, -+ title: therapySetting.title, -+ descriptiveText: therapySetting.descriptiveText(appName: appName), -+ destination: { dismiss in -+ editor(for: therapySetting, dismiss: dismiss) -+ .environment(\.dismissAction, dismiss) -+ }, -+ content: content -+ ) -+ } -+ } -+ -+ /// The therapy settings editors, saving to this profile. -+ @ViewBuilder -+ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { -+ switch therapySetting { -+ case .glucoseTargetRange: -+ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .carbRatio: -+ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .basalRate: -+ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .insulinSensitivity: -+ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ default: -+ EmptyView() -+ } -+ } -+} -+ -+fileprivate struct SectionDivider: View { -+ var body: some View { -+ Divider() -+ .padding(.trailing, -16) -+ } -+} -+ -+fileprivate struct SettingsDivider: View { -+ var body: some View { -+ Divider() -+ .padding(.trailing, -8) -+ } -+} -+ -+/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. -+final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { -+ private let viewModel: ProfileViewModel -+ private let profileName: String -+ private let basalTimeZone: TimeZone -+ /// Set when edits were saved, so the view can offer to load the edited active profile. -+ var savedEdits = false -+ /// Set when saving edits to the profile file failed. -+ var saveError: Error? -+ -+ init(viewModel: ProfileViewModel, profile: Profile) { -+ self.viewModel = viewModel -+ self.profileName = profile.name -+ self.basalTimeZone = profile.basalRateSchedule.timeZone -+ } -+ -+ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { -+ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { -+ completion(.failure(ProfileValidationError.basalRateError)) -+ return -+ } -+ completion(.success(schedule)) -+ } -+ -+ func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { -+ completion(.success(deliveryLimits)) -+ } -+ -+ func saveCompletion(therapySettings: TherapySettings) { -+ do { -+ try viewModel.updateProfile(named: profileName, from: therapySettings) -+ savedEdits = true -+ } catch { -+ saveError = error -+ } -+ } -+ -+ func pumpSupportedIncrements() -> PumpSupportedIncrements? { -+ return viewModel.pumpSupportedIncrements() -+ } -+ -+ func updateCurrentProfileName() {} -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift -new file mode 100644 -index 00000000..f816492b ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift -@@ -0,0 +1,173 @@ -+// -+// ProfileView.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-04-22. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import AVFoundation -+import HealthKit -+import LoopKit -+import SwiftUI -+ -+public struct ProfileView: View { -+ @ObservedObject public var viewModel: ProfileViewModel -+ @Environment(\.dismissAction) var dismissAction -+ @State private var newProfileName: String = "" -+ @State private var isAddingNewProfile = false -+ @State private var selectedProfileIndex: Int? = nil -+ @State private var refreshID = UUID() -+ -+ enum ActiveAlert: String, Identifiable { -+ case update, info -+ -+ var id: String { -+ return self.rawValue -+ } -+ } -+ @State private var activeAlert: ActiveAlert? -+ -+ public init(viewModel: ProfileViewModel) { -+ self.viewModel = viewModel -+ } -+ -+ private var dismissButton: some View { -+ Button(action: dismissAction) { -+ Text(LocalizedString("Done", comment: "Text for dismiss button")) -+ .bold() -+ } -+ } -+ -+ public var body: some View { -+ ZStack { -+ NavigationView { -+ ConfigurationPageScrollView( -+ content: { -+ VStack(alignment: .leading) { -+ -+ if !viewModel.profiles.isEmpty { -+ List { -+ ForEach(viewModel.profiles.indices, id: \.self) { index in -+ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { -+ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { -+ HStack { -+ if viewModel.currentProfileName == viewModel.profiles[index].name { -+ Image(systemName: "checkmark") -+ .foregroundColor(Color.blue) -+ } else { -+ // Add indentation to align with the checkmark for the active profile -+ Image(systemName: "checkmark") -+ .opacity(0) // Invisible -+ } -+ -+ Text(viewModel.profiles[index].name) -+ } -+ } -+ } -+ } -+ .onMove(perform: moveProfile) -+ }.id(refreshID) -+ -+ } else { -+ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") -+ .font(.subheadline) -+ .foregroundColor(.secondary) -+ .padding([.leading, .trailing]) -+ .multilineTextAlignment(.leading) -+ .lineLimit(nil) -+ .textCase(nil) -+ } -+ } -+ }, -+ actionArea: { EmptyView() } // no action area in this case -+ ) -+ .navigationBarItems( -+ leading: HStack { -+ Button(action: { withAnimation { isAddingNewProfile = true } }) { -+ Image(systemName: "plus") -+ } -+ -+ Button(action: { activeAlert = .info }) { -+ Image(systemName: "info.circle") -+ .resizable() -+ .frame(width: 24, height: 24) -+ } -+ }, -+ trailing: dismissButton -+ ) -+ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) -+ } -+ .navigationBarHidden(false) -+ .background(Color(.systemGroupedBackground)) -+ .onAppear { -+ if viewModel.isCurrentProfileOutOfSync() { -+ activeAlert = .update -+ } -+ } -+ -+ .alert(item: $activeAlert) { alertType in -+ switch alertType { -+ case .update: -+ return Alert( -+ title: Text("Profile Outdated"), -+ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), -+ primaryButton: .default(Text("Save Current Settings")) { -+ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") -+ }, -+ secondaryButton: .cancel(Text("Keep Saved Profile")) -+ ) -+ case .info: -+ return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), -+ dismissButton: .default(Text("Got it!"))) -+ } -+ } -+ -+ if isAddingNewProfile { -+ DarkenedOverlay() -+ -+ NewProfileEditor( -+ isPresented: $isAddingNewProfile, -+ newProfileName: newProfileName, -+ viewModel: viewModel -+ ) -+ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) -+ } -+ } -+ } -+ -+ func moveProfile(from source: IndexSet, to destination: Int) { -+ viewModel.profiles.move(fromOffsets: source, toOffset: destination) -+ refreshID = UUID() -+ viewModel.updateProfilesOrder() -+ } -+} -+ -+struct ProfileView_Previews: PreviewProvider { -+ static let preview_glucoseScheduleItems = [ -+ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), -+ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), -+ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) -+ ] -+ -+ static let preview_therapySettings = TherapySettings( -+ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), -+ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), -+ workout: DoubleRange(99...111), -+ unit: .milligramsPerDeciliter), -+ maximumBolus: 4, -+ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), -+ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: HKUnit.internationalUnit()), dailyItems: []), -+ carbRatioSchedule: nil, -+ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) -+ -+ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] -+ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] -+ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] -+ -+ static var previews: some View { -+ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) -+ } -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift -new file mode 100644 -index 00000000..8fd26665 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift -@@ -0,0 +1,92 @@ -+// -+// RenameProfileEditor.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-09-07. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+ -+struct RenameProfileEditor: View { -+ @Binding var isPresented: Bool -+ @State var currentProfileName: String -+ @State var newProfileName: String = "" -+ var viewModel: ProfileViewModel -+ @State private var showAlert = false -+ @Binding var shouldDismissParent: Bool -+ -+ var body: some View { -+ VStack(spacing: 0) { -+ ModalHeaderButtonBar( -+ leading: { cancelButton }, -+ center: { -+ Text("Rename Profile") -+ .font(.subheadline) -+ -+ }, -+ trailing: { renameButton } -+ ) -+ .onAppear { -+ self.newProfileName = currentProfileName -+ } -+ -+ TextField("Profile Name", text: $newProfileName) -+ .textFieldStyle(RoundedBorderTextFieldStyle()) -+ .padding() -+ .background( -+ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) -+ .fill(Color(.secondarySystemGroupedBackground)) -+ ) -+ } -+ .padding(.horizontal) -+ .alert(isPresented: $showAlert) { -+ Alert( -+ title: Text("Overwrite Existing Profile"), -+ message: Text("A profile with this name already exists. Would you like to overwrite it?"), -+ primaryButton: .destructive(Text("Overwrite")) { -+ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) -+ self.shouldDismissParent = true -+ self.isPresented = false -+ }, -+ secondaryButton: .cancel() -+ ) -+ } -+ } -+ -+ var renameButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ guard newProfileName != currentProfileName else { -+ self.isPresented = false -+ return -+ } -+ -+ if viewModel.doesProfileExist(withName: self.newProfileName) { -+ self.showAlert = true -+ } else { -+ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) -+ self.shouldDismissParent = true -+ self.isPresented = false -+ } -+ } -+ }, label: { -+ Text("Rename") -+ } -+ ) -+ } -+ -+ var cancelButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ self.isPresented = false -+ } -+ }, label: { -+ Text("Cancel") -+ } -+ ) -+ } -+} -Submodule LoopOnboarding contains modified content -diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -index cc61f2d..6462e2c 100644 ---- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -+++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -@@ -379,6 +379,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { - maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount - ) - } -+ -+ func updateCurrentProfileName() { -+ } - } - - -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index 69f7a26..14a7691 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -24,6 +24,10 @@ - A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; -@@ -158,6 +162,10 @@ - A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; - A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; -@@ -268,6 +276,7 @@ - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, - A934B6172358F49300949C8B /* StoredSettings.swift */, -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, - A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, - A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, -@@ -324,6 +333,7 @@ - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, - A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, - ); - path = NightscoutServiceKitTests; -@@ -364,6 +374,7 @@ - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, - ); - path = Notifications; - sourceTree = ""; -@@ -412,6 +423,7 @@ - A9AA6E5729EB06AA008FFA78 /* Actions */ = { - isa = PBXGroup; - children = ( -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -@@ -744,6 +756,9 @@ - C1398D2227C434FB00416AD6 /* Data.swift in Sources */, - A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, - A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, - C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, - ); -@@ -757,6 +772,7 @@ - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, - A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -new file mode 100644 -index 0000000..de73ca9 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -@@ -0,0 +1,104 @@ -+// -+// SavedProfile.swift -+// NightscoutServiceKit -+// -+ -+import CryptoKit -+import Foundation -+import HealthKit -+import LoopKit -+import NightscoutKit -+ -+/// A profile saved with the Profiles settings screen, read from the file it was saved to. -+struct SavedProfile: Decodable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ -+ private static var directory: URL { -+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") -+ } -+ -+ static func loadAll() -> [SavedProfile] { -+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] -+ return files -+ .filter { $0.pathExtension == "json" } -+ .compactMap { try? Data(contentsOf: $0) } -+ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } -+ } -+ -+ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. -+ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { -+ var store: [String: ProfileSet.Profile] = [:] -+ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. -+ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { -+ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) -+ } -+ return store -+ } -+ -+ /// True when this profile uploads with this fingerprint, in either glucose unit. -+ func matches(fingerprint: String) -> Bool { -+ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { -+ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint -+ } -+ } -+ -+ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { -+ guard let correctionRange = correctionRange.schedule(for: unit), -+ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) -+ else { -+ return nil -+ } -+ return ProfileSet.Profile( -+ timezone: basalRateSchedule.timeZone, -+ dia: .hours(6), -+ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, -+ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, -+ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) -+ } -+} -+ -+extension ProfileSet.Profile { -+ /// Followers compute the same value from the uploaded profile and send it with a switch command, -+ /// so Loop only activates the values the follower previewed. -+ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` -+ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. -+ var remoteFingerprint: String { -+ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { -+ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") -+ } -+ let canonical = [ -+ "basal=" + items(basal), -+ "carbratio=" + items(carbratio), -+ "sens=" + items(sensitivity), -+ "target_low=" + items(targetLow), -+ "target_high=" + items(targetHigh), -+ ].joined(separator: ";") -+ let digest = SHA256.hash(data: Data(canonical.utf8)) -+ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) -+ } -+} -+ -+enum RemoteProfileSwitchError: LocalizedError { -+ case profileChanged -+ case unavailable -+ case notActivated(String, Error) -+ -+ /// Followers read the profile name from this text to match the reply to their command. -+ var errorDescription: String? { -+ switch self { -+ case .profileChanged: -+ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") -+ case .unavailable: -+ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") -+ case .notActivated(let name, let error): -+ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index 4e1d49e..b66fb97 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -159,7 +159,8 @@ extension StoredDosingDecision { - automaticDoseRecommendation: loopStatusAutomaticDoseRecommendation, - recommendedBolus: loopStatusRecommendedBolus, - enacted: automaticDoseDecision?.loopStatusEnacted, -- failureReason: automaticDoseDecision?.loopStatusFailureReason), -+ failureReason: automaticDoseDecision?.loopStatusFailureReason, -+ testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]]), - overrideStatus: overrideStatus) - } - -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -index 2adadb9..dd7ac28 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -@@ -94,12 +94,21 @@ extension StoredSettings { - return nil - } - -+ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) -+ store["Default"] = profile -+ var defaultProfile = "Default" -+ -+ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { -+ defaultProfile = name -+ store[name] = profile -+ } -+ - return ProfileSet( - startDate: date, - units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), - enteredBy: "Loop", -- defaultProfile: "Default", -- store: ["Default": profile], -+ defaultProfile: defaultProfile, -+ store: store, - settings: loopSettings, - syncIdentifier: syncIdentifier.uuidString, - isAPNSProduction: Bundle.main.isAPNSProductionEnvironment) -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index a92b509..29313b6 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -9,6 +9,7 @@ - import Foundation - - public enum Action: Codable { -+ case profileSwitch(ProfileSwitchAction) - case temporaryScheduleOverride(OverrideAction) - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) -@@ -28,6 +29,8 @@ public enum Action: Codable { - return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") - case .temporaryScheduleOverride: - return LocalizedString("Override", comment: "The remote action name for Override") -+ case .profileSwitch: -+ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") - } - } - -@@ -43,6 +46,8 @@ public enum Action: Codable { - return "" - case .temporaryScheduleOverride(let overrideAction): - return overrideAction.name -+ case .profileSwitch(let profileSwitchAction): -+ return profileSwitchAction.name - } - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -new file mode 100644 -index 0000000..ce03ded ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -@@ -0,0 +1,17 @@ -+// -+// ProfileSwitchAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct ProfileSwitchAction: Codable { -+ -+ public let name: String -+ public let fingerprint: String -+ -+ public init(name: String, fingerprint: String) { -+ self.name = name -+ self.fingerprint = fingerprint -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 95274e4..4457f80 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -45,6 +45,7 @@ class RemoteNotificationResponseManager { - case carbs = "carbs" - case override = "override" - case cancelOverride = "cancel_override" -+ case profileSwitch = "profile_switch" - } - - func sendResponseNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -new file mode 100644 -index 0000000..ebcd6ef ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -0,0 +1,50 @@ -+// -+// ProfileSwitchRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Switches to a saved profile. `expiration` is required: decoding fails without it. -+public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { -+ -+ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. -+ static let commandKey = "profile-switch" -+ -+ public let profileName: String -+ public let fingerprint: String -+ public let remoteAddress: String -+ public let expirationDate: Date -+ public let sentAt: Date -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ public var expiration: Date? { -+ return expirationDate -+ } -+ -+ enum CodingKeys: String, CodingKey { -+ case profileName = "profile-switch" -+ case fingerprint = "profile-switch-fingerprint" -+ case remoteAddress = "remote-address" -+ case expirationDate = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification[commandKey] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index 24e63d7..a0f58a0 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -104,6 +104,8 @@ extension Dictionary { - return try OverrideRemoteNotification(dictionary: self) - } else if OverrideCancelRemoteNotification.includedInNotification(self) { - return try OverrideCancelRemoteNotification(dictionary: self) -+ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { -+ return try ProfileSwitchRemoteNotification(dictionary: self) - } else { - throw RemoteNotificationError.unhandledNotification(self) - } -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -index 84ff118..0912feb 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -@@ -96,6 +96,27 @@ class RemoteCommandSourceV1: RemoteCommandSource { - status: .success(date: Date(), syncIdentifier: carbEntry.syncIdentifier ?? "", completionMessage: nil), - notificationJSON: notification - ) -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ successMessage = "Profile '\(profileSwitchCommand.name)' activated" -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .success(date: Date(), syncIdentifier: "", completionMessage: nil), -+ notificationJSON: notification -+ ) - case .temporaryScheduleOverride(let overrideCommand): - commandType = .override - let override = try await serviceDelegate.enactRemoteOverride( -@@ -223,6 +244,10 @@ public class StoredRemoteNotification: NSObject, Codable { - self.init(notificationType: .carbs(carbNotification), notificationJSONData: notificationJSONData) - } - -+ convenience init(profileSwitchNotification: ProfileSwitchRemoteNotification, notificationJSONData: Data) { -+ self.init(notificationType: .profileSwitch(profileSwitchNotification), notificationJSONData: notificationJSONData) -+ } -+ - convenience init(overrideNotification: OverrideRemoteNotification, notificationJSONData: Data) { - self.init(notificationType: .override(overrideNotification), notificationJSONData: notificationJSONData) - } -@@ -306,6 +331,8 @@ public class StoredRemoteNotification: NSObject, Codable { - return bolusNotification - case let .carbs(carbNotification): - return carbNotification -+ case let .profileSwitch(profileSwitchNotification): -+ return profileSwitchNotification - case let .override(overrideNotification): - return overrideNotification - case let .overrideCancel(overrideCancelNotification): -@@ -334,6 +361,7 @@ public class StoredRemoteNotification: NSObject, Codable { - case carbs(CarbRemoteNotification) - case override(OverrideRemoteNotification) - case overrideCancel(OverrideCancelRemoteNotification) -+ case profileSwitch(ProfileSwitchRemoteNotification) - } - } - -@@ -384,6 +412,9 @@ actor RecentNotifications { - } else if let carbNotification = remoteNotification as? CarbRemoteNotification { - let storedNotification = StoredRemoteNotification(carbNotification: carbNotification, notificationJSONData: data) - try storeNotification(storedNotification) -+ } else if let profileSwitchNotification = remoteNotification as? ProfileSwitchRemoteNotification { -+ let storedNotification = StoredRemoteNotification(profileSwitchNotification: profileSwitchNotification, notificationJSONData: data) -+ try storeNotification(storedNotification) - } else if let overrideNotification = remoteNotification as? OverrideRemoteNotification { - let storedNotification = StoredRemoteNotification(overrideNotification: overrideNotification, notificationJSONData: data) - try storeNotification(storedNotification) -diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..2fcec94 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -@@ -0,0 +1,71 @@ -+// -+// ProfileSwitchRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+import LoopKit -+import NightscoutKit -+@testable import NightscoutServiceKit -+ -+final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "sent-at": "2023-02-25T20:46:35.778Z", -+ "expiration": "2023-02-25T20:51:35.778Z", -+ "otp": "123456" -+ ] -+ -+ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) -+ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) -+ -+ XCTAssertEqual(profileSwitch.profileName, "Sport") -+ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") -+ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) -+ XCTAssertTrue(profileSwitch.otpValidationRequired()) -+ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { -+ return XCTFail("Expected a profile switch action") -+ } -+ XCTAssertEqual(action.name, "Sport") -+ } -+ -+ func testParseProfileSwitchNotification_MissingExpiration_Fails() { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "otp": "123456" -+ ] -+ -+ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) -+ } -+ -+ func testFingerprint_MatchesFollowerVector() { -+ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { -+ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } -+ } -+ let profile = ProfileSet.Profile( -+ timezone: TimeZone(secondsFromGMT: 3600)!, -+ dia: .hours(6), -+ sensitivity: items([(0, 45)]), -+ carbratio: items([(0, 10)]), -+ basal: items([(0, 0.85), (21600, 1.1)]), -+ targetLow: items([(0, 100)]), -+ targetHigh: items([(0, 110)]), -+ units: "mg/dL") -+ -+ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") -+ } -+} -+ -+private extension ISO8601DateFormatter { -+ static let fractional: ISO8601DateFormatter = { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ }() -+} -diff --git a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -index 2a3fff3..4ce6ab7 100644 ---- a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -+++ b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -@@ -56,6 +56,8 @@ extension StoredRemoteNotification { - return "Carbs" - case .override: - return "Override" -+ case .profileSwitch: -+ return "Profile Switch" - case .overrideCancel: - return "Cancel Override" - } -@@ -76,6 +78,8 @@ extension StoredRemoteNotification { - return String(format: "%.0f g", notification.amount) - case .override(let notification): - return notification.name -+ case .profileSwitch(let notification): -+ return notification.profileName - case .overrideCancel: - return "" - } -diff --git a/profiles.loopcustomization b/profiles.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/profiles/dev_profiles_remote_carb_edit.patch b/profiles/dev_profiles_remote_carb_edit.patch deleted file mode 100644 index 17a9e4b..0000000 --- a/profiles/dev_profiles_remote_carb_edit.patch +++ /dev/null @@ -1,2167 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index cabca1e6..3b5f1de2 100644 ---- a/Loop/Loop/Managers/DeviceDataManager.swift -+++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -433,6 +433,7 @@ final class DeviceDataManager { - ) - - statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) -+ servicesManager.deviceDataManager = self - - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] - criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, -@@ -1673,6 +1674,10 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { - } - } - -+ func updateCurrentProfileName() { -+ loopManager.updateCurrentProfileName() -+ } -+ - func saveCompletion(therapySettings: TherapySettings) { - - loopManager.mutateSettings { settings in -diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift -index 8f497568..02359815 100644 ---- a/Loop/Loop/Managers/LoopDataManager.swift -+++ b/Loop/Loop/Managers/LoopDataManager.swift -@@ -244,6 +244,10 @@ final class LoopDataManager { - lockedSettings.value - } - -+ func updateCurrentProfileName () { -+ notify(forChange: .preferences) -+ } -+ - func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { - var oldValue: LoopSettings! - let newValue = lockedSettings.mutate { settings in -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 3eb3154b..6cefc092 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -293,6 +293,38 @@ extension NotificationManager { - } - } - -+ static func sendRemoteProfileNotification(name: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfile", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteProfileFailureNotification(name: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfileFailure", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ private static func remoteProfileNotificationBody(name: String) -> String { -+ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) -+ } -+ - private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { - return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) - } -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 3b74fd9d..7a8dc92f 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -28,6 +28,7 @@ class ServicesManager { - - weak var servicesManagerDelegate: ServicesManagerDelegate? - weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? -+ weak var deviceDataManager: DeviceDataManager? - - private var services = [Service]() - -@@ -456,3 +457,58 @@ extension Service { - ] - } - } -+ -+// MARK: - RemoteProfileActionDelegate -+ -+extension ServicesManager: RemoteProfileActionDelegate { -+ @MainActor -+ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { -+ do { -+ guard let deviceDataManager else { -+ throw RemoteProfileActionError.unavailable -+ } -+ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) -+ guard deviceDataManager.pumpSupportedIncrements() != nil else { -+ throw RemoteProfileActionError.noPump -+ } -+ guard let profile = try viewModel.savedProfile(named: name) else { -+ throw RemoteProfileActionError.profileNotFound(name) -+ } -+ try profile.verify(with: verify) -+ if case .failure(let error) = viewModel.validateProfile(profile) { -+ throw error -+ } -+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ continuation.resume() -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ NotificationManager.sendRemoteProfileNotification(name: name) -+ } catch { -+ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) -+ throw error -+ } -+ } -+ -+ enum RemoteProfileActionError: LocalizedError { -+ case unavailable -+ case noPump -+ case profileNotFound(String) -+ -+ var errorDescription: String? { -+ switch self { -+ case .unavailable: -+ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") -+ case .noPump: -+ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") -+ case .profileNotFound(let name): -+ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) -+ } -+ } -+ } -+} -diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift -index 9f9e3aa0..c1c9ea87 100644 ---- a/Loop/Loop/Views/SettingsView.swift -+++ b/Loop/Loop/Views/SettingsView.swift -@@ -50,6 +50,7 @@ public struct SettingsView: View { - } - - case favoriteFoods -+ case profiles - } - } - -@@ -119,6 +120,19 @@ public struct SettingsView: View { - switch sheet { - case .favoriteFoods: - FavoriteFoodsView() -+ case .profiles: -+ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), -+ sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, -+ adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, -+ delegate: self.viewModel.therapySettingsViewModelDelegate)) -+ .environmentObject(displayGlucosePreference) -+ .environment(\.dismissAction, self.dismiss) -+ .environment(\.appName, self.appName) -+ .environment(\.chartColorPalette, .primary) -+ .environment(\.carbTintColor, self.carbTintColor) -+ .environment(\.glucoseTintColor, self.glucoseTintColor) -+ .environment(\.guidanceColors, self.guidanceColors) -+ .environment(\.insulinTintColor, self.insulinTintColor) - } - } - } -@@ -279,6 +293,11 @@ extension SettingsView { - descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) - } - -+ LargeButton(action: { sheet = .profiles }, -+ includeArrow: true, -+ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), -+ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), -+ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) - ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in - item.view - } -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj -index e4423ce6..89045896 100644 ---- a/LoopKit/LoopKit.xcodeproj/project.pbxproj -+++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj -@@ -873,6 +873,12 @@ - D1A0B000000000000000000C /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; - D1A0B000000000000000000D /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; - D1A0B0000000000000000081 /* PersistenceControllerMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */; }; -+ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; -+ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; -+ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; -+ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; -+ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; -+ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; - E9077D2724ACD59F0066A88D /* InformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9077D2624ACD59F0066A88D /* InformationView.swift */; }; - E9077D2A24ACDE2C0066A88D /* CorrectionRangeInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9077D2924ACDE2C0066A88D /* CorrectionRangeInformationView.swift */; }; - E9086B2924B39EDC0062F5C8 /* ChartsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9086B2824B39EDC0062F5C8 /* ChartsTableViewController.swift */; }; -@@ -1813,6 +1819,12 @@ - D1A0B0000000000000000006 /* Modelv5ToModelv6.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = Modelv5ToModelv6.xcmappingmodel; sourceTree = ""; }; - D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelMappingv4v5Tov6Policy.swift; sourceTree = ""; }; - D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; -+ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; -+ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; -+ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; -+ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; -+ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; -+ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; - E9077D2624ACD59F0066A88D /* InformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InformationView.swift; sourceTree = ""; }; - E9077D2924ACDE2C0066A88D /* CorrectionRangeInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionRangeInformationView.swift; sourceTree = ""; }; - E9086B2824B39EDC0062F5C8 /* ChartsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartsTableViewController.swift; sourceTree = ""; }; -@@ -3009,11 +3021,13 @@ - isa = PBXGroup; - children = ( - B455F3A325FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift */, -+ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, - B4B7C1BB2604E3FC007379F6 /* CorrectionRangeScheduleEditorViewModel.swift */, - B4D4C20C25F95A8700DA809D /* DisplayGlucosePreference.swift */, - B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, - B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, - 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, -+ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, - ); - path = ViewModels; - sourceTree = ""; -@@ -3201,6 +3215,10 @@ - E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, - 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, - 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, -+ DDAF746629F422C600719F0A /* ProfileView.swift */, -+ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, -+ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, -+ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, - ); - path = "Settings Editors"; - sourceTree = ""; -@@ -3910,6 +3928,7 @@ - E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, - B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, - 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, -+ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, - 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, - E9E5E56A24D5CCE800B5DFFE /* OverrideViewCell.swift in Sources */, - 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, -@@ -3919,6 +3938,7 @@ - B43D69D62A26642300DF0925 /* DemoPlaceHolderView.swift in Sources */, - E949E38F24B3711E00024DA0 /* InsulinModelInformationView.swift in Sources */, - 895FE08B22011F0C00FCF18A /* EmojiInputHeaderView.swift in Sources */, -+ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, - 895FE08322011F0C00FCF18A /* OverrideSelectionFooterView.swift in Sources */, - 89AF78C22447E353002B4FCC /* Splat.swift in Sources */, - 893C9F8C2447DBD900CD4185 /* CardBuilder.swift in Sources */, -@@ -3969,9 +3989,11 @@ - E9C58A6E24DA65E400487A17 /* HistoricalOverrideDetailView.swift in Sources */, - 895FE08222011F0C00FCF18A /* LabeledTextFieldTableViewCell.swift in Sources */, - B43DA44224D9CD8500CAFF4E /* Environment+Colors.swift in Sources */, -+ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, - 892A5DB22231E191008961AB /* LoadingTableViewCell.swift in Sources */, - B4D3D4AD25B8A94E0085BA0F /* DisplayGlucoseUnitObserver.swift in Sources */, - A9D3FF102A6C19CA000C891D /* ChartAxisValueDoubleLog.swift in Sources */, -+ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, - E96DCB5A24AF74AC007117BC /* SuspendThresholdEditor.swift in Sources */, - E96DCB5824AEF50F007117BC /* SuspendThresholdInformationView.swift in Sources */, - A9D3FF0C2A6C198C000C891D /* CollectionType.swift in Sources */, -@@ -4047,6 +4069,7 @@ - E93C86B624D08CAD0073089B /* InsulinSensitivityInformationView.swift in Sources */, - C18733AF29B9492300519CDF /* Collection.swift in Sources */, - 892A5DA22231E137008961AB /* HUDProvider.swift in Sources */, -+ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, - 898E6E6C224194060019E459 /* UIColor.swift in Sources */, - 43BA717D201EE7090058961E /* GlucoseRangeTableViewCell.swift in Sources */, - B455F3A425FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift in Sources */, -@@ -4073,6 +4096,7 @@ - 1D1065E9282DC54700026A70 /* VideoPlayView.swift in Sources */, - E99A132E2557548300D3F5B3 /* SegmentedGaugeBar.swift in Sources */, - B455F31825FBEC5F000ED456 /* SuspendThresholdEditorViewModel.swift in Sources */, -+ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, - 892A5DB32231E191008961AB /* LevelHUDView.swift in Sources */, - B41A60B223D1DBC700636320 /* UIFont.swift in Sources */, - B41A60AF23D1DB5B00636320 /* TableViewTitleLabel.swift in Sources */, -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 4b2cecf3..56742c96 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -8,6 +8,12 @@ - - import Foundation - -+/// Switches to a saved therapy profile on a remote command. -+public protocol RemoteProfileActionDelegate: AnyObject { -+ /// `verify` receives the schedules about to be activated and throws to reject them. -+ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws -+} -+ - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws - func cancelRemoteOverride() async throws -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -new file mode 100644 -index 00000000..b571cf7b ---- /dev/null -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,466 @@ -+// -+// ProfileViewModel+FileManagement.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-19. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import LoopKit -+import HealthKit -+ -+public enum ProfileValidationError: Error, LocalizedError { -+ case fileLoadError -+ case unsupportedIncrement -+ case correctionRangeError -+ case insulinSensitivityError -+ case carbRatioError -+ case basalRateError -+ case maxBasalRateNotSet -+ -+ public var errorDescription: String? { -+ switch self { -+ case .fileLoadError: -+ return "Unable to load the profile file." -+ case .unsupportedIncrement: -+ return "The profile contains unsupported increments." -+ case .correctionRangeError: -+ return "Correction Range values are out of bounds." -+ case .insulinSensitivityError: -+ return "Insulin Sensitivity values are out of bounds." -+ case .carbRatioError: -+ return "Carb Ratio values are out of bounds." -+ case .basalRateError: -+ return "Basal Rate values are out of bounds." -+ case .maxBasalRateNotSet: -+ return "Maximum Basal Rate is not set." -+ } -+ } -+} -+ -+public enum LoadProfileResult { -+ case success -+ case failure(Error) -+} -+ -+// MARK: File management -+extension ProfileViewModel { -+ private func setCurrentProfile(name: String, syncChange: Bool = false) { -+ let sanitizedProfileName = sanitizeProfileName(name) -+ currentProfileName = sanitizedProfileName -+ if syncChange { -+ triggerProfileSync() -+ } -+ } -+ -+ private func clearCurrentProfile() { -+ currentProfileName = nil -+ triggerProfileSync() -+ } -+ -+ private func getCurrentProfileName() -> String? { -+ return currentProfileName -+ } -+ -+ private var profilesDirectory: URL { -+ let fileManager = FileManager.default -+ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] -+ return documentsDirectory.appendingPathComponent("LoopProfile") -+ } -+ -+ private func createProfileDirectoryIfNotExists() throws { -+ let fileManager = FileManager.default -+ var isDir : ObjCBool = false -+ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { -+ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) -+ } else if !isDir.boolValue { -+ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) -+ } -+ } -+ -+ private func getAllProfileFiles() throws -> [URL] { -+ let fileManager = FileManager.default -+ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) -+ return files.filter { $0.pathExtension == "json" } -+ } -+ -+ private func encodeProfile(_ profile: Profile) throws -> Data { -+ return try JSONEncoder().encode(profile) -+ } -+ -+ private func decodeProfile(from data: Data) throws -> Profile { -+ return try JSONDecoder().decode(Profile.self, from: data) -+ } -+ -+ public func sanitizeProfileName(_ name: String) -> String { -+ // Replace problematic characters -+ var sanitized = name.replacingOccurrences(of: ".", with: "_") -+ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") -+ -+ // Limit the name to 32 characters -+ if sanitized.count > 32 { -+ sanitized = String(sanitized.prefix(32)) -+ } -+ -+ return sanitized -+ } -+ -+ public func saveProfile(withName name: String) { -+ let sanitizedName = sanitizeProfileName(name) -+ -+ do { -+ try createProfileDirectoryIfNotExists() -+ -+ if let existingProfile = getProfileReference(withName: sanitizedName) { -+ removeProfile(profileReference: existingProfile) -+ } -+ -+ let profile = Profile( -+ name: sanitizedName, -+ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, -+ carbRatioSchedule: therapySettings.carbRatioSchedule!, -+ basalRateSchedule: therapySettings.basalRateSchedule!, -+ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, -+ sortOrder: -1 -+ ) -+ -+ let jsonData = try encodeProfile(profile) -+ -+ let dateFormatter = DateFormatter() -+ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" -+ let fileName = dateFormatter.string(from: Date()) + ".json" -+ let fileURL = profilesDirectory.appendingPathComponent(fileName) -+ -+ try jsonData.write(to: fileURL) -+ setCurrentProfile(name: sanitizedName, syncChange: true) -+ -+ self.loadProfiles() -+ } catch { -+ print("An error occurred while saving profile: \(error)") -+ } -+ } -+ -+ public func renameProfile(oldName: String, newName: String) { -+ let sanitizedProfileName = sanitizeProfileName(newName) -+ -+ if currentProfileName == oldName { -+ setCurrentProfile(name: sanitizedProfileName, syncChange: true) -+ } -+ -+ do { -+ guard let profileReference = getProfileReference(withName: oldName) else { -+ print("Profile with old name not found.") -+ return -+ } -+ -+ let profile = try getProfile(from: profileReference) -+ -+ let newProfile = Profile( -+ name: sanitizedProfileName, -+ correctionRange: profile.correctionRange, -+ carbRatioSchedule: profile.carbRatioSchedule, -+ basalRateSchedule: profile.basalRateSchedule, -+ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, -+ sortOrder: profile.sortOrder -+ ) -+ -+ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { -+ removeProfile(profileReference: existingProfile) -+ } -+ -+ let jsonData = try encodeProfile(newProfile) -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ -+ try jsonData.write(to: fileURL) -+ -+ self.loadProfiles() -+ triggerProfileSync() -+ } catch { -+ print("An error occurred while renaming profile: \(error)") -+ } -+ } -+ -+ public func updateProfilesOrder() { -+ for (index, profileReference) in profiles.enumerated() { -+ do { -+ var profile = try getProfile(from: profileReference) -+ let newProfile = Profile( -+ name: profile.name, -+ correctionRange: profile.correctionRange, -+ carbRatioSchedule: profile.carbRatioSchedule, -+ basalRateSchedule: profile.basalRateSchedule, -+ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, -+ sortOrder: index -+ ) -+ -+ let jsonData = try encodeProfile(newProfile) -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ try jsonData.write(to: fileURL) -+ -+ } catch { -+ print("An error occurred while updating profile order: \(error)") -+ } -+ } -+ loadProfiles() -+ } -+ -+ public func loadProfiles() { -+ do { -+ let profileFiles = try getAllProfileFiles() -+ -+ var newProfiles = [ProfileReference]() -+ for fileURL in profileFiles { -+ let data = try Data(contentsOf: fileURL) -+ let profile = try decodeProfile(from: data) -+ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) -+ newProfiles.append(profileRef) -+ } -+ -+ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } -+ -+ self.profiles = newProfiles -+ } catch { -+ print("An error occurred while loading profiles: \(error)") -+ } -+ } -+ -+ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { -+ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && -+ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && -+ therapySettings.basalRateSchedule == profile.basalRateSchedule && -+ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { -+ self.setCurrentProfile(name: profile.name, syncChange: true) -+ completion(.success) -+ } -+ else -+ { -+ do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in -+ switch result { -+ case .success(let syncedSchedule): -+ DispatchQueue.main.async { -+ let previousSettings = self.therapySettings -+ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! -+ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule -+ self.therapySettings.basalRateSchedule = syncedSchedule -+ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! -+ // One settings change, so the stored and uploaded settings never mix two profiles. -+ self.setCurrentProfile(name: profile.name, syncChange: false) -+ self.delegate?.saveCompletion(therapySettings: self.therapySettings) -+ if self.therapySettings == previousSettings { -+ // Nothing changed, so only the profile name needs to reach the uploaded settings. -+ self.triggerProfileSync() -+ } -+ print("New profile loaded") -+ completion(.success) -+ } -+ case .failure(let error): -+ print("An error occurred while syncing basal rates: \(error)") -+ completion(.failure(error)) -+ } -+ }) -+ } -+ } -+ } -+ -+ func getProfileReference(withName name: String) -> ProfileReference? { -+ let sanitizedInputName = sanitizeProfileName(name) -+ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) -+ } -+ -+ public func removeProfile(profile: Profile) { -+ guard let profileReference = getProfileReference(withName: profile.name) else { -+ print("No ProfileReference found for given Profile") -+ return -+ } -+ -+ removeProfile(profileReference: profileReference) -+ if getCurrentProfileName() == profile.name { -+ clearCurrentProfile() -+ } else { -+ triggerProfileSync() -+ } -+ } -+ -+ public func removeProfile(profileReference: ProfileReference) { -+ do { -+ let fileManager = FileManager.default -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ try fileManager.removeItem(at: fileURL) -+ loadProfiles() -+ } catch { -+ print("An error occurred while removing profile: \(error)") -+ } -+ } -+ -+ func doesProfileExist(withName name: String) -> Bool { -+ return profiles.contains(where: { $0.name == name }) -+ } -+ -+ public func getProfile(from profileReference: ProfileReference) throws -> Profile { -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ let data = try Data(contentsOf: fileURL) -+ let getProfile = try decodeProfile(from: data) -+ return getProfile -+ } -+ -+ /// The saved profile named `name`, or nil when there is none. -+ public func savedProfile(named name: String) throws -> Profile? { -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ return nil -+ } -+ return try getProfile(from: profileReference) -+ } -+ -+ public func validateProfile(_ profile: Profile) -> Result { -+ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { -+ return .failure(.fileLoadError) -+ } -+ -+ // Checking Correction Range Schedule -+ for item in profile.correctionRange.items { -+ let minValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) -+ let maxValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) -+ -+ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { -+ return .failure(.correctionRangeError) -+ } -+ } -+ -+ // Checking Insulin Sensitivity Schedule -+ for item in profile.insulinSensitivitySchedule.items { -+ let value = HKQuantity(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: .internationalUnit()), doubleValue: item.value) -+ -+ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { -+ return .failure(.insulinSensitivityError) -+ } -+ } -+ -+ // Checking Carb Ratio Schedule -+ for item in profile.carbRatioSchedule.items { -+ let value = HKQuantity(unit: .gramsPerUnit, doubleValue: item.value) -+ -+ if !Guardrail.carbRatio.absoluteBounds.contains(value) { -+ return .failure(.carbRatioError) -+ } -+ } -+ -+ // Checking Basal Rate Schedule -+ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { -+ for item in profile.basalRateSchedule.items { -+ let value = item.value -+ -+ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { -+ return .failure(.basalRateError) -+ } -+ } -+ } else { -+ return .failure(.maxBasalRateNotSet) -+ } -+ -+ return .success(()) -+ } -+ -+ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { -+ guard a.items.count == b.items.count else { -+ return false -+ } -+ -+ for (item1, item2) in zip(a.items, b.items) { -+ if item1.startTime != item2.startTime || -+ abs(item1.value.minValue - item2.value.minValue) > 0.01 || -+ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { -+ return false -+ } -+ } -+ -+ return true -+ } -+ -+ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { -+ guard a.items.count == b.items.count else { -+ return false -+ } -+ -+ for (item1, item2) in zip(a.items, b.items) { -+ if item1.startTime != item2.startTime || -+ abs(item1.value - item2.value) > 0.01 { -+ return false -+ } -+ } -+ -+ return true -+ } -+ -+ public func isCurrentProfileOutOfSync() -> Bool { -+ guard let currentProfileName = self.currentProfileName, -+ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), -+ let profile = try? getProfile(from: currentProfileReference), -+ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: HKUnit.milligramsPerDeciliter), -+ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: HKUnit.milligramsPerDeciliter) else { -+ return false // No current profile set or profile not found or schedules not available -+ } -+ -+ var isOutOfSync = false -+ -+ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: HKUnit.milligramsPerDeciliter)!) { -+ print("Glucose target range schedule differs") -+ isOutOfSync = true -+ } -+ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { -+ print("Carb ratio schedule differs") -+ isOutOfSync = true -+ } -+ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { -+ print("Basal rate schedule differs") -+ isOutOfSync = true -+ } -+ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: HKUnit.milligramsPerDeciliter)!) { -+ print("Insulin sensitivity schedule differs") -+ isOutOfSync = true -+ } -+ -+ return isOutOfSync -+ } -+} -+ -+extension Profile { -+ /// Passes the schedules this profile activates to `check`. -+ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { -+ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) -+ } -+} -+ -+extension ProfileViewModel { -+ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. -+ func therapySettings(applying profile: Profile) -> TherapySettings { -+ var settings = therapySettings -+ let unit = settings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) -+ settings.carbRatioSchedule = profile.carbRatioSchedule -+ settings.basalRateSchedule = profile.basalRateSchedule -+ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) -+ return settings -+ } -+ -+ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. -+ func updateProfile(named name: String, from settings: TherapySettings) throws { -+ loadProfiles() -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ throw ProfileValidationError.fileLoadError -+ } -+ let profile = try getProfile(from: profileReference) -+ let updatedProfile = Profile( -+ name: profile.name, -+ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, -+ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, -+ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, -+ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, -+ sortOrder: profile.sortOrder -+ ) -+ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) -+ loadProfiles() -+ triggerProfileSync() -+ } -+} -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift -new file mode 100644 -index 00000000..a48fa667 ---- /dev/null -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift -@@ -0,0 +1,86 @@ -+// -+// ProfileViewModel.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-04-22. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import Combine -+import LoopKit -+import HealthKit -+import SwiftUI -+ -+public class ProfileViewModel: ObservableObject { -+ -+ @Published public var therapySettings: TherapySettings -+ @Published public var profiles = [ProfileReference]() -+ @Published public var currentProfileName: String? { -+ didSet { -+ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") -+ } -+ } -+ -+ internal weak var delegate: TherapySettingsViewModelDelegate? -+ -+ public init(therapySettings: TherapySettings, -+ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, -+ sensitivityOverridesEnabled: Bool = false, -+ adultChildInsulinModelSelectionEnabled: Bool = false, -+ prescription: Prescription? = nil, -+ delegate: TherapySettingsViewModelDelegate? = nil) { -+ self.therapySettings = therapySettings -+ self.delegate = delegate -+ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") -+ self.loadProfiles() -+ } -+ -+ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { -+ return delegate?.pumpSupportedIncrements() -+ } -+} -+ -+public struct Profile: Codable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ var sortOrder: Int? -+} -+ -+public struct ProfileReference: Codable, Equatable { -+ var name: String -+ var fileName: String -+ var sortOrder: Int? -+} -+ -+ -+// MARK: Saving -+extension ProfileViewModel { -+ -+ public func saveCorrectionRange(range: GlucoseRangeSchedule) { -+ therapySettings.glucoseTargetRangeSchedule = range -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveBasalRates(basalRates: BasalRateSchedule) { -+ therapySettings.basalRateSchedule = basalRates -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { -+ therapySettings.carbRatioSchedule = carbRatioSchedule -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { -+ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func triggerProfileSync() { -+ delegate?.updateCurrentProfileName() -+ } -+} -diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -index 3650ef60..c5c1dc01 100644 ---- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -+++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { - func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) - func saveCompletion(therapySettings: TherapySettings) - func pumpSupportedIncrements() -> PumpSupportedIncrements? -+ func updateCurrentProfileName() - } - - public class TherapySettingsViewModel: ObservableObject { -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift -new file mode 100644 -index 00000000..446910e4 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift -@@ -0,0 +1,79 @@ -+// -+// NewProfileEditor.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-19. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+ -+struct NewProfileEditor: View { -+ @Binding var isPresented: Bool -+ @State var newProfileName: String -+ var viewModel: ProfileViewModel -+ @State private var showAlert = false -+ -+ var body: some View { -+ VStack(spacing: 0) { -+ ModalHeaderButtonBar( -+ leading: { cancelButton }, -+ center: { -+ Text("New Profile") -+ .font(.headline) -+ }, -+ trailing: { addButton } -+ ) -+ -+ TextField("Profile Name", text: $newProfileName) -+ .textFieldStyle(RoundedBorderTextFieldStyle()) -+ .padding() -+ .background( -+ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) -+ .fill(Color(.secondarySystemGroupedBackground)) -+ ) -+ } -+ .padding(.horizontal) -+ .alert(isPresented: $showAlert) { -+ Alert( -+ title: Text("Overwrite Existing Profile"), -+ message: Text("A profile with this name already exists. Would you like to overwrite it?"), -+ primaryButton: .destructive(Text("Overwrite")) { -+ self.viewModel.saveProfile(withName: self.newProfileName) -+ self.isPresented = false -+ }, -+ secondaryButton: .cancel() -+ ) -+ } -+ } -+ -+ var addButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ if viewModel.doesProfileExist(withName: self.newProfileName) { -+ self.showAlert = true -+ } else { -+ self.viewModel.saveProfile(withName: self.newProfileName) -+ self.isPresented = false -+ } -+ } -+ }, label: { -+ Text("Add") -+ } -+ ) -+ } -+ -+ var cancelButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ self.isPresented = false -+ } -+ }, label: { -+ Text("Cancel") -+ } -+ ) -+ } -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -new file mode 100644 -index 00000000..a956b6d5 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,423 @@ -+// -+// ProfilePreviewView.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-23. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+import HealthKit -+import LoopKit -+ -+enum ActiveAlert { -+ case load -+ case delete -+ case error -+ case loadEdited -+} -+ -+struct ProfilePreviewView: View { -+ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference -+ @ObservedObject var viewModel: ProfileViewModel -+ @Environment(\.dismissAction) var dismissAction -+ @Environment(\.presentationMode) var presentationMode -+ @Environment(\.appName) private var appName -+ @State private var profile: Profile -+ @State private var editingDelegate: ProfileEditingDelegate -+ @State private var therapySettingsViewModel: TherapySettingsViewModel -+ -+ @State private var showingAlert = false -+ @State private var activeAlert: ActiveAlert = .load -+ @State private var errorText: String? -+ @State private var newProfileName: String = "" -+ @State private var isRenamingProfile = false -+ @State private var shouldDismissSelf: Bool = false -+ -+ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } -+ -+ init(viewModel: ProfileViewModel, profile: Profile) { -+ self.viewModel = viewModel -+ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) -+ _profile = State(initialValue: profile) -+ _editingDelegate = State(initialValue: editingDelegate) -+ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( -+ therapySettings: viewModel.therapySettings(applying: profile), -+ delegate: editingDelegate)) -+ } -+ -+ var body: some View { -+ ZStack { -+ ScrollView { -+ VStack(alignment: .leading) { -+ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") -+ .font(.body) -+ .foregroundColor(.primary) -+ .padding([.leading, .trailing]) -+ .fixedSize(horizontal: false, vertical: true) -+ .textCase(nil) -+ -+ profileCardStack -+ -+ Button(action: { -+ activeAlert = .load -+ showingAlert = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Load") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.blue) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ Button(action: { -+ isRenamingProfile = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Rename") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.orange) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ Button(action: { -+ activeAlert = .delete -+ showingAlert = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Delete") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.red) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ } -+ } -+ .navigationBarHidden(false) -+ .background(Color(.systemGroupedBackground)) -+ .navigationTitle(Text(profile.name)) -+ .alert(isPresented: $showingAlert) { -+ switch activeAlert { -+ case .load: -+ return Alert( -+ title: Text("Load Profile"), -+ message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: load), -+ secondaryButton: .cancel() -+ ) -+ case .delete: -+ return Alert( -+ title: Text("Delete Profile"), -+ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), -+ primaryButton: .destructive(Text("Delete"), action: { -+ viewModel.removeProfile(profile: profile) -+ self.presentationMode.wrappedValue.dismiss() -+ }), -+ secondaryButton: .cancel() -+ ) -+ case .error: -+ return Alert( -+ title: Text("Error loading profile"), -+ message: Text(errorText ?? "Unknown error"), -+ dismissButton: .default(Text("OK")) -+ ) -+ case .loadEdited: -+ return Alert( -+ title: Text("Load Edited Profile?"), -+ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), -+ primaryButton: .default(Text("Load"), action: load), -+ secondaryButton: .cancel(Text("Later")) -+ ) -+ } -+ } -+ if isRenamingProfile { -+ // Some hacks here to mimic Alert behaviour. -+ // This function should be migrateed to Alert when iOS 16 is required. -+ Color.black.opacity(0.5) -+ .edgesIgnoringSafeArea(.all) -+ .onTapGesture { -+ isRenamingProfile = false -+ } -+ -+ RenameProfileEditor( -+ isPresented: $isRenamingProfile, -+ currentProfileName: profile.name, -+ viewModel: viewModel, -+ shouldDismissParent: $shouldDismissSelf -+ ) -+ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) -+ .offset(y: -22) -+ } -+ } -+ .onAppear { -+ // Runs again when an editor closes. -+ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } -+ profile = saved -+ // The editors show the saved values, also after a save that failed. -+ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) -+ if let error = editingDelegate.saveError { -+ editingDelegate.saveError = nil -+ presentAlertLater(.error, errorText: error.localizedDescription) -+ } else if editingDelegate.savedEdits { -+ editingDelegate.savedEdits = false -+ if viewModel.currentProfileName == profile.name { -+ presentAlertLater(.loadEdited) -+ } -+ } -+ } -+ .onChange(of: shouldDismissSelf) { shouldDismiss in -+ if shouldDismiss { -+ self.presentationMode.wrappedValue.dismiss() -+ } -+ } -+ } -+ -+ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ guard !showingAlert else { return } -+ self.errorText = errorText -+ activeAlert = alert -+ showingAlert = true -+ } -+ } -+ -+ private func load() { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ -+ private var profileCardStack: CardStack { -+ var cards: [Card] = [] -+ -+ cards.append(correctionRangeSection) -+ cards.append(carbRatioSection) -+ cards.append(basalRatesSection) -+ cards.append(insulinSensitivitiesSection) -+ -+ return CardStack(cards: cards) -+ } -+ -+ private var correctionRangeSection: Card { -+ card(for: .glucoseTargetRange) { -+ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { -+ SectionDivider() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleRangeItem(time: items[index].startTime, -+ range: items[index].value, -+ unit: glucoseUnit, -+ guardrail: .correctionRange) -+ } -+ } -+ } -+ } -+ -+ private var carbRatioSection: Card { -+ card(for: .carbRatio) { -+ SectionDivider() -+ let items = profile.carbRatioSchedule.items -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: .gramsPerUnit, -+ guardrail: .carbRatio) -+ } -+ } -+ } -+ -+ private var basalRatesSection: Card { -+ card(for: .basalRate) { -+ let items = profile.basalRateSchedule.items -+ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates -+ { -+ SectionDivider() -+ let total = profile.basalRateSchedule.total() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: .internationalUnitsPerHour, -+ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) -+ } -+ HStack { -+ Text("Total") -+ .bold() -+ .foregroundColor(.primary) -+ Spacer() -+ Text(String(format: "%.2f ",total)) -+ .foregroundColor(.primary) + -+ Text("U/day") -+ .foregroundColor(.secondary) -+ } -+ } -+ } -+ } -+ -+ private var insulinSensitivitiesSection: Card { -+ card(for: .insulinSensitivity) { -+ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items -+ { -+ SectionDivider() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: sensitivityUnit, -+ guardrail: .insulinSensitivity) -+ } -+ } -+ } -+ } -+} -+ -+ -+// MARK: Utilities -+extension ProfilePreviewView { -+ -+ private var glucoseUnit: HKUnit { -+ displayGlucosePreference.unit -+ } -+ -+ private var sensitivityUnit: HKUnit { -+ glucoseUnit.unitDivided(by: .internationalUnit()) -+ } -+ -+ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { -+ Card { -+ SectionWithTapToEdit( -+ // The basal editor needs the pump's supported rates. -+ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, -+ title: therapySetting.title, -+ descriptiveText: therapySetting.descriptiveText(appName: appName), -+ destination: { dismiss in -+ editor(for: therapySetting, dismiss: dismiss) -+ .environment(\.dismissAction, dismiss) -+ }, -+ content: content -+ ) -+ } -+ } -+ -+ /// The therapy settings editors, saving to this profile. -+ @ViewBuilder -+ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { -+ switch therapySetting { -+ case .glucoseTargetRange: -+ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .carbRatio: -+ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .basalRate: -+ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .insulinSensitivity: -+ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ default: -+ EmptyView() -+ } -+ } -+} -+ -+fileprivate struct SectionDivider: View { -+ var body: some View { -+ Divider() -+ .padding(.trailing, -16) -+ } -+} -+ -+fileprivate struct SettingsDivider: View { -+ var body: some View { -+ Divider() -+ .padding(.trailing, -8) -+ } -+} -+ -+/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. -+final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { -+ private let viewModel: ProfileViewModel -+ private let profileName: String -+ private let basalTimeZone: TimeZone -+ /// Set when edits were saved, so the view can offer to load the edited active profile. -+ var savedEdits = false -+ /// Set when saving edits to the profile file failed. -+ var saveError: Error? -+ -+ init(viewModel: ProfileViewModel, profile: Profile) { -+ self.viewModel = viewModel -+ self.profileName = profile.name -+ self.basalTimeZone = profile.basalRateSchedule.timeZone -+ } -+ -+ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { -+ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { -+ completion(.failure(ProfileValidationError.basalRateError)) -+ return -+ } -+ completion(.success(schedule)) -+ } -+ -+ func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { -+ completion(.success(deliveryLimits)) -+ } -+ -+ func saveCompletion(therapySettings: TherapySettings) { -+ do { -+ try viewModel.updateProfile(named: profileName, from: therapySettings) -+ savedEdits = true -+ } catch { -+ saveError = error -+ } -+ } -+ -+ func pumpSupportedIncrements() -> PumpSupportedIncrements? { -+ return viewModel.pumpSupportedIncrements() -+ } -+ -+ func updateCurrentProfileName() {} -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift -new file mode 100644 -index 00000000..f816492b ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift -@@ -0,0 +1,173 @@ -+// -+// ProfileView.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-04-22. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import AVFoundation -+import HealthKit -+import LoopKit -+import SwiftUI -+ -+public struct ProfileView: View { -+ @ObservedObject public var viewModel: ProfileViewModel -+ @Environment(\.dismissAction) var dismissAction -+ @State private var newProfileName: String = "" -+ @State private var isAddingNewProfile = false -+ @State private var selectedProfileIndex: Int? = nil -+ @State private var refreshID = UUID() -+ -+ enum ActiveAlert: String, Identifiable { -+ case update, info -+ -+ var id: String { -+ return self.rawValue -+ } -+ } -+ @State private var activeAlert: ActiveAlert? -+ -+ public init(viewModel: ProfileViewModel) { -+ self.viewModel = viewModel -+ } -+ -+ private var dismissButton: some View { -+ Button(action: dismissAction) { -+ Text(LocalizedString("Done", comment: "Text for dismiss button")) -+ .bold() -+ } -+ } -+ -+ public var body: some View { -+ ZStack { -+ NavigationView { -+ ConfigurationPageScrollView( -+ content: { -+ VStack(alignment: .leading) { -+ -+ if !viewModel.profiles.isEmpty { -+ List { -+ ForEach(viewModel.profiles.indices, id: \.self) { index in -+ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { -+ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { -+ HStack { -+ if viewModel.currentProfileName == viewModel.profiles[index].name { -+ Image(systemName: "checkmark") -+ .foregroundColor(Color.blue) -+ } else { -+ // Add indentation to align with the checkmark for the active profile -+ Image(systemName: "checkmark") -+ .opacity(0) // Invisible -+ } -+ -+ Text(viewModel.profiles[index].name) -+ } -+ } -+ } -+ } -+ .onMove(perform: moveProfile) -+ }.id(refreshID) -+ -+ } else { -+ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") -+ .font(.subheadline) -+ .foregroundColor(.secondary) -+ .padding([.leading, .trailing]) -+ .multilineTextAlignment(.leading) -+ .lineLimit(nil) -+ .textCase(nil) -+ } -+ } -+ }, -+ actionArea: { EmptyView() } // no action area in this case -+ ) -+ .navigationBarItems( -+ leading: HStack { -+ Button(action: { withAnimation { isAddingNewProfile = true } }) { -+ Image(systemName: "plus") -+ } -+ -+ Button(action: { activeAlert = .info }) { -+ Image(systemName: "info.circle") -+ .resizable() -+ .frame(width: 24, height: 24) -+ } -+ }, -+ trailing: dismissButton -+ ) -+ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) -+ } -+ .navigationBarHidden(false) -+ .background(Color(.systemGroupedBackground)) -+ .onAppear { -+ if viewModel.isCurrentProfileOutOfSync() { -+ activeAlert = .update -+ } -+ } -+ -+ .alert(item: $activeAlert) { alertType in -+ switch alertType { -+ case .update: -+ return Alert( -+ title: Text("Profile Outdated"), -+ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), -+ primaryButton: .default(Text("Save Current Settings")) { -+ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") -+ }, -+ secondaryButton: .cancel(Text("Keep Saved Profile")) -+ ) -+ case .info: -+ return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), -+ dismissButton: .default(Text("Got it!"))) -+ } -+ } -+ -+ if isAddingNewProfile { -+ DarkenedOverlay() -+ -+ NewProfileEditor( -+ isPresented: $isAddingNewProfile, -+ newProfileName: newProfileName, -+ viewModel: viewModel -+ ) -+ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) -+ } -+ } -+ } -+ -+ func moveProfile(from source: IndexSet, to destination: Int) { -+ viewModel.profiles.move(fromOffsets: source, toOffset: destination) -+ refreshID = UUID() -+ viewModel.updateProfilesOrder() -+ } -+} -+ -+struct ProfileView_Previews: PreviewProvider { -+ static let preview_glucoseScheduleItems = [ -+ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), -+ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), -+ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) -+ ] -+ -+ static let preview_therapySettings = TherapySettings( -+ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), -+ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), -+ workout: DoubleRange(99...111), -+ unit: .milligramsPerDeciliter), -+ maximumBolus: 4, -+ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), -+ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: HKUnit.internationalUnit()), dailyItems: []), -+ carbRatioSchedule: nil, -+ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) -+ -+ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] -+ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] -+ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] -+ -+ static var previews: some View { -+ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) -+ } -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift -new file mode 100644 -index 00000000..8fd26665 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift -@@ -0,0 +1,92 @@ -+// -+// RenameProfileEditor.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-09-07. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+ -+struct RenameProfileEditor: View { -+ @Binding var isPresented: Bool -+ @State var currentProfileName: String -+ @State var newProfileName: String = "" -+ var viewModel: ProfileViewModel -+ @State private var showAlert = false -+ @Binding var shouldDismissParent: Bool -+ -+ var body: some View { -+ VStack(spacing: 0) { -+ ModalHeaderButtonBar( -+ leading: { cancelButton }, -+ center: { -+ Text("Rename Profile") -+ .font(.subheadline) -+ -+ }, -+ trailing: { renameButton } -+ ) -+ .onAppear { -+ self.newProfileName = currentProfileName -+ } -+ -+ TextField("Profile Name", text: $newProfileName) -+ .textFieldStyle(RoundedBorderTextFieldStyle()) -+ .padding() -+ .background( -+ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) -+ .fill(Color(.secondarySystemGroupedBackground)) -+ ) -+ } -+ .padding(.horizontal) -+ .alert(isPresented: $showAlert) { -+ Alert( -+ title: Text("Overwrite Existing Profile"), -+ message: Text("A profile with this name already exists. Would you like to overwrite it?"), -+ primaryButton: .destructive(Text("Overwrite")) { -+ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) -+ self.shouldDismissParent = true -+ self.isPresented = false -+ }, -+ secondaryButton: .cancel() -+ ) -+ } -+ } -+ -+ var renameButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ guard newProfileName != currentProfileName else { -+ self.isPresented = false -+ return -+ } -+ -+ if viewModel.doesProfileExist(withName: self.newProfileName) { -+ self.showAlert = true -+ } else { -+ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) -+ self.shouldDismissParent = true -+ self.isPresented = false -+ } -+ } -+ }, label: { -+ Text("Rename") -+ } -+ ) -+ } -+ -+ var cancelButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ self.isPresented = false -+ } -+ }, label: { -+ Text("Cancel") -+ } -+ ) -+ } -+} -Submodule LoopOnboarding contains modified content -diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -index cc61f2d..6462e2c 100644 ---- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -+++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -@@ -379,6 +379,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { - maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount - ) - } -+ -+ func updateCurrentProfileName() { -+ } - } - - -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index 9f75373..18143ab 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -24,6 +24,10 @@ - A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; -@@ -165,6 +169,10 @@ - A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; - A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; -@@ -282,6 +290,7 @@ - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, - A934B6172358F49300949C8B /* StoredSettings.swift */, -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, - A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, - A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, -@@ -341,6 +350,7 @@ - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, - A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, - ); - path = NightscoutServiceKitTests; -@@ -383,6 +393,7 @@ - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, - ); - path = Notifications; - sourceTree = ""; -@@ -431,6 +442,7 @@ - A9AA6E5729EB06AA008FFA78 /* Actions */ = { - isa = PBXGroup; - children = ( -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -@@ -769,6 +781,9 @@ - C1398D2227C434FB00416AD6 /* Data.swift in Sources */, - A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, - A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, - C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, - ); -@@ -785,6 +800,7 @@ - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, - A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -new file mode 100644 -index 0000000..de73ca9 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -@@ -0,0 +1,104 @@ -+// -+// SavedProfile.swift -+// NightscoutServiceKit -+// -+ -+import CryptoKit -+import Foundation -+import HealthKit -+import LoopKit -+import NightscoutKit -+ -+/// A profile saved with the Profiles settings screen, read from the file it was saved to. -+struct SavedProfile: Decodable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ -+ private static var directory: URL { -+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") -+ } -+ -+ static func loadAll() -> [SavedProfile] { -+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] -+ return files -+ .filter { $0.pathExtension == "json" } -+ .compactMap { try? Data(contentsOf: $0) } -+ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } -+ } -+ -+ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. -+ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { -+ var store: [String: ProfileSet.Profile] = [:] -+ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. -+ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { -+ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) -+ } -+ return store -+ } -+ -+ /// True when this profile uploads with this fingerprint, in either glucose unit. -+ func matches(fingerprint: String) -> Bool { -+ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { -+ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint -+ } -+ } -+ -+ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { -+ guard let correctionRange = correctionRange.schedule(for: unit), -+ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) -+ else { -+ return nil -+ } -+ return ProfileSet.Profile( -+ timezone: basalRateSchedule.timeZone, -+ dia: .hours(6), -+ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, -+ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, -+ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) -+ } -+} -+ -+extension ProfileSet.Profile { -+ /// Followers compute the same value from the uploaded profile and send it with a switch command, -+ /// so Loop only activates the values the follower previewed. -+ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` -+ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. -+ var remoteFingerprint: String { -+ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { -+ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") -+ } -+ let canonical = [ -+ "basal=" + items(basal), -+ "carbratio=" + items(carbratio), -+ "sens=" + items(sensitivity), -+ "target_low=" + items(targetLow), -+ "target_high=" + items(targetHigh), -+ ].joined(separator: ";") -+ let digest = SHA256.hash(data: Data(canonical.utf8)) -+ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) -+ } -+} -+ -+enum RemoteProfileSwitchError: LocalizedError { -+ case profileChanged -+ case unavailable -+ case notActivated(String, Error) -+ -+ /// Followers read the profile name from this text to match the reply to their command. -+ var errorDescription: String? { -+ switch self { -+ case .profileChanged: -+ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") -+ case .unavailable: -+ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") -+ case .notActivated(let name, let error): -+ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index 637988b..7c780ea 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -160,7 +160,7 @@ extension StoredDosingDecision { - recommendedBolus: loopStatusRecommendedBolus, - enacted: automaticDoseDecision?.loopStatusEnacted, - failureReason: automaticDoseDecision?.loopStatusFailureReason, -- testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported]), -+ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]]), - overrideStatus: overrideStatus) - } - -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -index 2adadb9..dd7ac28 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -@@ -94,12 +94,21 @@ extension StoredSettings { - return nil - } - -+ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) -+ store["Default"] = profile -+ var defaultProfile = "Default" -+ -+ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { -+ defaultProfile = name -+ store[name] = profile -+ } -+ - return ProfileSet( - startDate: date, - units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), - enteredBy: "Loop", -- defaultProfile: "Default", -- store: ["Default": profile], -+ defaultProfile: defaultProfile, -+ store: store, - settings: loopSettings, - syncIdentifier: syncIdentifier.uuidString, - isAPNSProduction: Bundle.main.isAPNSProductionEnvironment) -diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index 169fb86..18ac515 100644 ---- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift -+++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -437,6 +437,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - success = true - message = "Override '\(overrideCommand.name)' enacted successfully" - -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ success = true -+ message = "Profile '\(profileSwitchCommand.name)' activated" -+ - case .cancelTemporaryOverride: - commandType = .cancelOverride - try await self.serviceDelegate?.cancelRemoteOverride() -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index 91ad56c..29d3563 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -9,6 +9,7 @@ - import Foundation - - public enum Action: Codable { -+ case profileSwitch(ProfileSwitchAction) - case temporaryScheduleOverride(OverrideAction) - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) -@@ -34,6 +35,8 @@ public enum Action: Codable { - return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") - case .temporaryScheduleOverride: - return LocalizedString("Override", comment: "The remote action name for Override") -+ case .profileSwitch: -+ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") - } - } - -@@ -54,6 +57,8 @@ public enum Action: Codable { - return "" - case .temporaryScheduleOverride(let overrideAction): - return overrideAction.name -+ case .profileSwitch(let profileSwitchAction): -+ return profileSwitchAction.name - } - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -new file mode 100644 -index 0000000..ce03ded ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -@@ -0,0 +1,17 @@ -+// -+// ProfileSwitchAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct ProfileSwitchAction: Codable { -+ -+ public let name: String -+ public let fingerprint: String -+ -+ public init(name: String, fingerprint: String) { -+ self.name = name -+ self.fingerprint = fingerprint -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 6092bc9..aabb47f 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -49,6 +49,7 @@ class RemoteNotificationResponseManager { - case carbsEdit = "carbs_edit" - case override = "override" - case cancelOverride = "cancel_override" -+ case profileSwitch = "profile_switch" - } - - func sendResponseNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -new file mode 100644 -index 0000000..487c620 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -0,0 +1,50 @@ -+// -+// ProfileSwitchRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Switches to a saved profile. `expiration` is required: decoding fails without it. -+public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { -+ -+ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. -+ static let commandKey = "profile-switch" -+ -+ public let profileName: String -+ public let fingerprint: String -+ public let remoteAddress: String -+ public let expirationDate: Date -+ public let sentAt: Date? -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ public var expiration: Date? { -+ return expirationDate -+ } -+ -+ enum CodingKeys: String, CodingKey { -+ case profileName = "profile-switch" -+ case fingerprint = "profile-switch-fingerprint" -+ case remoteAddress = "remote-address" -+ case expirationDate = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification[commandKey] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index 6f0524d..87f1e92 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -122,6 +122,8 @@ extension Dictionary { - return try OverrideRemoteNotification(dictionary: self) - } else if OverrideCancelRemoteNotification.includedInNotification(self) { - return try OverrideCancelRemoteNotification(dictionary: self) -+ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { -+ return try ProfileSwitchRemoteNotification(dictionary: self) - } else { - throw RemoteNotificationError.unhandledNotification(self) - } -diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..2fcec94 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -@@ -0,0 +1,71 @@ -+// -+// ProfileSwitchRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+import LoopKit -+import NightscoutKit -+@testable import NightscoutServiceKit -+ -+final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "sent-at": "2023-02-25T20:46:35.778Z", -+ "expiration": "2023-02-25T20:51:35.778Z", -+ "otp": "123456" -+ ] -+ -+ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) -+ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) -+ -+ XCTAssertEqual(profileSwitch.profileName, "Sport") -+ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") -+ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) -+ XCTAssertTrue(profileSwitch.otpValidationRequired()) -+ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { -+ return XCTFail("Expected a profile switch action") -+ } -+ XCTAssertEqual(action.name, "Sport") -+ } -+ -+ func testParseProfileSwitchNotification_MissingExpiration_Fails() { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "otp": "123456" -+ ] -+ -+ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) -+ } -+ -+ func testFingerprint_MatchesFollowerVector() { -+ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { -+ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } -+ } -+ let profile = ProfileSet.Profile( -+ timezone: TimeZone(secondsFromGMT: 3600)!, -+ dia: .hours(6), -+ sensitivity: items([(0, 45)]), -+ carbratio: items([(0, 10)]), -+ basal: items([(0, 0.85), (21600, 1.1)]), -+ targetLow: items([(0, 100)]), -+ targetHigh: items([(0, 110)]), -+ units: "mg/dL") -+ -+ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") -+ } -+} -+ -+private extension ISO8601DateFormatter { -+ static let fractional: ISO8601DateFormatter = { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ }() -+} -diff --git a/profiles.loopcustomization b/profiles.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/profiles/dev_profiles_remote_carb_edit_remote_window.patch b/profiles/dev_profiles_remote_carb_edit_remote_window.patch deleted file mode 100644 index 350c8b5..0000000 --- a/profiles/dev_profiles_remote_carb_edit_remote_window.patch +++ /dev/null @@ -1,2230 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index 8cc5714a..c253e88e 100644 ---- a/Loop/Loop/Managers/DeviceDataManager.swift -+++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -433,6 +433,7 @@ final class DeviceDataManager { - ) - - statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) -+ servicesManager.deviceDataManager = self - - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] - criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, -@@ -1725,6 +1726,10 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { - } - } - -+ func updateCurrentProfileName() { -+ loopManager.updateCurrentProfileName() -+ } -+ - func saveCompletion(therapySettings: TherapySettings) { - - loopManager.mutateSettings { settings in -diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift -index 974122ce..03ffe6c7 100644 ---- a/Loop/Loop/Managers/LoopDataManager.swift -+++ b/Loop/Loop/Managers/LoopDataManager.swift -@@ -244,6 +244,10 @@ final class LoopDataManager { - lockedSettings.value - } - -+ func updateCurrentProfileName () { -+ notify(forChange: .preferences) -+ } -+ - func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { - var oldValue: LoopSettings! - let newValue = lockedSettings.mutate { settings in -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 3eb3154b..6cefc092 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -293,6 +293,38 @@ extension NotificationManager { - } - } - -+ static func sendRemoteProfileNotification(name: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfile", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteProfileFailureNotification(name: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfileFailure", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ private static func remoteProfileNotificationBody(name: String) -> String { -+ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) -+ } -+ - private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { - return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) - } -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 1e272bb2..d804456f 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -28,6 +28,7 @@ class ServicesManager { - - weak var servicesManagerDelegate: ServicesManagerDelegate? - weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? -+ weak var deviceDataManager: DeviceDataManager? - - private var services = [Service]() - -@@ -482,3 +483,58 @@ extension Service { - ] - } - } -+ -+// MARK: - RemoteProfileActionDelegate -+ -+extension ServicesManager: RemoteProfileActionDelegate { -+ @MainActor -+ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { -+ do { -+ guard let deviceDataManager else { -+ throw RemoteProfileActionError.unavailable -+ } -+ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) -+ guard deviceDataManager.pumpSupportedIncrements() != nil else { -+ throw RemoteProfileActionError.noPump -+ } -+ guard let profile = try viewModel.savedProfile(named: name) else { -+ throw RemoteProfileActionError.profileNotFound(name) -+ } -+ try profile.verify(with: verify) -+ if case .failure(let error) = viewModel.validateProfile(profile) { -+ throw error -+ } -+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ continuation.resume() -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ NotificationManager.sendRemoteProfileNotification(name: name) -+ } catch { -+ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) -+ throw error -+ } -+ } -+ -+ enum RemoteProfileActionError: LocalizedError { -+ case unavailable -+ case noPump -+ case profileNotFound(String) -+ -+ var errorDescription: String? { -+ switch self { -+ case .unavailable: -+ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") -+ case .noPump: -+ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") -+ case .profileNotFound(let name): -+ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) -+ } -+ } -+ } -+} -diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift -index 9f9e3aa0..c1c9ea87 100644 ---- a/Loop/Loop/Views/SettingsView.swift -+++ b/Loop/Loop/Views/SettingsView.swift -@@ -50,6 +50,7 @@ public struct SettingsView: View { - } - - case favoriteFoods -+ case profiles - } - } - -@@ -119,6 +120,19 @@ public struct SettingsView: View { - switch sheet { - case .favoriteFoods: - FavoriteFoodsView() -+ case .profiles: -+ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), -+ sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, -+ adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, -+ delegate: self.viewModel.therapySettingsViewModelDelegate)) -+ .environmentObject(displayGlucosePreference) -+ .environment(\.dismissAction, self.dismiss) -+ .environment(\.appName, self.appName) -+ .environment(\.chartColorPalette, .primary) -+ .environment(\.carbTintColor, self.carbTintColor) -+ .environment(\.glucoseTintColor, self.glucoseTintColor) -+ .environment(\.guidanceColors, self.guidanceColors) -+ .environment(\.insulinTintColor, self.insulinTintColor) - } - } - } -@@ -279,6 +293,11 @@ extension SettingsView { - descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) - } - -+ LargeButton(action: { sheet = .profiles }, -+ includeArrow: true, -+ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), -+ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), -+ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) - ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in - item.view - } -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj -index e4423ce6..89045896 100644 ---- a/LoopKit/LoopKit.xcodeproj/project.pbxproj -+++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj -@@ -873,6 +873,12 @@ - D1A0B000000000000000000C /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; - D1A0B000000000000000000D /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; - D1A0B0000000000000000081 /* PersistenceControllerMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */; }; -+ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; -+ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; -+ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; -+ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; -+ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; -+ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; - E9077D2724ACD59F0066A88D /* InformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9077D2624ACD59F0066A88D /* InformationView.swift */; }; - E9077D2A24ACDE2C0066A88D /* CorrectionRangeInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9077D2924ACDE2C0066A88D /* CorrectionRangeInformationView.swift */; }; - E9086B2924B39EDC0062F5C8 /* ChartsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9086B2824B39EDC0062F5C8 /* ChartsTableViewController.swift */; }; -@@ -1813,6 +1819,12 @@ - D1A0B0000000000000000006 /* Modelv5ToModelv6.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = Modelv5ToModelv6.xcmappingmodel; sourceTree = ""; }; - D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelMappingv4v5Tov6Policy.swift; sourceTree = ""; }; - D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; -+ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; -+ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; -+ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; -+ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; -+ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; -+ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; - E9077D2624ACD59F0066A88D /* InformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InformationView.swift; sourceTree = ""; }; - E9077D2924ACDE2C0066A88D /* CorrectionRangeInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionRangeInformationView.swift; sourceTree = ""; }; - E9086B2824B39EDC0062F5C8 /* ChartsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartsTableViewController.swift; sourceTree = ""; }; -@@ -3009,11 +3021,13 @@ - isa = PBXGroup; - children = ( - B455F3A325FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift */, -+ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, - B4B7C1BB2604E3FC007379F6 /* CorrectionRangeScheduleEditorViewModel.swift */, - B4D4C20C25F95A8700DA809D /* DisplayGlucosePreference.swift */, - B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, - B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, - 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, -+ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, - ); - path = ViewModels; - sourceTree = ""; -@@ -3201,6 +3215,10 @@ - E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, - 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, - 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, -+ DDAF746629F422C600719F0A /* ProfileView.swift */, -+ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, -+ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, -+ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, - ); - path = "Settings Editors"; - sourceTree = ""; -@@ -3910,6 +3928,7 @@ - E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, - B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, - 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, -+ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, - 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, - E9E5E56A24D5CCE800B5DFFE /* OverrideViewCell.swift in Sources */, - 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, -@@ -3919,6 +3938,7 @@ - B43D69D62A26642300DF0925 /* DemoPlaceHolderView.swift in Sources */, - E949E38F24B3711E00024DA0 /* InsulinModelInformationView.swift in Sources */, - 895FE08B22011F0C00FCF18A /* EmojiInputHeaderView.swift in Sources */, -+ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, - 895FE08322011F0C00FCF18A /* OverrideSelectionFooterView.swift in Sources */, - 89AF78C22447E353002B4FCC /* Splat.swift in Sources */, - 893C9F8C2447DBD900CD4185 /* CardBuilder.swift in Sources */, -@@ -3969,9 +3989,11 @@ - E9C58A6E24DA65E400487A17 /* HistoricalOverrideDetailView.swift in Sources */, - 895FE08222011F0C00FCF18A /* LabeledTextFieldTableViewCell.swift in Sources */, - B43DA44224D9CD8500CAFF4E /* Environment+Colors.swift in Sources */, -+ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, - 892A5DB22231E191008961AB /* LoadingTableViewCell.swift in Sources */, - B4D3D4AD25B8A94E0085BA0F /* DisplayGlucoseUnitObserver.swift in Sources */, - A9D3FF102A6C19CA000C891D /* ChartAxisValueDoubleLog.swift in Sources */, -+ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, - E96DCB5A24AF74AC007117BC /* SuspendThresholdEditor.swift in Sources */, - E96DCB5824AEF50F007117BC /* SuspendThresholdInformationView.swift in Sources */, - A9D3FF0C2A6C198C000C891D /* CollectionType.swift in Sources */, -@@ -4047,6 +4069,7 @@ - E93C86B624D08CAD0073089B /* InsulinSensitivityInformationView.swift in Sources */, - C18733AF29B9492300519CDF /* Collection.swift in Sources */, - 892A5DA22231E137008961AB /* HUDProvider.swift in Sources */, -+ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, - 898E6E6C224194060019E459 /* UIColor.swift in Sources */, - 43BA717D201EE7090058961E /* GlucoseRangeTableViewCell.swift in Sources */, - B455F3A425FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift in Sources */, -@@ -4073,6 +4096,7 @@ - 1D1065E9282DC54700026A70 /* VideoPlayView.swift in Sources */, - E99A132E2557548300D3F5B3 /* SegmentedGaugeBar.swift in Sources */, - B455F31825FBEC5F000ED456 /* SuspendThresholdEditorViewModel.swift in Sources */, -+ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, - 892A5DB32231E191008961AB /* LevelHUDView.swift in Sources */, - B41A60B223D1DBC700636320 /* UIFont.swift in Sources */, - B41A60AF23D1DB5B00636320 /* TableViewTitleLabel.swift in Sources */, -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 8fb254b6..858a5621 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -8,6 +8,12 @@ - - import Foundation - -+/// Switches to a saved therapy profile on a remote command. -+public protocol RemoteProfileActionDelegate: AnyObject { -+ /// `verify` receives the schedules about to be activated and throws to reject them. -+ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws -+} -+ - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride - func cancelRemoteOverride() async throws -> TemporaryScheduleOverride -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -new file mode 100644 -index 00000000..b571cf7b ---- /dev/null -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,466 @@ -+// -+// ProfileViewModel+FileManagement.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-19. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import LoopKit -+import HealthKit -+ -+public enum ProfileValidationError: Error, LocalizedError { -+ case fileLoadError -+ case unsupportedIncrement -+ case correctionRangeError -+ case insulinSensitivityError -+ case carbRatioError -+ case basalRateError -+ case maxBasalRateNotSet -+ -+ public var errorDescription: String? { -+ switch self { -+ case .fileLoadError: -+ return "Unable to load the profile file." -+ case .unsupportedIncrement: -+ return "The profile contains unsupported increments." -+ case .correctionRangeError: -+ return "Correction Range values are out of bounds." -+ case .insulinSensitivityError: -+ return "Insulin Sensitivity values are out of bounds." -+ case .carbRatioError: -+ return "Carb Ratio values are out of bounds." -+ case .basalRateError: -+ return "Basal Rate values are out of bounds." -+ case .maxBasalRateNotSet: -+ return "Maximum Basal Rate is not set." -+ } -+ } -+} -+ -+public enum LoadProfileResult { -+ case success -+ case failure(Error) -+} -+ -+// MARK: File management -+extension ProfileViewModel { -+ private func setCurrentProfile(name: String, syncChange: Bool = false) { -+ let sanitizedProfileName = sanitizeProfileName(name) -+ currentProfileName = sanitizedProfileName -+ if syncChange { -+ triggerProfileSync() -+ } -+ } -+ -+ private func clearCurrentProfile() { -+ currentProfileName = nil -+ triggerProfileSync() -+ } -+ -+ private func getCurrentProfileName() -> String? { -+ return currentProfileName -+ } -+ -+ private var profilesDirectory: URL { -+ let fileManager = FileManager.default -+ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] -+ return documentsDirectory.appendingPathComponent("LoopProfile") -+ } -+ -+ private func createProfileDirectoryIfNotExists() throws { -+ let fileManager = FileManager.default -+ var isDir : ObjCBool = false -+ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { -+ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) -+ } else if !isDir.boolValue { -+ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) -+ } -+ } -+ -+ private func getAllProfileFiles() throws -> [URL] { -+ let fileManager = FileManager.default -+ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) -+ return files.filter { $0.pathExtension == "json" } -+ } -+ -+ private func encodeProfile(_ profile: Profile) throws -> Data { -+ return try JSONEncoder().encode(profile) -+ } -+ -+ private func decodeProfile(from data: Data) throws -> Profile { -+ return try JSONDecoder().decode(Profile.self, from: data) -+ } -+ -+ public func sanitizeProfileName(_ name: String) -> String { -+ // Replace problematic characters -+ var sanitized = name.replacingOccurrences(of: ".", with: "_") -+ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") -+ -+ // Limit the name to 32 characters -+ if sanitized.count > 32 { -+ sanitized = String(sanitized.prefix(32)) -+ } -+ -+ return sanitized -+ } -+ -+ public func saveProfile(withName name: String) { -+ let sanitizedName = sanitizeProfileName(name) -+ -+ do { -+ try createProfileDirectoryIfNotExists() -+ -+ if let existingProfile = getProfileReference(withName: sanitizedName) { -+ removeProfile(profileReference: existingProfile) -+ } -+ -+ let profile = Profile( -+ name: sanitizedName, -+ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, -+ carbRatioSchedule: therapySettings.carbRatioSchedule!, -+ basalRateSchedule: therapySettings.basalRateSchedule!, -+ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, -+ sortOrder: -1 -+ ) -+ -+ let jsonData = try encodeProfile(profile) -+ -+ let dateFormatter = DateFormatter() -+ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" -+ let fileName = dateFormatter.string(from: Date()) + ".json" -+ let fileURL = profilesDirectory.appendingPathComponent(fileName) -+ -+ try jsonData.write(to: fileURL) -+ setCurrentProfile(name: sanitizedName, syncChange: true) -+ -+ self.loadProfiles() -+ } catch { -+ print("An error occurred while saving profile: \(error)") -+ } -+ } -+ -+ public func renameProfile(oldName: String, newName: String) { -+ let sanitizedProfileName = sanitizeProfileName(newName) -+ -+ if currentProfileName == oldName { -+ setCurrentProfile(name: sanitizedProfileName, syncChange: true) -+ } -+ -+ do { -+ guard let profileReference = getProfileReference(withName: oldName) else { -+ print("Profile with old name not found.") -+ return -+ } -+ -+ let profile = try getProfile(from: profileReference) -+ -+ let newProfile = Profile( -+ name: sanitizedProfileName, -+ correctionRange: profile.correctionRange, -+ carbRatioSchedule: profile.carbRatioSchedule, -+ basalRateSchedule: profile.basalRateSchedule, -+ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, -+ sortOrder: profile.sortOrder -+ ) -+ -+ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { -+ removeProfile(profileReference: existingProfile) -+ } -+ -+ let jsonData = try encodeProfile(newProfile) -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ -+ try jsonData.write(to: fileURL) -+ -+ self.loadProfiles() -+ triggerProfileSync() -+ } catch { -+ print("An error occurred while renaming profile: \(error)") -+ } -+ } -+ -+ public func updateProfilesOrder() { -+ for (index, profileReference) in profiles.enumerated() { -+ do { -+ var profile = try getProfile(from: profileReference) -+ let newProfile = Profile( -+ name: profile.name, -+ correctionRange: profile.correctionRange, -+ carbRatioSchedule: profile.carbRatioSchedule, -+ basalRateSchedule: profile.basalRateSchedule, -+ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, -+ sortOrder: index -+ ) -+ -+ let jsonData = try encodeProfile(newProfile) -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ try jsonData.write(to: fileURL) -+ -+ } catch { -+ print("An error occurred while updating profile order: \(error)") -+ } -+ } -+ loadProfiles() -+ } -+ -+ public func loadProfiles() { -+ do { -+ let profileFiles = try getAllProfileFiles() -+ -+ var newProfiles = [ProfileReference]() -+ for fileURL in profileFiles { -+ let data = try Data(contentsOf: fileURL) -+ let profile = try decodeProfile(from: data) -+ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) -+ newProfiles.append(profileRef) -+ } -+ -+ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } -+ -+ self.profiles = newProfiles -+ } catch { -+ print("An error occurred while loading profiles: \(error)") -+ } -+ } -+ -+ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { -+ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && -+ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && -+ therapySettings.basalRateSchedule == profile.basalRateSchedule && -+ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { -+ self.setCurrentProfile(name: profile.name, syncChange: true) -+ completion(.success) -+ } -+ else -+ { -+ do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in -+ switch result { -+ case .success(let syncedSchedule): -+ DispatchQueue.main.async { -+ let previousSettings = self.therapySettings -+ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! -+ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule -+ self.therapySettings.basalRateSchedule = syncedSchedule -+ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! -+ // One settings change, so the stored and uploaded settings never mix two profiles. -+ self.setCurrentProfile(name: profile.name, syncChange: false) -+ self.delegate?.saveCompletion(therapySettings: self.therapySettings) -+ if self.therapySettings == previousSettings { -+ // Nothing changed, so only the profile name needs to reach the uploaded settings. -+ self.triggerProfileSync() -+ } -+ print("New profile loaded") -+ completion(.success) -+ } -+ case .failure(let error): -+ print("An error occurred while syncing basal rates: \(error)") -+ completion(.failure(error)) -+ } -+ }) -+ } -+ } -+ } -+ -+ func getProfileReference(withName name: String) -> ProfileReference? { -+ let sanitizedInputName = sanitizeProfileName(name) -+ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) -+ } -+ -+ public func removeProfile(profile: Profile) { -+ guard let profileReference = getProfileReference(withName: profile.name) else { -+ print("No ProfileReference found for given Profile") -+ return -+ } -+ -+ removeProfile(profileReference: profileReference) -+ if getCurrentProfileName() == profile.name { -+ clearCurrentProfile() -+ } else { -+ triggerProfileSync() -+ } -+ } -+ -+ public func removeProfile(profileReference: ProfileReference) { -+ do { -+ let fileManager = FileManager.default -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ try fileManager.removeItem(at: fileURL) -+ loadProfiles() -+ } catch { -+ print("An error occurred while removing profile: \(error)") -+ } -+ } -+ -+ func doesProfileExist(withName name: String) -> Bool { -+ return profiles.contains(where: { $0.name == name }) -+ } -+ -+ public func getProfile(from profileReference: ProfileReference) throws -> Profile { -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ let data = try Data(contentsOf: fileURL) -+ let getProfile = try decodeProfile(from: data) -+ return getProfile -+ } -+ -+ /// The saved profile named `name`, or nil when there is none. -+ public func savedProfile(named name: String) throws -> Profile? { -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ return nil -+ } -+ return try getProfile(from: profileReference) -+ } -+ -+ public func validateProfile(_ profile: Profile) -> Result { -+ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { -+ return .failure(.fileLoadError) -+ } -+ -+ // Checking Correction Range Schedule -+ for item in profile.correctionRange.items { -+ let minValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) -+ let maxValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) -+ -+ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { -+ return .failure(.correctionRangeError) -+ } -+ } -+ -+ // Checking Insulin Sensitivity Schedule -+ for item in profile.insulinSensitivitySchedule.items { -+ let value = HKQuantity(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: .internationalUnit()), doubleValue: item.value) -+ -+ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { -+ return .failure(.insulinSensitivityError) -+ } -+ } -+ -+ // Checking Carb Ratio Schedule -+ for item in profile.carbRatioSchedule.items { -+ let value = HKQuantity(unit: .gramsPerUnit, doubleValue: item.value) -+ -+ if !Guardrail.carbRatio.absoluteBounds.contains(value) { -+ return .failure(.carbRatioError) -+ } -+ } -+ -+ // Checking Basal Rate Schedule -+ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { -+ for item in profile.basalRateSchedule.items { -+ let value = item.value -+ -+ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { -+ return .failure(.basalRateError) -+ } -+ } -+ } else { -+ return .failure(.maxBasalRateNotSet) -+ } -+ -+ return .success(()) -+ } -+ -+ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { -+ guard a.items.count == b.items.count else { -+ return false -+ } -+ -+ for (item1, item2) in zip(a.items, b.items) { -+ if item1.startTime != item2.startTime || -+ abs(item1.value.minValue - item2.value.minValue) > 0.01 || -+ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { -+ return false -+ } -+ } -+ -+ return true -+ } -+ -+ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { -+ guard a.items.count == b.items.count else { -+ return false -+ } -+ -+ for (item1, item2) in zip(a.items, b.items) { -+ if item1.startTime != item2.startTime || -+ abs(item1.value - item2.value) > 0.01 { -+ return false -+ } -+ } -+ -+ return true -+ } -+ -+ public func isCurrentProfileOutOfSync() -> Bool { -+ guard let currentProfileName = self.currentProfileName, -+ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), -+ let profile = try? getProfile(from: currentProfileReference), -+ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: HKUnit.milligramsPerDeciliter), -+ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: HKUnit.milligramsPerDeciliter) else { -+ return false // No current profile set or profile not found or schedules not available -+ } -+ -+ var isOutOfSync = false -+ -+ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: HKUnit.milligramsPerDeciliter)!) { -+ print("Glucose target range schedule differs") -+ isOutOfSync = true -+ } -+ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { -+ print("Carb ratio schedule differs") -+ isOutOfSync = true -+ } -+ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { -+ print("Basal rate schedule differs") -+ isOutOfSync = true -+ } -+ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: HKUnit.milligramsPerDeciliter)!) { -+ print("Insulin sensitivity schedule differs") -+ isOutOfSync = true -+ } -+ -+ return isOutOfSync -+ } -+} -+ -+extension Profile { -+ /// Passes the schedules this profile activates to `check`. -+ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { -+ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) -+ } -+} -+ -+extension ProfileViewModel { -+ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. -+ func therapySettings(applying profile: Profile) -> TherapySettings { -+ var settings = therapySettings -+ let unit = settings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) -+ settings.carbRatioSchedule = profile.carbRatioSchedule -+ settings.basalRateSchedule = profile.basalRateSchedule -+ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) -+ return settings -+ } -+ -+ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. -+ func updateProfile(named name: String, from settings: TherapySettings) throws { -+ loadProfiles() -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ throw ProfileValidationError.fileLoadError -+ } -+ let profile = try getProfile(from: profileReference) -+ let updatedProfile = Profile( -+ name: profile.name, -+ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, -+ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, -+ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, -+ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, -+ sortOrder: profile.sortOrder -+ ) -+ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) -+ loadProfiles() -+ triggerProfileSync() -+ } -+} -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift -new file mode 100644 -index 00000000..a48fa667 ---- /dev/null -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift -@@ -0,0 +1,86 @@ -+// -+// ProfileViewModel.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-04-22. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import Combine -+import LoopKit -+import HealthKit -+import SwiftUI -+ -+public class ProfileViewModel: ObservableObject { -+ -+ @Published public var therapySettings: TherapySettings -+ @Published public var profiles = [ProfileReference]() -+ @Published public var currentProfileName: String? { -+ didSet { -+ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") -+ } -+ } -+ -+ internal weak var delegate: TherapySettingsViewModelDelegate? -+ -+ public init(therapySettings: TherapySettings, -+ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, -+ sensitivityOverridesEnabled: Bool = false, -+ adultChildInsulinModelSelectionEnabled: Bool = false, -+ prescription: Prescription? = nil, -+ delegate: TherapySettingsViewModelDelegate? = nil) { -+ self.therapySettings = therapySettings -+ self.delegate = delegate -+ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") -+ self.loadProfiles() -+ } -+ -+ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { -+ return delegate?.pumpSupportedIncrements() -+ } -+} -+ -+public struct Profile: Codable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ var sortOrder: Int? -+} -+ -+public struct ProfileReference: Codable, Equatable { -+ var name: String -+ var fileName: String -+ var sortOrder: Int? -+} -+ -+ -+// MARK: Saving -+extension ProfileViewModel { -+ -+ public func saveCorrectionRange(range: GlucoseRangeSchedule) { -+ therapySettings.glucoseTargetRangeSchedule = range -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveBasalRates(basalRates: BasalRateSchedule) { -+ therapySettings.basalRateSchedule = basalRates -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { -+ therapySettings.carbRatioSchedule = carbRatioSchedule -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { -+ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func triggerProfileSync() { -+ delegate?.updateCurrentProfileName() -+ } -+} -diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -index 3650ef60..c5c1dc01 100644 ---- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -+++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { - func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) - func saveCompletion(therapySettings: TherapySettings) - func pumpSupportedIncrements() -> PumpSupportedIncrements? -+ func updateCurrentProfileName() - } - - public class TherapySettingsViewModel: ObservableObject { -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift -new file mode 100644 -index 00000000..446910e4 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift -@@ -0,0 +1,79 @@ -+// -+// NewProfileEditor.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-19. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+ -+struct NewProfileEditor: View { -+ @Binding var isPresented: Bool -+ @State var newProfileName: String -+ var viewModel: ProfileViewModel -+ @State private var showAlert = false -+ -+ var body: some View { -+ VStack(spacing: 0) { -+ ModalHeaderButtonBar( -+ leading: { cancelButton }, -+ center: { -+ Text("New Profile") -+ .font(.headline) -+ }, -+ trailing: { addButton } -+ ) -+ -+ TextField("Profile Name", text: $newProfileName) -+ .textFieldStyle(RoundedBorderTextFieldStyle()) -+ .padding() -+ .background( -+ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) -+ .fill(Color(.secondarySystemGroupedBackground)) -+ ) -+ } -+ .padding(.horizontal) -+ .alert(isPresented: $showAlert) { -+ Alert( -+ title: Text("Overwrite Existing Profile"), -+ message: Text("A profile with this name already exists. Would you like to overwrite it?"), -+ primaryButton: .destructive(Text("Overwrite")) { -+ self.viewModel.saveProfile(withName: self.newProfileName) -+ self.isPresented = false -+ }, -+ secondaryButton: .cancel() -+ ) -+ } -+ } -+ -+ var addButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ if viewModel.doesProfileExist(withName: self.newProfileName) { -+ self.showAlert = true -+ } else { -+ self.viewModel.saveProfile(withName: self.newProfileName) -+ self.isPresented = false -+ } -+ } -+ }, label: { -+ Text("Add") -+ } -+ ) -+ } -+ -+ var cancelButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ self.isPresented = false -+ } -+ }, label: { -+ Text("Cancel") -+ } -+ ) -+ } -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -new file mode 100644 -index 00000000..a956b6d5 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,423 @@ -+// -+// ProfilePreviewView.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-23. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+import HealthKit -+import LoopKit -+ -+enum ActiveAlert { -+ case load -+ case delete -+ case error -+ case loadEdited -+} -+ -+struct ProfilePreviewView: View { -+ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference -+ @ObservedObject var viewModel: ProfileViewModel -+ @Environment(\.dismissAction) var dismissAction -+ @Environment(\.presentationMode) var presentationMode -+ @Environment(\.appName) private var appName -+ @State private var profile: Profile -+ @State private var editingDelegate: ProfileEditingDelegate -+ @State private var therapySettingsViewModel: TherapySettingsViewModel -+ -+ @State private var showingAlert = false -+ @State private var activeAlert: ActiveAlert = .load -+ @State private var errorText: String? -+ @State private var newProfileName: String = "" -+ @State private var isRenamingProfile = false -+ @State private var shouldDismissSelf: Bool = false -+ -+ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } -+ -+ init(viewModel: ProfileViewModel, profile: Profile) { -+ self.viewModel = viewModel -+ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) -+ _profile = State(initialValue: profile) -+ _editingDelegate = State(initialValue: editingDelegate) -+ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( -+ therapySettings: viewModel.therapySettings(applying: profile), -+ delegate: editingDelegate)) -+ } -+ -+ var body: some View { -+ ZStack { -+ ScrollView { -+ VStack(alignment: .leading) { -+ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") -+ .font(.body) -+ .foregroundColor(.primary) -+ .padding([.leading, .trailing]) -+ .fixedSize(horizontal: false, vertical: true) -+ .textCase(nil) -+ -+ profileCardStack -+ -+ Button(action: { -+ activeAlert = .load -+ showingAlert = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Load") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.blue) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ Button(action: { -+ isRenamingProfile = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Rename") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.orange) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ Button(action: { -+ activeAlert = .delete -+ showingAlert = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Delete") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.red) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ } -+ } -+ .navigationBarHidden(false) -+ .background(Color(.systemGroupedBackground)) -+ .navigationTitle(Text(profile.name)) -+ .alert(isPresented: $showingAlert) { -+ switch activeAlert { -+ case .load: -+ return Alert( -+ title: Text("Load Profile"), -+ message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: load), -+ secondaryButton: .cancel() -+ ) -+ case .delete: -+ return Alert( -+ title: Text("Delete Profile"), -+ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), -+ primaryButton: .destructive(Text("Delete"), action: { -+ viewModel.removeProfile(profile: profile) -+ self.presentationMode.wrappedValue.dismiss() -+ }), -+ secondaryButton: .cancel() -+ ) -+ case .error: -+ return Alert( -+ title: Text("Error loading profile"), -+ message: Text(errorText ?? "Unknown error"), -+ dismissButton: .default(Text("OK")) -+ ) -+ case .loadEdited: -+ return Alert( -+ title: Text("Load Edited Profile?"), -+ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), -+ primaryButton: .default(Text("Load"), action: load), -+ secondaryButton: .cancel(Text("Later")) -+ ) -+ } -+ } -+ if isRenamingProfile { -+ // Some hacks here to mimic Alert behaviour. -+ // This function should be migrateed to Alert when iOS 16 is required. -+ Color.black.opacity(0.5) -+ .edgesIgnoringSafeArea(.all) -+ .onTapGesture { -+ isRenamingProfile = false -+ } -+ -+ RenameProfileEditor( -+ isPresented: $isRenamingProfile, -+ currentProfileName: profile.name, -+ viewModel: viewModel, -+ shouldDismissParent: $shouldDismissSelf -+ ) -+ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) -+ .offset(y: -22) -+ } -+ } -+ .onAppear { -+ // Runs again when an editor closes. -+ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } -+ profile = saved -+ // The editors show the saved values, also after a save that failed. -+ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) -+ if let error = editingDelegate.saveError { -+ editingDelegate.saveError = nil -+ presentAlertLater(.error, errorText: error.localizedDescription) -+ } else if editingDelegate.savedEdits { -+ editingDelegate.savedEdits = false -+ if viewModel.currentProfileName == profile.name { -+ presentAlertLater(.loadEdited) -+ } -+ } -+ } -+ .onChange(of: shouldDismissSelf) { shouldDismiss in -+ if shouldDismiss { -+ self.presentationMode.wrappedValue.dismiss() -+ } -+ } -+ } -+ -+ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ guard !showingAlert else { return } -+ self.errorText = errorText -+ activeAlert = alert -+ showingAlert = true -+ } -+ } -+ -+ private func load() { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ -+ private var profileCardStack: CardStack { -+ var cards: [Card] = [] -+ -+ cards.append(correctionRangeSection) -+ cards.append(carbRatioSection) -+ cards.append(basalRatesSection) -+ cards.append(insulinSensitivitiesSection) -+ -+ return CardStack(cards: cards) -+ } -+ -+ private var correctionRangeSection: Card { -+ card(for: .glucoseTargetRange) { -+ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { -+ SectionDivider() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleRangeItem(time: items[index].startTime, -+ range: items[index].value, -+ unit: glucoseUnit, -+ guardrail: .correctionRange) -+ } -+ } -+ } -+ } -+ -+ private var carbRatioSection: Card { -+ card(for: .carbRatio) { -+ SectionDivider() -+ let items = profile.carbRatioSchedule.items -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: .gramsPerUnit, -+ guardrail: .carbRatio) -+ } -+ } -+ } -+ -+ private var basalRatesSection: Card { -+ card(for: .basalRate) { -+ let items = profile.basalRateSchedule.items -+ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates -+ { -+ SectionDivider() -+ let total = profile.basalRateSchedule.total() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: .internationalUnitsPerHour, -+ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) -+ } -+ HStack { -+ Text("Total") -+ .bold() -+ .foregroundColor(.primary) -+ Spacer() -+ Text(String(format: "%.2f ",total)) -+ .foregroundColor(.primary) + -+ Text("U/day") -+ .foregroundColor(.secondary) -+ } -+ } -+ } -+ } -+ -+ private var insulinSensitivitiesSection: Card { -+ card(for: .insulinSensitivity) { -+ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items -+ { -+ SectionDivider() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: sensitivityUnit, -+ guardrail: .insulinSensitivity) -+ } -+ } -+ } -+ } -+} -+ -+ -+// MARK: Utilities -+extension ProfilePreviewView { -+ -+ private var glucoseUnit: HKUnit { -+ displayGlucosePreference.unit -+ } -+ -+ private var sensitivityUnit: HKUnit { -+ glucoseUnit.unitDivided(by: .internationalUnit()) -+ } -+ -+ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { -+ Card { -+ SectionWithTapToEdit( -+ // The basal editor needs the pump's supported rates. -+ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, -+ title: therapySetting.title, -+ descriptiveText: therapySetting.descriptiveText(appName: appName), -+ destination: { dismiss in -+ editor(for: therapySetting, dismiss: dismiss) -+ .environment(\.dismissAction, dismiss) -+ }, -+ content: content -+ ) -+ } -+ } -+ -+ /// The therapy settings editors, saving to this profile. -+ @ViewBuilder -+ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { -+ switch therapySetting { -+ case .glucoseTargetRange: -+ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .carbRatio: -+ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .basalRate: -+ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .insulinSensitivity: -+ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ default: -+ EmptyView() -+ } -+ } -+} -+ -+fileprivate struct SectionDivider: View { -+ var body: some View { -+ Divider() -+ .padding(.trailing, -16) -+ } -+} -+ -+fileprivate struct SettingsDivider: View { -+ var body: some View { -+ Divider() -+ .padding(.trailing, -8) -+ } -+} -+ -+/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. -+final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { -+ private let viewModel: ProfileViewModel -+ private let profileName: String -+ private let basalTimeZone: TimeZone -+ /// Set when edits were saved, so the view can offer to load the edited active profile. -+ var savedEdits = false -+ /// Set when saving edits to the profile file failed. -+ var saveError: Error? -+ -+ init(viewModel: ProfileViewModel, profile: Profile) { -+ self.viewModel = viewModel -+ self.profileName = profile.name -+ self.basalTimeZone = profile.basalRateSchedule.timeZone -+ } -+ -+ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { -+ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { -+ completion(.failure(ProfileValidationError.basalRateError)) -+ return -+ } -+ completion(.success(schedule)) -+ } -+ -+ func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { -+ completion(.success(deliveryLimits)) -+ } -+ -+ func saveCompletion(therapySettings: TherapySettings) { -+ do { -+ try viewModel.updateProfile(named: profileName, from: therapySettings) -+ savedEdits = true -+ } catch { -+ saveError = error -+ } -+ } -+ -+ func pumpSupportedIncrements() -> PumpSupportedIncrements? { -+ return viewModel.pumpSupportedIncrements() -+ } -+ -+ func updateCurrentProfileName() {} -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift -new file mode 100644 -index 00000000..f816492b ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift -@@ -0,0 +1,173 @@ -+// -+// ProfileView.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-04-22. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import AVFoundation -+import HealthKit -+import LoopKit -+import SwiftUI -+ -+public struct ProfileView: View { -+ @ObservedObject public var viewModel: ProfileViewModel -+ @Environment(\.dismissAction) var dismissAction -+ @State private var newProfileName: String = "" -+ @State private var isAddingNewProfile = false -+ @State private var selectedProfileIndex: Int? = nil -+ @State private var refreshID = UUID() -+ -+ enum ActiveAlert: String, Identifiable { -+ case update, info -+ -+ var id: String { -+ return self.rawValue -+ } -+ } -+ @State private var activeAlert: ActiveAlert? -+ -+ public init(viewModel: ProfileViewModel) { -+ self.viewModel = viewModel -+ } -+ -+ private var dismissButton: some View { -+ Button(action: dismissAction) { -+ Text(LocalizedString("Done", comment: "Text for dismiss button")) -+ .bold() -+ } -+ } -+ -+ public var body: some View { -+ ZStack { -+ NavigationView { -+ ConfigurationPageScrollView( -+ content: { -+ VStack(alignment: .leading) { -+ -+ if !viewModel.profiles.isEmpty { -+ List { -+ ForEach(viewModel.profiles.indices, id: \.self) { index in -+ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { -+ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { -+ HStack { -+ if viewModel.currentProfileName == viewModel.profiles[index].name { -+ Image(systemName: "checkmark") -+ .foregroundColor(Color.blue) -+ } else { -+ // Add indentation to align with the checkmark for the active profile -+ Image(systemName: "checkmark") -+ .opacity(0) // Invisible -+ } -+ -+ Text(viewModel.profiles[index].name) -+ } -+ } -+ } -+ } -+ .onMove(perform: moveProfile) -+ }.id(refreshID) -+ -+ } else { -+ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") -+ .font(.subheadline) -+ .foregroundColor(.secondary) -+ .padding([.leading, .trailing]) -+ .multilineTextAlignment(.leading) -+ .lineLimit(nil) -+ .textCase(nil) -+ } -+ } -+ }, -+ actionArea: { EmptyView() } // no action area in this case -+ ) -+ .navigationBarItems( -+ leading: HStack { -+ Button(action: { withAnimation { isAddingNewProfile = true } }) { -+ Image(systemName: "plus") -+ } -+ -+ Button(action: { activeAlert = .info }) { -+ Image(systemName: "info.circle") -+ .resizable() -+ .frame(width: 24, height: 24) -+ } -+ }, -+ trailing: dismissButton -+ ) -+ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) -+ } -+ .navigationBarHidden(false) -+ .background(Color(.systemGroupedBackground)) -+ .onAppear { -+ if viewModel.isCurrentProfileOutOfSync() { -+ activeAlert = .update -+ } -+ } -+ -+ .alert(item: $activeAlert) { alertType in -+ switch alertType { -+ case .update: -+ return Alert( -+ title: Text("Profile Outdated"), -+ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), -+ primaryButton: .default(Text("Save Current Settings")) { -+ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") -+ }, -+ secondaryButton: .cancel(Text("Keep Saved Profile")) -+ ) -+ case .info: -+ return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), -+ dismissButton: .default(Text("Got it!"))) -+ } -+ } -+ -+ if isAddingNewProfile { -+ DarkenedOverlay() -+ -+ NewProfileEditor( -+ isPresented: $isAddingNewProfile, -+ newProfileName: newProfileName, -+ viewModel: viewModel -+ ) -+ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) -+ } -+ } -+ } -+ -+ func moveProfile(from source: IndexSet, to destination: Int) { -+ viewModel.profiles.move(fromOffsets: source, toOffset: destination) -+ refreshID = UUID() -+ viewModel.updateProfilesOrder() -+ } -+} -+ -+struct ProfileView_Previews: PreviewProvider { -+ static let preview_glucoseScheduleItems = [ -+ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), -+ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), -+ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) -+ ] -+ -+ static let preview_therapySettings = TherapySettings( -+ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), -+ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), -+ workout: DoubleRange(99...111), -+ unit: .milligramsPerDeciliter), -+ maximumBolus: 4, -+ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), -+ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: HKUnit.internationalUnit()), dailyItems: []), -+ carbRatioSchedule: nil, -+ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) -+ -+ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] -+ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] -+ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] -+ -+ static var previews: some View { -+ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) -+ } -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift -new file mode 100644 -index 00000000..8fd26665 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift -@@ -0,0 +1,92 @@ -+// -+// RenameProfileEditor.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-09-07. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+ -+struct RenameProfileEditor: View { -+ @Binding var isPresented: Bool -+ @State var currentProfileName: String -+ @State var newProfileName: String = "" -+ var viewModel: ProfileViewModel -+ @State private var showAlert = false -+ @Binding var shouldDismissParent: Bool -+ -+ var body: some View { -+ VStack(spacing: 0) { -+ ModalHeaderButtonBar( -+ leading: { cancelButton }, -+ center: { -+ Text("Rename Profile") -+ .font(.subheadline) -+ -+ }, -+ trailing: { renameButton } -+ ) -+ .onAppear { -+ self.newProfileName = currentProfileName -+ } -+ -+ TextField("Profile Name", text: $newProfileName) -+ .textFieldStyle(RoundedBorderTextFieldStyle()) -+ .padding() -+ .background( -+ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) -+ .fill(Color(.secondarySystemGroupedBackground)) -+ ) -+ } -+ .padding(.horizontal) -+ .alert(isPresented: $showAlert) { -+ Alert( -+ title: Text("Overwrite Existing Profile"), -+ message: Text("A profile with this name already exists. Would you like to overwrite it?"), -+ primaryButton: .destructive(Text("Overwrite")) { -+ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) -+ self.shouldDismissParent = true -+ self.isPresented = false -+ }, -+ secondaryButton: .cancel() -+ ) -+ } -+ } -+ -+ var renameButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ guard newProfileName != currentProfileName else { -+ self.isPresented = false -+ return -+ } -+ -+ if viewModel.doesProfileExist(withName: self.newProfileName) { -+ self.showAlert = true -+ } else { -+ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) -+ self.shouldDismissParent = true -+ self.isPresented = false -+ } -+ } -+ }, label: { -+ Text("Rename") -+ } -+ ) -+ } -+ -+ var cancelButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ self.isPresented = false -+ } -+ }, label: { -+ Text("Cancel") -+ } -+ ) -+ } -+} -Submodule LoopOnboarding contains modified content -diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -index cc61f2d..6462e2c 100644 ---- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -+++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -@@ -379,6 +379,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { - maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount - ) - } -+ -+ func updateCurrentProfileName() { -+ } - } - - -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index 9f75373..18143ab 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -24,6 +24,10 @@ - A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; -@@ -165,6 +169,10 @@ - A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; - A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; -@@ -282,6 +290,7 @@ - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, - A934B6172358F49300949C8B /* StoredSettings.swift */, -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, - A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, - A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, -@@ -341,6 +350,7 @@ - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, - A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, - ); - path = NightscoutServiceKitTests; -@@ -383,6 +393,7 @@ - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, - ); - path = Notifications; - sourceTree = ""; -@@ -431,6 +442,7 @@ - A9AA6E5729EB06AA008FFA78 /* Actions */ = { - isa = PBXGroup; - children = ( -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -@@ -769,6 +781,9 @@ - C1398D2227C434FB00416AD6 /* Data.swift in Sources */, - A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, - A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, - C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, - ); -@@ -785,6 +800,7 @@ - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, - A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -new file mode 100644 -index 0000000..de73ca9 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -@@ -0,0 +1,104 @@ -+// -+// SavedProfile.swift -+// NightscoutServiceKit -+// -+ -+import CryptoKit -+import Foundation -+import HealthKit -+import LoopKit -+import NightscoutKit -+ -+/// A profile saved with the Profiles settings screen, read from the file it was saved to. -+struct SavedProfile: Decodable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ -+ private static var directory: URL { -+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") -+ } -+ -+ static func loadAll() -> [SavedProfile] { -+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] -+ return files -+ .filter { $0.pathExtension == "json" } -+ .compactMap { try? Data(contentsOf: $0) } -+ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } -+ } -+ -+ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. -+ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { -+ var store: [String: ProfileSet.Profile] = [:] -+ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. -+ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { -+ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) -+ } -+ return store -+ } -+ -+ /// True when this profile uploads with this fingerprint, in either glucose unit. -+ func matches(fingerprint: String) -> Bool { -+ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { -+ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint -+ } -+ } -+ -+ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { -+ guard let correctionRange = correctionRange.schedule(for: unit), -+ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) -+ else { -+ return nil -+ } -+ return ProfileSet.Profile( -+ timezone: basalRateSchedule.timeZone, -+ dia: .hours(6), -+ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, -+ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, -+ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) -+ } -+} -+ -+extension ProfileSet.Profile { -+ /// Followers compute the same value from the uploaded profile and send it with a switch command, -+ /// so Loop only activates the values the follower previewed. -+ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` -+ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. -+ var remoteFingerprint: String { -+ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { -+ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") -+ } -+ let canonical = [ -+ "basal=" + items(basal), -+ "carbratio=" + items(carbratio), -+ "sens=" + items(sensitivity), -+ "target_low=" + items(targetLow), -+ "target_high=" + items(targetHigh), -+ ].joined(separator: ";") -+ let digest = SHA256.hash(data: Data(canonical.utf8)) -+ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) -+ } -+} -+ -+enum RemoteProfileSwitchError: LocalizedError { -+ case profileChanged -+ case unavailable -+ case notActivated(String, Error) -+ -+ /// Followers read the profile name from this text to match the reply to their command. -+ var errorDescription: String? { -+ switch self { -+ case .profileChanged: -+ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") -+ case .unavailable: -+ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") -+ case .notActivated(let name, let error): -+ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index 637988b..7c780ea 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -160,7 +160,7 @@ extension StoredDosingDecision { - recommendedBolus: loopStatusRecommendedBolus, - enacted: automaticDoseDecision?.loopStatusEnacted, - failureReason: automaticDoseDecision?.loopStatusFailureReason, -- testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported]), -+ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]]), - overrideStatus: overrideStatus) - } - -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -index 2adadb9..dd7ac28 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -@@ -94,12 +94,21 @@ extension StoredSettings { - return nil - } - -+ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) -+ store["Default"] = profile -+ var defaultProfile = "Default" -+ -+ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { -+ defaultProfile = name -+ store[name] = profile -+ } -+ - return ProfileSet( - startDate: date, - units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), - enteredBy: "Loop", -- defaultProfile: "Default", -- store: ["Default": profile], -+ defaultProfile: defaultProfile, -+ store: store, - settings: loopSettings, - syncIdentifier: syncIdentifier.uuidString, - isAPNSProduction: Bundle.main.isAPNSProductionEnvironment) -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index 91ad56c..29d3563 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -9,6 +9,7 @@ - import Foundation - - public enum Action: Codable { -+ case profileSwitch(ProfileSwitchAction) - case temporaryScheduleOverride(OverrideAction) - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) -@@ -34,6 +35,8 @@ public enum Action: Codable { - return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") - case .temporaryScheduleOverride: - return LocalizedString("Override", comment: "The remote action name for Override") -+ case .profileSwitch: -+ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") - } - } - -@@ -54,6 +57,8 @@ public enum Action: Codable { - return "" - case .temporaryScheduleOverride(let overrideAction): - return overrideAction.name -+ case .profileSwitch(let profileSwitchAction): -+ return profileSwitchAction.name - } - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -new file mode 100644 -index 0000000..ce03ded ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -@@ -0,0 +1,17 @@ -+// -+// ProfileSwitchAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct ProfileSwitchAction: Codable { -+ -+ public let name: String -+ public let fingerprint: String -+ -+ public init(name: String, fingerprint: String) { -+ self.name = name -+ self.fingerprint = fingerprint -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 6092bc9..aabb47f 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -49,6 +49,7 @@ class RemoteNotificationResponseManager { - case carbsEdit = "carbs_edit" - case override = "override" - case cancelOverride = "cancel_override" -+ case profileSwitch = "profile_switch" - } - - func sendResponseNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -new file mode 100644 -index 0000000..ebcd6ef ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -0,0 +1,50 @@ -+// -+// ProfileSwitchRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Switches to a saved profile. `expiration` is required: decoding fails without it. -+public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { -+ -+ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. -+ static let commandKey = "profile-switch" -+ -+ public let profileName: String -+ public let fingerprint: String -+ public let remoteAddress: String -+ public let expirationDate: Date -+ public let sentAt: Date -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ public var expiration: Date? { -+ return expirationDate -+ } -+ -+ enum CodingKeys: String, CodingKey { -+ case profileName = "profile-switch" -+ case fingerprint = "profile-switch-fingerprint" -+ case remoteAddress = "remote-address" -+ case expirationDate = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification[commandKey] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index be43210..1ea8b96 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -118,6 +118,8 @@ extension Dictionary { - return try OverrideRemoteNotification(dictionary: self) - } else if OverrideCancelRemoteNotification.includedInNotification(self) { - return try OverrideCancelRemoteNotification(dictionary: self) -+ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { -+ return try ProfileSwitchRemoteNotification(dictionary: self) - } else { - throw RemoteNotificationError.unhandledNotification(self) - } -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -index 331ed5c..bff69c8 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -@@ -123,6 +123,27 @@ class RemoteCommandSourceV1: RemoteCommandSource { - status: .success(date: Date(), syncIdentifier: replacementEntry.syncIdentifier ?? "", completionMessage: nil), - notificationJSON: notification - ) -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ successMessage = "Profile '\(profileSwitchCommand.name)' activated" -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .success(date: Date(), syncIdentifier: "", completionMessage: nil), -+ notificationJSON: notification -+ ) - case .temporaryScheduleOverride(let overrideCommand): - commandType = .override - let override = try await serviceDelegate.enactRemoteOverride( -@@ -260,6 +281,10 @@ public class StoredRemoteNotification: NSObject, Codable { - self.init(notificationType: .carbsEdit(carbEditNotification), notificationJSONData: notificationJSONData) - } - -+ convenience init(profileSwitchNotification: ProfileSwitchRemoteNotification, notificationJSONData: Data) { -+ self.init(notificationType: .profileSwitch(profileSwitchNotification), notificationJSONData: notificationJSONData) -+ } -+ - convenience init(overrideNotification: OverrideRemoteNotification, notificationJSONData: Data) { - self.init(notificationType: .override(overrideNotification), notificationJSONData: notificationJSONData) - } -@@ -347,6 +372,8 @@ public class StoredRemoteNotification: NSObject, Codable { - return carbDeleteNotification - case let .carbsEdit(carbEditNotification): - return carbEditNotification -+ case let .profileSwitch(profileSwitchNotification): -+ return profileSwitchNotification - case let .override(overrideNotification): - return overrideNotification - case let .overrideCancel(overrideCancelNotification): -@@ -377,6 +404,7 @@ public class StoredRemoteNotification: NSObject, Codable { - case carbsEdit(CarbEditRemoteNotification) - case override(OverrideRemoteNotification) - case overrideCancel(OverrideCancelRemoteNotification) -+ case profileSwitch(ProfileSwitchRemoteNotification) - } - } - -@@ -433,6 +461,9 @@ actor RecentNotifications { - } else if let carbEditNotification = remoteNotification as? CarbEditRemoteNotification { - let storedNotification = StoredRemoteNotification(carbEditNotification: carbEditNotification, notificationJSONData: data) - try storeNotification(storedNotification) -+ } else if let profileSwitchNotification = remoteNotification as? ProfileSwitchRemoteNotification { -+ let storedNotification = StoredRemoteNotification(profileSwitchNotification: profileSwitchNotification, notificationJSONData: data) -+ try storeNotification(storedNotification) - } else if let overrideNotification = remoteNotification as? OverrideRemoteNotification { - let storedNotification = StoredRemoteNotification(overrideNotification: overrideNotification, notificationJSONData: data) - try storeNotification(storedNotification) -diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..2fcec94 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -@@ -0,0 +1,71 @@ -+// -+// ProfileSwitchRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+import LoopKit -+import NightscoutKit -+@testable import NightscoutServiceKit -+ -+final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "sent-at": "2023-02-25T20:46:35.778Z", -+ "expiration": "2023-02-25T20:51:35.778Z", -+ "otp": "123456" -+ ] -+ -+ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) -+ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) -+ -+ XCTAssertEqual(profileSwitch.profileName, "Sport") -+ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") -+ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) -+ XCTAssertTrue(profileSwitch.otpValidationRequired()) -+ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { -+ return XCTFail("Expected a profile switch action") -+ } -+ XCTAssertEqual(action.name, "Sport") -+ } -+ -+ func testParseProfileSwitchNotification_MissingExpiration_Fails() { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "otp": "123456" -+ ] -+ -+ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) -+ } -+ -+ func testFingerprint_MatchesFollowerVector() { -+ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { -+ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } -+ } -+ let profile = ProfileSet.Profile( -+ timezone: TimeZone(secondsFromGMT: 3600)!, -+ dia: .hours(6), -+ sensitivity: items([(0, 45)]), -+ carbratio: items([(0, 10)]), -+ basal: items([(0, 0.85), (21600, 1.1)]), -+ targetLow: items([(0, 100)]), -+ targetHigh: items([(0, 110)]), -+ units: "mg/dL") -+ -+ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") -+ } -+} -+ -+private extension ISO8601DateFormatter { -+ static let fractional: ISO8601DateFormatter = { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ }() -+} -diff --git a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -index 0bfd600..bb9423b 100644 ---- a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -+++ b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -@@ -60,6 +60,8 @@ extension StoredRemoteNotification { - return "Edit Carbs" - case .override: - return "Override" -+ case .profileSwitch: -+ return "Profile Switch" - case .overrideCancel: - return "Cancel Override" - } -@@ -84,6 +86,8 @@ extension StoredRemoteNotification { - return String(format: "%.0f g", notification.amount) - case .override(let notification): - return notification.name -+ case .profileSwitch(let notification): -+ return notification.profileName - case .overrideCancel: - return "" - } -diff --git a/profiles.loopcustomization b/profiles.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/profiles/dev_profiles_remote_window.patch b/profiles/dev_profiles_remote_window.patch deleted file mode 100644 index 1bf1372..0000000 --- a/profiles/dev_profiles_remote_window.patch +++ /dev/null @@ -1,2231 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index 8cc5714a..c253e88e 100644 ---- a/Loop/Loop/Managers/DeviceDataManager.swift -+++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -433,6 +433,7 @@ final class DeviceDataManager { - ) - - statefulPluginManager = StatefulPluginManager(pluginManager: pluginManager, servicesManager: servicesManager) -+ servicesManager.deviceDataManager = self - - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore, glucoseStore, carbStore, dosingDecisionStore, doseStore, deviceLog, alertManager.alertStore] - criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, -@@ -1725,6 +1726,10 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { - } - } - -+ func updateCurrentProfileName() { -+ loopManager.updateCurrentProfileName() -+ } -+ - func saveCompletion(therapySettings: TherapySettings) { - - loopManager.mutateSettings { settings in -diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift -index ff60c424..0a5c29eb 100644 ---- a/Loop/Loop/Managers/LoopDataManager.swift -+++ b/Loop/Loop/Managers/LoopDataManager.swift -@@ -244,6 +244,10 @@ final class LoopDataManager { - lockedSettings.value - } - -+ func updateCurrentProfileName () { -+ notify(forChange: .preferences) -+ } -+ - func mutateSettings(_ changes: (_ settings: inout LoopSettings) -> Void) { - var oldValue: LoopSettings! - let newValue = lockedSettings.mutate { settings in -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 996d1470..17cff75d 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -263,6 +263,38 @@ extension NotificationManager { - } - } - -+ static func sendRemoteProfileNotification(name: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfile", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteProfileFailureNotification(name: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfileFailure", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ private static func remoteProfileNotificationBody(name: String) -> String { -+ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) -+ } -+ - private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { - return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) - } -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 0b498420..a9d9177d 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -28,6 +28,7 @@ class ServicesManager { - - weak var servicesManagerDelegate: ServicesManagerDelegate? - weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? -+ weak var deviceDataManager: DeviceDataManager? - - private var services = [Service]() - -@@ -447,3 +448,58 @@ extension Service { - ] - } - } -+ -+// MARK: - RemoteProfileActionDelegate -+ -+extension ServicesManager: RemoteProfileActionDelegate { -+ @MainActor -+ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { -+ do { -+ guard let deviceDataManager else { -+ throw RemoteProfileActionError.unavailable -+ } -+ let viewModel = ProfileViewModel(therapySettings: deviceDataManager.loopManager.therapySettings, delegate: deviceDataManager) -+ guard deviceDataManager.pumpSupportedIncrements() != nil else { -+ throw RemoteProfileActionError.noPump -+ } -+ guard let profile = try viewModel.savedProfile(named: name) else { -+ throw RemoteProfileActionError.profileNotFound(name) -+ } -+ try profile.verify(with: verify) -+ if case .failure(let error) = viewModel.validateProfile(profile) { -+ throw error -+ } -+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ continuation.resume() -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ NotificationManager.sendRemoteProfileNotification(name: name) -+ } catch { -+ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) -+ throw error -+ } -+ } -+ -+ enum RemoteProfileActionError: LocalizedError { -+ case unavailable -+ case noPump -+ case profileNotFound(String) -+ -+ var errorDescription: String? { -+ switch self { -+ case .unavailable: -+ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: device data manager missing.") -+ case .noPump: -+ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") -+ case .profileNotFound(let name): -+ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) -+ } -+ } -+ } -+} -diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift -index 9f9e3aa0..c1c9ea87 100644 ---- a/Loop/Loop/Views/SettingsView.swift -+++ b/Loop/Loop/Views/SettingsView.swift -@@ -50,6 +50,7 @@ public struct SettingsView: View { - } - - case favoriteFoods -+ case profiles - } - } - -@@ -119,6 +120,19 @@ public struct SettingsView: View { - switch sheet { - case .favoriteFoods: - FavoriteFoodsView() -+ case .profiles: -+ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), -+ sensitivityOverridesEnabled: FeatureFlags.sensitivityOverridesEnabled, -+ adultChildInsulinModelSelectionEnabled: FeatureFlags.adultChildInsulinModelSelectionEnabled, -+ delegate: self.viewModel.therapySettingsViewModelDelegate)) -+ .environmentObject(displayGlucosePreference) -+ .environment(\.dismissAction, self.dismiss) -+ .environment(\.appName, self.appName) -+ .environment(\.chartColorPalette, .primary) -+ .environment(\.carbTintColor, self.carbTintColor) -+ .environment(\.glucoseTintColor, self.glucoseTintColor) -+ .environment(\.guidanceColors, self.guidanceColors) -+ .environment(\.insulinTintColor, self.insulinTintColor) - } - } - } -@@ -279,6 +293,11 @@ extension SettingsView { - descriptiveText: NSLocalizedString("Diabetes Treatment", comment: "Descriptive text for Therapy Settings")) - } - -+ LargeButton(action: { sheet = .profiles }, -+ includeArrow: true, -+ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), -+ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), -+ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) - ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in - item.view - } -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj -index e4423ce6..89045896 100644 ---- a/LoopKit/LoopKit.xcodeproj/project.pbxproj -+++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj -@@ -873,6 +873,12 @@ - D1A0B000000000000000000C /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; - D1A0B000000000000000000D /* ModelMappingv4v5Tov6Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */; }; - D1A0B0000000000000000081 /* PersistenceControllerMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */; }; -+ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; -+ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; -+ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; -+ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; -+ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; -+ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; - E9077D2724ACD59F0066A88D /* InformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9077D2624ACD59F0066A88D /* InformationView.swift */; }; - E9077D2A24ACDE2C0066A88D /* CorrectionRangeInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9077D2924ACDE2C0066A88D /* CorrectionRangeInformationView.swift */; }; - E9086B2924B39EDC0062F5C8 /* ChartsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9086B2824B39EDC0062F5C8 /* ChartsTableViewController.swift */; }; -@@ -1813,6 +1819,12 @@ - D1A0B0000000000000000006 /* Modelv5ToModelv6.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = Modelv5ToModelv6.xcmappingmodel; sourceTree = ""; }; - D1A0B0000000000000000007 /* ModelMappingv4v5Tov6Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelMappingv4v5Tov6Policy.swift; sourceTree = ""; }; - D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; -+ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; -+ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; -+ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; -+ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; -+ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; -+ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; - E9077D2624ACD59F0066A88D /* InformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InformationView.swift; sourceTree = ""; }; - E9077D2924ACDE2C0066A88D /* CorrectionRangeInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionRangeInformationView.swift; sourceTree = ""; }; - E9086B2824B39EDC0062F5C8 /* ChartsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartsTableViewController.swift; sourceTree = ""; }; -@@ -3009,11 +3021,13 @@ - isa = PBXGroup; - children = ( - B455F3A325FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift */, -+ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, - B4B7C1BB2604E3FC007379F6 /* CorrectionRangeScheduleEditorViewModel.swift */, - B4D4C20C25F95A8700DA809D /* DisplayGlucosePreference.swift */, - B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, - B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, - 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, -+ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, - ); - path = ViewModels; - sourceTree = ""; -@@ -3201,6 +3215,10 @@ - E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, - 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, - 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, -+ DDAF746629F422C600719F0A /* ProfileView.swift */, -+ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, -+ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, -+ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, - ); - path = "Settings Editors"; - sourceTree = ""; -@@ -3910,6 +3928,7 @@ - E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, - B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, - 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, -+ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, - 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, - E9E5E56A24D5CCE800B5DFFE /* OverrideViewCell.swift in Sources */, - 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, -@@ -3919,6 +3938,7 @@ - B43D69D62A26642300DF0925 /* DemoPlaceHolderView.swift in Sources */, - E949E38F24B3711E00024DA0 /* InsulinModelInformationView.swift in Sources */, - 895FE08B22011F0C00FCF18A /* EmojiInputHeaderView.swift in Sources */, -+ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, - 895FE08322011F0C00FCF18A /* OverrideSelectionFooterView.swift in Sources */, - 89AF78C22447E353002B4FCC /* Splat.swift in Sources */, - 893C9F8C2447DBD900CD4185 /* CardBuilder.swift in Sources */, -@@ -3969,9 +3989,11 @@ - E9C58A6E24DA65E400487A17 /* HistoricalOverrideDetailView.swift in Sources */, - 895FE08222011F0C00FCF18A /* LabeledTextFieldTableViewCell.swift in Sources */, - B43DA44224D9CD8500CAFF4E /* Environment+Colors.swift in Sources */, -+ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, - 892A5DB22231E191008961AB /* LoadingTableViewCell.swift in Sources */, - B4D3D4AD25B8A94E0085BA0F /* DisplayGlucoseUnitObserver.swift in Sources */, - A9D3FF102A6C19CA000C891D /* ChartAxisValueDoubleLog.swift in Sources */, -+ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, - E96DCB5A24AF74AC007117BC /* SuspendThresholdEditor.swift in Sources */, - E96DCB5824AEF50F007117BC /* SuspendThresholdInformationView.swift in Sources */, - A9D3FF0C2A6C198C000C891D /* CollectionType.swift in Sources */, -@@ -4047,6 +4069,7 @@ - E93C86B624D08CAD0073089B /* InsulinSensitivityInformationView.swift in Sources */, - C18733AF29B9492300519CDF /* Collection.swift in Sources */, - 892A5DA22231E137008961AB /* HUDProvider.swift in Sources */, -+ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, - 898E6E6C224194060019E459 /* UIColor.swift in Sources */, - 43BA717D201EE7090058961E /* GlucoseRangeTableViewCell.swift in Sources */, - B455F3A425FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift in Sources */, -@@ -4073,6 +4096,7 @@ - 1D1065E9282DC54700026A70 /* VideoPlayView.swift in Sources */, - E99A132E2557548300D3F5B3 /* SegmentedGaugeBar.swift in Sources */, - B455F31825FBEC5F000ED456 /* SuspendThresholdEditorViewModel.swift in Sources */, -+ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, - 892A5DB32231E191008961AB /* LevelHUDView.swift in Sources */, - B41A60B223D1DBC700636320 /* UIFont.swift in Sources */, - B41A60AF23D1DB5B00636320 /* TableViewTitleLabel.swift in Sources */, -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index e4279746..6f691598 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -8,6 +8,12 @@ - - import Foundation - -+/// Switches to a saved therapy profile on a remote command. -+public protocol RemoteProfileActionDelegate: AnyObject { -+ /// `verify` receives the schedules about to be activated and throws to reject them. -+ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws -+} -+ - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride - func cancelRemoteOverride() async throws -> TemporaryScheduleOverride -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -new file mode 100644 -index 00000000..b571cf7b ---- /dev/null -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,466 @@ -+// -+// ProfileViewModel+FileManagement.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-19. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import LoopKit -+import HealthKit -+ -+public enum ProfileValidationError: Error, LocalizedError { -+ case fileLoadError -+ case unsupportedIncrement -+ case correctionRangeError -+ case insulinSensitivityError -+ case carbRatioError -+ case basalRateError -+ case maxBasalRateNotSet -+ -+ public var errorDescription: String? { -+ switch self { -+ case .fileLoadError: -+ return "Unable to load the profile file." -+ case .unsupportedIncrement: -+ return "The profile contains unsupported increments." -+ case .correctionRangeError: -+ return "Correction Range values are out of bounds." -+ case .insulinSensitivityError: -+ return "Insulin Sensitivity values are out of bounds." -+ case .carbRatioError: -+ return "Carb Ratio values are out of bounds." -+ case .basalRateError: -+ return "Basal Rate values are out of bounds." -+ case .maxBasalRateNotSet: -+ return "Maximum Basal Rate is not set." -+ } -+ } -+} -+ -+public enum LoadProfileResult { -+ case success -+ case failure(Error) -+} -+ -+// MARK: File management -+extension ProfileViewModel { -+ private func setCurrentProfile(name: String, syncChange: Bool = false) { -+ let sanitizedProfileName = sanitizeProfileName(name) -+ currentProfileName = sanitizedProfileName -+ if syncChange { -+ triggerProfileSync() -+ } -+ } -+ -+ private func clearCurrentProfile() { -+ currentProfileName = nil -+ triggerProfileSync() -+ } -+ -+ private func getCurrentProfileName() -> String? { -+ return currentProfileName -+ } -+ -+ private var profilesDirectory: URL { -+ let fileManager = FileManager.default -+ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] -+ return documentsDirectory.appendingPathComponent("LoopProfile") -+ } -+ -+ private func createProfileDirectoryIfNotExists() throws { -+ let fileManager = FileManager.default -+ var isDir : ObjCBool = false -+ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { -+ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) -+ } else if !isDir.boolValue { -+ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) -+ } -+ } -+ -+ private func getAllProfileFiles() throws -> [URL] { -+ let fileManager = FileManager.default -+ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) -+ return files.filter { $0.pathExtension == "json" } -+ } -+ -+ private func encodeProfile(_ profile: Profile) throws -> Data { -+ return try JSONEncoder().encode(profile) -+ } -+ -+ private func decodeProfile(from data: Data) throws -> Profile { -+ return try JSONDecoder().decode(Profile.self, from: data) -+ } -+ -+ public func sanitizeProfileName(_ name: String) -> String { -+ // Replace problematic characters -+ var sanitized = name.replacingOccurrences(of: ".", with: "_") -+ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") -+ -+ // Limit the name to 32 characters -+ if sanitized.count > 32 { -+ sanitized = String(sanitized.prefix(32)) -+ } -+ -+ return sanitized -+ } -+ -+ public func saveProfile(withName name: String) { -+ let sanitizedName = sanitizeProfileName(name) -+ -+ do { -+ try createProfileDirectoryIfNotExists() -+ -+ if let existingProfile = getProfileReference(withName: sanitizedName) { -+ removeProfile(profileReference: existingProfile) -+ } -+ -+ let profile = Profile( -+ name: sanitizedName, -+ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, -+ carbRatioSchedule: therapySettings.carbRatioSchedule!, -+ basalRateSchedule: therapySettings.basalRateSchedule!, -+ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, -+ sortOrder: -1 -+ ) -+ -+ let jsonData = try encodeProfile(profile) -+ -+ let dateFormatter = DateFormatter() -+ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" -+ let fileName = dateFormatter.string(from: Date()) + ".json" -+ let fileURL = profilesDirectory.appendingPathComponent(fileName) -+ -+ try jsonData.write(to: fileURL) -+ setCurrentProfile(name: sanitizedName, syncChange: true) -+ -+ self.loadProfiles() -+ } catch { -+ print("An error occurred while saving profile: \(error)") -+ } -+ } -+ -+ public func renameProfile(oldName: String, newName: String) { -+ let sanitizedProfileName = sanitizeProfileName(newName) -+ -+ if currentProfileName == oldName { -+ setCurrentProfile(name: sanitizedProfileName, syncChange: true) -+ } -+ -+ do { -+ guard let profileReference = getProfileReference(withName: oldName) else { -+ print("Profile with old name not found.") -+ return -+ } -+ -+ let profile = try getProfile(from: profileReference) -+ -+ let newProfile = Profile( -+ name: sanitizedProfileName, -+ correctionRange: profile.correctionRange, -+ carbRatioSchedule: profile.carbRatioSchedule, -+ basalRateSchedule: profile.basalRateSchedule, -+ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, -+ sortOrder: profile.sortOrder -+ ) -+ -+ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { -+ removeProfile(profileReference: existingProfile) -+ } -+ -+ let jsonData = try encodeProfile(newProfile) -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ -+ try jsonData.write(to: fileURL) -+ -+ self.loadProfiles() -+ triggerProfileSync() -+ } catch { -+ print("An error occurred while renaming profile: \(error)") -+ } -+ } -+ -+ public func updateProfilesOrder() { -+ for (index, profileReference) in profiles.enumerated() { -+ do { -+ var profile = try getProfile(from: profileReference) -+ let newProfile = Profile( -+ name: profile.name, -+ correctionRange: profile.correctionRange, -+ carbRatioSchedule: profile.carbRatioSchedule, -+ basalRateSchedule: profile.basalRateSchedule, -+ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, -+ sortOrder: index -+ ) -+ -+ let jsonData = try encodeProfile(newProfile) -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ try jsonData.write(to: fileURL) -+ -+ } catch { -+ print("An error occurred while updating profile order: \(error)") -+ } -+ } -+ loadProfiles() -+ } -+ -+ public func loadProfiles() { -+ do { -+ let profileFiles = try getAllProfileFiles() -+ -+ var newProfiles = [ProfileReference]() -+ for fileURL in profileFiles { -+ let data = try Data(contentsOf: fileURL) -+ let profile = try decodeProfile(from: data) -+ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) -+ newProfiles.append(profileRef) -+ } -+ -+ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } -+ -+ self.profiles = newProfiles -+ } catch { -+ print("An error occurred while loading profiles: \(error)") -+ } -+ } -+ -+ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { -+ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && -+ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && -+ therapySettings.basalRateSchedule == profile.basalRateSchedule && -+ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { -+ self.setCurrentProfile(name: profile.name, syncChange: true) -+ completion(.success) -+ } -+ else -+ { -+ do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in -+ switch result { -+ case .success(let syncedSchedule): -+ DispatchQueue.main.async { -+ let previousSettings = self.therapySettings -+ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! -+ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule -+ self.therapySettings.basalRateSchedule = syncedSchedule -+ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! -+ // One settings change, so the stored and uploaded settings never mix two profiles. -+ self.setCurrentProfile(name: profile.name, syncChange: false) -+ self.delegate?.saveCompletion(therapySettings: self.therapySettings) -+ if self.therapySettings == previousSettings { -+ // Nothing changed, so only the profile name needs to reach the uploaded settings. -+ self.triggerProfileSync() -+ } -+ print("New profile loaded") -+ completion(.success) -+ } -+ case .failure(let error): -+ print("An error occurred while syncing basal rates: \(error)") -+ completion(.failure(error)) -+ } -+ }) -+ } -+ } -+ } -+ -+ func getProfileReference(withName name: String) -> ProfileReference? { -+ let sanitizedInputName = sanitizeProfileName(name) -+ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) -+ } -+ -+ public func removeProfile(profile: Profile) { -+ guard let profileReference = getProfileReference(withName: profile.name) else { -+ print("No ProfileReference found for given Profile") -+ return -+ } -+ -+ removeProfile(profileReference: profileReference) -+ if getCurrentProfileName() == profile.name { -+ clearCurrentProfile() -+ } else { -+ triggerProfileSync() -+ } -+ } -+ -+ public func removeProfile(profileReference: ProfileReference) { -+ do { -+ let fileManager = FileManager.default -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ try fileManager.removeItem(at: fileURL) -+ loadProfiles() -+ } catch { -+ print("An error occurred while removing profile: \(error)") -+ } -+ } -+ -+ func doesProfileExist(withName name: String) -> Bool { -+ return profiles.contains(where: { $0.name == name }) -+ } -+ -+ public func getProfile(from profileReference: ProfileReference) throws -> Profile { -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ let data = try Data(contentsOf: fileURL) -+ let getProfile = try decodeProfile(from: data) -+ return getProfile -+ } -+ -+ /// The saved profile named `name`, or nil when there is none. -+ public func savedProfile(named name: String) throws -> Profile? { -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ return nil -+ } -+ return try getProfile(from: profileReference) -+ } -+ -+ public func validateProfile(_ profile: Profile) -> Result { -+ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { -+ return .failure(.fileLoadError) -+ } -+ -+ // Checking Correction Range Schedule -+ for item in profile.correctionRange.items { -+ let minValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) -+ let maxValue = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) -+ -+ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { -+ return .failure(.correctionRangeError) -+ } -+ } -+ -+ // Checking Insulin Sensitivity Schedule -+ for item in profile.insulinSensitivitySchedule.items { -+ let value = HKQuantity(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: .internationalUnit()), doubleValue: item.value) -+ -+ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { -+ return .failure(.insulinSensitivityError) -+ } -+ } -+ -+ // Checking Carb Ratio Schedule -+ for item in profile.carbRatioSchedule.items { -+ let value = HKQuantity(unit: .gramsPerUnit, doubleValue: item.value) -+ -+ if !Guardrail.carbRatio.absoluteBounds.contains(value) { -+ return .failure(.carbRatioError) -+ } -+ } -+ -+ // Checking Basal Rate Schedule -+ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { -+ for item in profile.basalRateSchedule.items { -+ let value = item.value -+ -+ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { -+ return .failure(.basalRateError) -+ } -+ } -+ } else { -+ return .failure(.maxBasalRateNotSet) -+ } -+ -+ return .success(()) -+ } -+ -+ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { -+ guard a.items.count == b.items.count else { -+ return false -+ } -+ -+ for (item1, item2) in zip(a.items, b.items) { -+ if item1.startTime != item2.startTime || -+ abs(item1.value.minValue - item2.value.minValue) > 0.01 || -+ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { -+ return false -+ } -+ } -+ -+ return true -+ } -+ -+ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { -+ guard a.items.count == b.items.count else { -+ return false -+ } -+ -+ for (item1, item2) in zip(a.items, b.items) { -+ if item1.startTime != item2.startTime || -+ abs(item1.value - item2.value) > 0.01 { -+ return false -+ } -+ } -+ -+ return true -+ } -+ -+ public func isCurrentProfileOutOfSync() -> Bool { -+ guard let currentProfileName = self.currentProfileName, -+ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), -+ let profile = try? getProfile(from: currentProfileReference), -+ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: HKUnit.milligramsPerDeciliter), -+ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: HKUnit.milligramsPerDeciliter) else { -+ return false // No current profile set or profile not found or schedules not available -+ } -+ -+ var isOutOfSync = false -+ -+ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: HKUnit.milligramsPerDeciliter)!) { -+ print("Glucose target range schedule differs") -+ isOutOfSync = true -+ } -+ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { -+ print("Carb ratio schedule differs") -+ isOutOfSync = true -+ } -+ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { -+ print("Basal rate schedule differs") -+ isOutOfSync = true -+ } -+ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: HKUnit.milligramsPerDeciliter)!) { -+ print("Insulin sensitivity schedule differs") -+ isOutOfSync = true -+ } -+ -+ return isOutOfSync -+ } -+} -+ -+extension Profile { -+ /// Passes the schedules this profile activates to `check`. -+ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { -+ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) -+ } -+} -+ -+extension ProfileViewModel { -+ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. -+ func therapySettings(applying profile: Profile) -> TherapySettings { -+ var settings = therapySettings -+ let unit = settings.glucoseTargetRangeSchedule?.unit ?? HKUnit.milligramsPerDeciliter -+ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) -+ settings.carbRatioSchedule = profile.carbRatioSchedule -+ settings.basalRateSchedule = profile.basalRateSchedule -+ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) -+ return settings -+ } -+ -+ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. -+ func updateProfile(named name: String, from settings: TherapySettings) throws { -+ loadProfiles() -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ throw ProfileValidationError.fileLoadError -+ } -+ let profile = try getProfile(from: profileReference) -+ let updatedProfile = Profile( -+ name: profile.name, -+ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, -+ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, -+ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, -+ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, -+ sortOrder: profile.sortOrder -+ ) -+ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) -+ loadProfiles() -+ triggerProfileSync() -+ } -+} -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift -new file mode 100644 -index 00000000..a48fa667 ---- /dev/null -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift -@@ -0,0 +1,86 @@ -+// -+// ProfileViewModel.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-04-22. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import Combine -+import LoopKit -+import HealthKit -+import SwiftUI -+ -+public class ProfileViewModel: ObservableObject { -+ -+ @Published public var therapySettings: TherapySettings -+ @Published public var profiles = [ProfileReference]() -+ @Published public var currentProfileName: String? { -+ didSet { -+ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") -+ } -+ } -+ -+ internal weak var delegate: TherapySettingsViewModelDelegate? -+ -+ public init(therapySettings: TherapySettings, -+ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, -+ sensitivityOverridesEnabled: Bool = false, -+ adultChildInsulinModelSelectionEnabled: Bool = false, -+ prescription: Prescription? = nil, -+ delegate: TherapySettingsViewModelDelegate? = nil) { -+ self.therapySettings = therapySettings -+ self.delegate = delegate -+ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") -+ self.loadProfiles() -+ } -+ -+ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { -+ return delegate?.pumpSupportedIncrements() -+ } -+} -+ -+public struct Profile: Codable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ var sortOrder: Int? -+} -+ -+public struct ProfileReference: Codable, Equatable { -+ var name: String -+ var fileName: String -+ var sortOrder: Int? -+} -+ -+ -+// MARK: Saving -+extension ProfileViewModel { -+ -+ public func saveCorrectionRange(range: GlucoseRangeSchedule) { -+ therapySettings.glucoseTargetRangeSchedule = range -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveBasalRates(basalRates: BasalRateSchedule) { -+ therapySettings.basalRateSchedule = basalRates -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { -+ therapySettings.carbRatioSchedule = carbRatioSchedule -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { -+ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func triggerProfileSync() { -+ delegate?.updateCurrentProfileName() -+ } -+} -diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -index 3650ef60..c5c1dc01 100644 ---- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -+++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { - func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) - func saveCompletion(therapySettings: TherapySettings) - func pumpSupportedIncrements() -> PumpSupportedIncrements? -+ func updateCurrentProfileName() - } - - public class TherapySettingsViewModel: ObservableObject { -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift -new file mode 100644 -index 00000000..446910e4 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift -@@ -0,0 +1,79 @@ -+// -+// NewProfileEditor.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-19. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+ -+struct NewProfileEditor: View { -+ @Binding var isPresented: Bool -+ @State var newProfileName: String -+ var viewModel: ProfileViewModel -+ @State private var showAlert = false -+ -+ var body: some View { -+ VStack(spacing: 0) { -+ ModalHeaderButtonBar( -+ leading: { cancelButton }, -+ center: { -+ Text("New Profile") -+ .font(.headline) -+ }, -+ trailing: { addButton } -+ ) -+ -+ TextField("Profile Name", text: $newProfileName) -+ .textFieldStyle(RoundedBorderTextFieldStyle()) -+ .padding() -+ .background( -+ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) -+ .fill(Color(.secondarySystemGroupedBackground)) -+ ) -+ } -+ .padding(.horizontal) -+ .alert(isPresented: $showAlert) { -+ Alert( -+ title: Text("Overwrite Existing Profile"), -+ message: Text("A profile with this name already exists. Would you like to overwrite it?"), -+ primaryButton: .destructive(Text("Overwrite")) { -+ self.viewModel.saveProfile(withName: self.newProfileName) -+ self.isPresented = false -+ }, -+ secondaryButton: .cancel() -+ ) -+ } -+ } -+ -+ var addButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ if viewModel.doesProfileExist(withName: self.newProfileName) { -+ self.showAlert = true -+ } else { -+ self.viewModel.saveProfile(withName: self.newProfileName) -+ self.isPresented = false -+ } -+ } -+ }, label: { -+ Text("Add") -+ } -+ ) -+ } -+ -+ var cancelButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ self.isPresented = false -+ } -+ }, label: { -+ Text("Cancel") -+ } -+ ) -+ } -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -new file mode 100644 -index 00000000..a956b6d5 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,423 @@ -+// -+// ProfilePreviewView.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-23. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+import HealthKit -+import LoopKit -+ -+enum ActiveAlert { -+ case load -+ case delete -+ case error -+ case loadEdited -+} -+ -+struct ProfilePreviewView: View { -+ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference -+ @ObservedObject var viewModel: ProfileViewModel -+ @Environment(\.dismissAction) var dismissAction -+ @Environment(\.presentationMode) var presentationMode -+ @Environment(\.appName) private var appName -+ @State private var profile: Profile -+ @State private var editingDelegate: ProfileEditingDelegate -+ @State private var therapySettingsViewModel: TherapySettingsViewModel -+ -+ @State private var showingAlert = false -+ @State private var activeAlert: ActiveAlert = .load -+ @State private var errorText: String? -+ @State private var newProfileName: String = "" -+ @State private var isRenamingProfile = false -+ @State private var shouldDismissSelf: Bool = false -+ -+ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } -+ -+ init(viewModel: ProfileViewModel, profile: Profile) { -+ self.viewModel = viewModel -+ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) -+ _profile = State(initialValue: profile) -+ _editingDelegate = State(initialValue: editingDelegate) -+ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( -+ therapySettings: viewModel.therapySettings(applying: profile), -+ delegate: editingDelegate)) -+ } -+ -+ var body: some View { -+ ZStack { -+ ScrollView { -+ VStack(alignment: .leading) { -+ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") -+ .font(.body) -+ .foregroundColor(.primary) -+ .padding([.leading, .trailing]) -+ .fixedSize(horizontal: false, vertical: true) -+ .textCase(nil) -+ -+ profileCardStack -+ -+ Button(action: { -+ activeAlert = .load -+ showingAlert = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Load") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.blue) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ Button(action: { -+ isRenamingProfile = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Rename") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.orange) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ Button(action: { -+ activeAlert = .delete -+ showingAlert = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Delete") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.red) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ } -+ } -+ .navigationBarHidden(false) -+ .background(Color(.systemGroupedBackground)) -+ .navigationTitle(Text(profile.name)) -+ .alert(isPresented: $showingAlert) { -+ switch activeAlert { -+ case .load: -+ return Alert( -+ title: Text("Load Profile"), -+ message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: load), -+ secondaryButton: .cancel() -+ ) -+ case .delete: -+ return Alert( -+ title: Text("Delete Profile"), -+ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), -+ primaryButton: .destructive(Text("Delete"), action: { -+ viewModel.removeProfile(profile: profile) -+ self.presentationMode.wrappedValue.dismiss() -+ }), -+ secondaryButton: .cancel() -+ ) -+ case .error: -+ return Alert( -+ title: Text("Error loading profile"), -+ message: Text(errorText ?? "Unknown error"), -+ dismissButton: .default(Text("OK")) -+ ) -+ case .loadEdited: -+ return Alert( -+ title: Text("Load Edited Profile?"), -+ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), -+ primaryButton: .default(Text("Load"), action: load), -+ secondaryButton: .cancel(Text("Later")) -+ ) -+ } -+ } -+ if isRenamingProfile { -+ // Some hacks here to mimic Alert behaviour. -+ // This function should be migrateed to Alert when iOS 16 is required. -+ Color.black.opacity(0.5) -+ .edgesIgnoringSafeArea(.all) -+ .onTapGesture { -+ isRenamingProfile = false -+ } -+ -+ RenameProfileEditor( -+ isPresented: $isRenamingProfile, -+ currentProfileName: profile.name, -+ viewModel: viewModel, -+ shouldDismissParent: $shouldDismissSelf -+ ) -+ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) -+ .offset(y: -22) -+ } -+ } -+ .onAppear { -+ // Runs again when an editor closes. -+ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } -+ profile = saved -+ // The editors show the saved values, also after a save that failed. -+ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) -+ if let error = editingDelegate.saveError { -+ editingDelegate.saveError = nil -+ presentAlertLater(.error, errorText: error.localizedDescription) -+ } else if editingDelegate.savedEdits { -+ editingDelegate.savedEdits = false -+ if viewModel.currentProfileName == profile.name { -+ presentAlertLater(.loadEdited) -+ } -+ } -+ } -+ .onChange(of: shouldDismissSelf) { shouldDismiss in -+ if shouldDismiss { -+ self.presentationMode.wrappedValue.dismiss() -+ } -+ } -+ } -+ -+ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ guard !showingAlert else { return } -+ self.errorText = errorText -+ activeAlert = alert -+ showingAlert = true -+ } -+ } -+ -+ private func load() { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ -+ private var profileCardStack: CardStack { -+ var cards: [Card] = [] -+ -+ cards.append(correctionRangeSection) -+ cards.append(carbRatioSection) -+ cards.append(basalRatesSection) -+ cards.append(insulinSensitivitiesSection) -+ -+ return CardStack(cards: cards) -+ } -+ -+ private var correctionRangeSection: Card { -+ card(for: .glucoseTargetRange) { -+ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { -+ SectionDivider() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleRangeItem(time: items[index].startTime, -+ range: items[index].value, -+ unit: glucoseUnit, -+ guardrail: .correctionRange) -+ } -+ } -+ } -+ } -+ -+ private var carbRatioSection: Card { -+ card(for: .carbRatio) { -+ SectionDivider() -+ let items = profile.carbRatioSchedule.items -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: .gramsPerUnit, -+ guardrail: .carbRatio) -+ } -+ } -+ } -+ -+ private var basalRatesSection: Card { -+ card(for: .basalRate) { -+ let items = profile.basalRateSchedule.items -+ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates -+ { -+ SectionDivider() -+ let total = profile.basalRateSchedule.total() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: .internationalUnitsPerHour, -+ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) -+ } -+ HStack { -+ Text("Total") -+ .bold() -+ .foregroundColor(.primary) -+ Spacer() -+ Text(String(format: "%.2f ",total)) -+ .foregroundColor(.primary) + -+ Text("U/day") -+ .foregroundColor(.secondary) -+ } -+ } -+ } -+ } -+ -+ private var insulinSensitivitiesSection: Card { -+ card(for: .insulinSensitivity) { -+ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items -+ { -+ SectionDivider() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: sensitivityUnit, -+ guardrail: .insulinSensitivity) -+ } -+ } -+ } -+ } -+} -+ -+ -+// MARK: Utilities -+extension ProfilePreviewView { -+ -+ private var glucoseUnit: HKUnit { -+ displayGlucosePreference.unit -+ } -+ -+ private var sensitivityUnit: HKUnit { -+ glucoseUnit.unitDivided(by: .internationalUnit()) -+ } -+ -+ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { -+ Card { -+ SectionWithTapToEdit( -+ // The basal editor needs the pump's supported rates. -+ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, -+ title: therapySetting.title, -+ descriptiveText: therapySetting.descriptiveText(appName: appName), -+ destination: { dismiss in -+ editor(for: therapySetting, dismiss: dismiss) -+ .environment(\.dismissAction, dismiss) -+ }, -+ content: content -+ ) -+ } -+ } -+ -+ /// The therapy settings editors, saving to this profile. -+ @ViewBuilder -+ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { -+ switch therapySetting { -+ case .glucoseTargetRange: -+ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .carbRatio: -+ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .basalRate: -+ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .insulinSensitivity: -+ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ default: -+ EmptyView() -+ } -+ } -+} -+ -+fileprivate struct SectionDivider: View { -+ var body: some View { -+ Divider() -+ .padding(.trailing, -16) -+ } -+} -+ -+fileprivate struct SettingsDivider: View { -+ var body: some View { -+ Divider() -+ .padding(.trailing, -8) -+ } -+} -+ -+/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. -+final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { -+ private let viewModel: ProfileViewModel -+ private let profileName: String -+ private let basalTimeZone: TimeZone -+ /// Set when edits were saved, so the view can offer to load the edited active profile. -+ var savedEdits = false -+ /// Set when saving edits to the profile file failed. -+ var saveError: Error? -+ -+ init(viewModel: ProfileViewModel, profile: Profile) { -+ self.viewModel = viewModel -+ self.profileName = profile.name -+ self.basalTimeZone = profile.basalRateSchedule.timeZone -+ } -+ -+ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { -+ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { -+ completion(.failure(ProfileValidationError.basalRateError)) -+ return -+ } -+ completion(.success(schedule)) -+ } -+ -+ func syncDeliveryLimits(deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { -+ completion(.success(deliveryLimits)) -+ } -+ -+ func saveCompletion(therapySettings: TherapySettings) { -+ do { -+ try viewModel.updateProfile(named: profileName, from: therapySettings) -+ savedEdits = true -+ } catch { -+ saveError = error -+ } -+ } -+ -+ func pumpSupportedIncrements() -> PumpSupportedIncrements? { -+ return viewModel.pumpSupportedIncrements() -+ } -+ -+ func updateCurrentProfileName() {} -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift -new file mode 100644 -index 00000000..f816492b ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift -@@ -0,0 +1,173 @@ -+// -+// ProfileView.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-04-22. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import AVFoundation -+import HealthKit -+import LoopKit -+import SwiftUI -+ -+public struct ProfileView: View { -+ @ObservedObject public var viewModel: ProfileViewModel -+ @Environment(\.dismissAction) var dismissAction -+ @State private var newProfileName: String = "" -+ @State private var isAddingNewProfile = false -+ @State private var selectedProfileIndex: Int? = nil -+ @State private var refreshID = UUID() -+ -+ enum ActiveAlert: String, Identifiable { -+ case update, info -+ -+ var id: String { -+ return self.rawValue -+ } -+ } -+ @State private var activeAlert: ActiveAlert? -+ -+ public init(viewModel: ProfileViewModel) { -+ self.viewModel = viewModel -+ } -+ -+ private var dismissButton: some View { -+ Button(action: dismissAction) { -+ Text(LocalizedString("Done", comment: "Text for dismiss button")) -+ .bold() -+ } -+ } -+ -+ public var body: some View { -+ ZStack { -+ NavigationView { -+ ConfigurationPageScrollView( -+ content: { -+ VStack(alignment: .leading) { -+ -+ if !viewModel.profiles.isEmpty { -+ List { -+ ForEach(viewModel.profiles.indices, id: \.self) { index in -+ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { -+ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { -+ HStack { -+ if viewModel.currentProfileName == viewModel.profiles[index].name { -+ Image(systemName: "checkmark") -+ .foregroundColor(Color.blue) -+ } else { -+ // Add indentation to align with the checkmark for the active profile -+ Image(systemName: "checkmark") -+ .opacity(0) // Invisible -+ } -+ -+ Text(viewModel.profiles[index].name) -+ } -+ } -+ } -+ } -+ .onMove(perform: moveProfile) -+ }.id(refreshID) -+ -+ } else { -+ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") -+ .font(.subheadline) -+ .foregroundColor(.secondary) -+ .padding([.leading, .trailing]) -+ .multilineTextAlignment(.leading) -+ .lineLimit(nil) -+ .textCase(nil) -+ } -+ } -+ }, -+ actionArea: { EmptyView() } // no action area in this case -+ ) -+ .navigationBarItems( -+ leading: HStack { -+ Button(action: { withAnimation { isAddingNewProfile = true } }) { -+ Image(systemName: "plus") -+ } -+ -+ Button(action: { activeAlert = .info }) { -+ Image(systemName: "info.circle") -+ .resizable() -+ .frame(width: 24, height: 24) -+ } -+ }, -+ trailing: dismissButton -+ ) -+ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) -+ } -+ .navigationBarHidden(false) -+ .background(Color(.systemGroupedBackground)) -+ .onAppear { -+ if viewModel.isCurrentProfileOutOfSync() { -+ activeAlert = .update -+ } -+ } -+ -+ .alert(item: $activeAlert) { alertType in -+ switch alertType { -+ case .update: -+ return Alert( -+ title: Text("Profile Outdated"), -+ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), -+ primaryButton: .default(Text("Save Current Settings")) { -+ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") -+ }, -+ secondaryButton: .cancel(Text("Keep Saved Profile")) -+ ) -+ case .info: -+ return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), -+ dismissButton: .default(Text("Got it!"))) -+ } -+ } -+ -+ if isAddingNewProfile { -+ DarkenedOverlay() -+ -+ NewProfileEditor( -+ isPresented: $isAddingNewProfile, -+ newProfileName: newProfileName, -+ viewModel: viewModel -+ ) -+ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) -+ } -+ } -+ } -+ -+ func moveProfile(from source: IndexSet, to destination: Int) { -+ viewModel.profiles.move(fromOffsets: source, toOffset: destination) -+ refreshID = UUID() -+ viewModel.updateProfilesOrder() -+ } -+} -+ -+struct ProfileView_Previews: PreviewProvider { -+ static let preview_glucoseScheduleItems = [ -+ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), -+ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), -+ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) -+ ] -+ -+ static let preview_therapySettings = TherapySettings( -+ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), -+ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), -+ workout: DoubleRange(99...111), -+ unit: .milligramsPerDeciliter), -+ maximumBolus: 4, -+ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), -+ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: HKUnit.internationalUnit()), dailyItems: []), -+ carbRatioSchedule: nil, -+ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) -+ -+ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] -+ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] -+ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] -+ -+ static var previews: some View { -+ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) -+ } -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift -new file mode 100644 -index 00000000..8fd26665 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift -@@ -0,0 +1,92 @@ -+// -+// RenameProfileEditor.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-09-07. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+ -+struct RenameProfileEditor: View { -+ @Binding var isPresented: Bool -+ @State var currentProfileName: String -+ @State var newProfileName: String = "" -+ var viewModel: ProfileViewModel -+ @State private var showAlert = false -+ @Binding var shouldDismissParent: Bool -+ -+ var body: some View { -+ VStack(spacing: 0) { -+ ModalHeaderButtonBar( -+ leading: { cancelButton }, -+ center: { -+ Text("Rename Profile") -+ .font(.subheadline) -+ -+ }, -+ trailing: { renameButton } -+ ) -+ .onAppear { -+ self.newProfileName = currentProfileName -+ } -+ -+ TextField("Profile Name", text: $newProfileName) -+ .textFieldStyle(RoundedBorderTextFieldStyle()) -+ .padding() -+ .background( -+ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) -+ .fill(Color(.secondarySystemGroupedBackground)) -+ ) -+ } -+ .padding(.horizontal) -+ .alert(isPresented: $showAlert) { -+ Alert( -+ title: Text("Overwrite Existing Profile"), -+ message: Text("A profile with this name already exists. Would you like to overwrite it?"), -+ primaryButton: .destructive(Text("Overwrite")) { -+ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) -+ self.shouldDismissParent = true -+ self.isPresented = false -+ }, -+ secondaryButton: .cancel() -+ ) -+ } -+ } -+ -+ var renameButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ guard newProfileName != currentProfileName else { -+ self.isPresented = false -+ return -+ } -+ -+ if viewModel.doesProfileExist(withName: self.newProfileName) { -+ self.showAlert = true -+ } else { -+ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) -+ self.shouldDismissParent = true -+ self.isPresented = false -+ } -+ } -+ }, label: { -+ Text("Rename") -+ } -+ ) -+ } -+ -+ var cancelButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ self.isPresented = false -+ } -+ }, label: { -+ Text("Cancel") -+ } -+ ) -+ } -+} -Submodule LoopOnboarding contains modified content -diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -index cc61f2d..6462e2c 100644 ---- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -+++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -@@ -379,6 +379,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { - maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount - ) - } -+ -+ func updateCurrentProfileName() { -+ } - } - - -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index 69f7a26..14a7691 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -24,6 +24,10 @@ - A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; -@@ -158,6 +162,10 @@ - A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; - A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; -@@ -268,6 +276,7 @@ - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, - A934B6172358F49300949C8B /* StoredSettings.swift */, -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, - A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, - A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, -@@ -324,6 +333,7 @@ - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, - A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, - ); - path = NightscoutServiceKitTests; -@@ -364,6 +374,7 @@ - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, - ); - path = Notifications; - sourceTree = ""; -@@ -412,6 +423,7 @@ - A9AA6E5729EB06AA008FFA78 /* Actions */ = { - isa = PBXGroup; - children = ( -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -@@ -744,6 +756,9 @@ - C1398D2227C434FB00416AD6 /* Data.swift in Sources */, - A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, - A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, - C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, - ); -@@ -757,6 +772,7 @@ - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, - A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -new file mode 100644 -index 0000000..de73ca9 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -@@ -0,0 +1,104 @@ -+// -+// SavedProfile.swift -+// NightscoutServiceKit -+// -+ -+import CryptoKit -+import Foundation -+import HealthKit -+import LoopKit -+import NightscoutKit -+ -+/// A profile saved with the Profiles settings screen, read from the file it was saved to. -+struct SavedProfile: Decodable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ -+ private static var directory: URL { -+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") -+ } -+ -+ static func loadAll() -> [SavedProfile] { -+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] -+ return files -+ .filter { $0.pathExtension == "json" } -+ .compactMap { try? Data(contentsOf: $0) } -+ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } -+ } -+ -+ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. -+ static func nightscoutStore(unit: HKUnit) -> [String: ProfileSet.Profile] { -+ var store: [String: ProfileSet.Profile] = [:] -+ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. -+ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { -+ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) -+ } -+ return store -+ } -+ -+ /// True when this profile uploads with this fingerprint, in either glucose unit. -+ func matches(fingerprint: String) -> Bool { -+ [HKUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { -+ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint -+ } -+ } -+ -+ func nightscoutProfile(unit: HKUnit) -> ProfileSet.Profile? { -+ guard let correctionRange = correctionRange.schedule(for: unit), -+ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) -+ else { -+ return nil -+ } -+ return ProfileSet.Profile( -+ timezone: basalRateSchedule.timeZone, -+ dia: .hours(6), -+ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, -+ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, -+ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) -+ } -+} -+ -+extension ProfileSet.Profile { -+ /// Followers compute the same value from the uploaded profile and send it with a switch command, -+ /// so Loop only activates the values the follower previewed. -+ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` -+ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. -+ var remoteFingerprint: String { -+ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { -+ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") -+ } -+ let canonical = [ -+ "basal=" + items(basal), -+ "carbratio=" + items(carbratio), -+ "sens=" + items(sensitivity), -+ "target_low=" + items(targetLow), -+ "target_high=" + items(targetHigh), -+ ].joined(separator: ";") -+ let digest = SHA256.hash(data: Data(canonical.utf8)) -+ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) -+ } -+} -+ -+enum RemoteProfileSwitchError: LocalizedError { -+ case profileChanged -+ case unavailable -+ case notActivated(String, Error) -+ -+ /// Followers read the profile name from this text to match the reply to their command. -+ var errorDescription: String? { -+ switch self { -+ case .profileChanged: -+ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") -+ case .unavailable: -+ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") -+ case .notActivated(let name, let error): -+ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index 4e1d49e..b66fb97 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -159,7 +159,8 @@ extension StoredDosingDecision { - automaticDoseRecommendation: loopStatusAutomaticDoseRecommendation, - recommendedBolus: loopStatusRecommendedBolus, - enacted: automaticDoseDecision?.loopStatusEnacted, -- failureReason: automaticDoseDecision?.loopStatusFailureReason), -+ failureReason: automaticDoseDecision?.loopStatusFailureReason, -+ testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]]), - overrideStatus: overrideStatus) - } - -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -index 2adadb9..dd7ac28 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -@@ -94,12 +94,21 @@ extension StoredSettings { - return nil - } - -+ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) -+ store["Default"] = profile -+ var defaultProfile = "Default" -+ -+ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { -+ defaultProfile = name -+ store[name] = profile -+ } -+ - return ProfileSet( - startDate: date, - units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), - enteredBy: "Loop", -- defaultProfile: "Default", -- store: ["Default": profile], -+ defaultProfile: defaultProfile, -+ store: store, - settings: loopSettings, - syncIdentifier: syncIdentifier.uuidString, - isAPNSProduction: Bundle.main.isAPNSProductionEnvironment) -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index a92b509..29313b6 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -9,6 +9,7 @@ - import Foundation - - public enum Action: Codable { -+ case profileSwitch(ProfileSwitchAction) - case temporaryScheduleOverride(OverrideAction) - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) -@@ -28,6 +29,8 @@ public enum Action: Codable { - return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") - case .temporaryScheduleOverride: - return LocalizedString("Override", comment: "The remote action name for Override") -+ case .profileSwitch: -+ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") - } - } - -@@ -43,6 +46,8 @@ public enum Action: Codable { - return "" - case .temporaryScheduleOverride(let overrideAction): - return overrideAction.name -+ case .profileSwitch(let profileSwitchAction): -+ return profileSwitchAction.name - } - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -new file mode 100644 -index 0000000..ce03ded ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -@@ -0,0 +1,17 @@ -+// -+// ProfileSwitchAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct ProfileSwitchAction: Codable { -+ -+ public let name: String -+ public let fingerprint: String -+ -+ public init(name: String, fingerprint: String) { -+ self.name = name -+ self.fingerprint = fingerprint -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 95274e4..4457f80 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -45,6 +45,7 @@ class RemoteNotificationResponseManager { - case carbs = "carbs" - case override = "override" - case cancelOverride = "cancel_override" -+ case profileSwitch = "profile_switch" - } - - func sendResponseNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -new file mode 100644 -index 0000000..ebcd6ef ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -0,0 +1,50 @@ -+// -+// ProfileSwitchRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Switches to a saved profile. `expiration` is required: decoding fails without it. -+public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { -+ -+ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. -+ static let commandKey = "profile-switch" -+ -+ public let profileName: String -+ public let fingerprint: String -+ public let remoteAddress: String -+ public let expirationDate: Date -+ public let sentAt: Date -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ public var expiration: Date? { -+ return expirationDate -+ } -+ -+ enum CodingKeys: String, CodingKey { -+ case profileName = "profile-switch" -+ case fingerprint = "profile-switch-fingerprint" -+ case remoteAddress = "remote-address" -+ case expirationDate = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification[commandKey] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index 24e63d7..a0f58a0 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -104,6 +104,8 @@ extension Dictionary { - return try OverrideRemoteNotification(dictionary: self) - } else if OverrideCancelRemoteNotification.includedInNotification(self) { - return try OverrideCancelRemoteNotification(dictionary: self) -+ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { -+ return try ProfileSwitchRemoteNotification(dictionary: self) - } else { - throw RemoteNotificationError.unhandledNotification(self) - } -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -index 84ff118..0912feb 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -@@ -96,6 +96,27 @@ class RemoteCommandSourceV1: RemoteCommandSource { - status: .success(date: Date(), syncIdentifier: carbEntry.syncIdentifier ?? "", completionMessage: nil), - notificationJSON: notification - ) -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ successMessage = "Profile '\(profileSwitchCommand.name)' activated" -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .success(date: Date(), syncIdentifier: "", completionMessage: nil), -+ notificationJSON: notification -+ ) - case .temporaryScheduleOverride(let overrideCommand): - commandType = .override - let override = try await serviceDelegate.enactRemoteOverride( -@@ -223,6 +244,10 @@ public class StoredRemoteNotification: NSObject, Codable { - self.init(notificationType: .carbs(carbNotification), notificationJSONData: notificationJSONData) - } - -+ convenience init(profileSwitchNotification: ProfileSwitchRemoteNotification, notificationJSONData: Data) { -+ self.init(notificationType: .profileSwitch(profileSwitchNotification), notificationJSONData: notificationJSONData) -+ } -+ - convenience init(overrideNotification: OverrideRemoteNotification, notificationJSONData: Data) { - self.init(notificationType: .override(overrideNotification), notificationJSONData: notificationJSONData) - } -@@ -306,6 +331,8 @@ public class StoredRemoteNotification: NSObject, Codable { - return bolusNotification - case let .carbs(carbNotification): - return carbNotification -+ case let .profileSwitch(profileSwitchNotification): -+ return profileSwitchNotification - case let .override(overrideNotification): - return overrideNotification - case let .overrideCancel(overrideCancelNotification): -@@ -334,6 +361,7 @@ public class StoredRemoteNotification: NSObject, Codable { - case carbs(CarbRemoteNotification) - case override(OverrideRemoteNotification) - case overrideCancel(OverrideCancelRemoteNotification) -+ case profileSwitch(ProfileSwitchRemoteNotification) - } - } - -@@ -384,6 +412,9 @@ actor RecentNotifications { - } else if let carbNotification = remoteNotification as? CarbRemoteNotification { - let storedNotification = StoredRemoteNotification(carbNotification: carbNotification, notificationJSONData: data) - try storeNotification(storedNotification) -+ } else if let profileSwitchNotification = remoteNotification as? ProfileSwitchRemoteNotification { -+ let storedNotification = StoredRemoteNotification(profileSwitchNotification: profileSwitchNotification, notificationJSONData: data) -+ try storeNotification(storedNotification) - } else if let overrideNotification = remoteNotification as? OverrideRemoteNotification { - let storedNotification = StoredRemoteNotification(overrideNotification: overrideNotification, notificationJSONData: data) - try storeNotification(storedNotification) -diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..2fcec94 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -@@ -0,0 +1,71 @@ -+// -+// ProfileSwitchRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+import LoopKit -+import NightscoutKit -+@testable import NightscoutServiceKit -+ -+final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "sent-at": "2023-02-25T20:46:35.778Z", -+ "expiration": "2023-02-25T20:51:35.778Z", -+ "otp": "123456" -+ ] -+ -+ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) -+ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) -+ -+ XCTAssertEqual(profileSwitch.profileName, "Sport") -+ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") -+ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) -+ XCTAssertTrue(profileSwitch.otpValidationRequired()) -+ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { -+ return XCTFail("Expected a profile switch action") -+ } -+ XCTAssertEqual(action.name, "Sport") -+ } -+ -+ func testParseProfileSwitchNotification_MissingExpiration_Fails() { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "otp": "123456" -+ ] -+ -+ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) -+ } -+ -+ func testFingerprint_MatchesFollowerVector() { -+ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { -+ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } -+ } -+ let profile = ProfileSet.Profile( -+ timezone: TimeZone(secondsFromGMT: 3600)!, -+ dia: .hours(6), -+ sensitivity: items([(0, 45)]), -+ carbratio: items([(0, 10)]), -+ basal: items([(0, 0.85), (21600, 1.1)]), -+ targetLow: items([(0, 100)]), -+ targetHigh: items([(0, 110)]), -+ units: "mg/dL") -+ -+ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") -+ } -+} -+ -+private extension ISO8601DateFormatter { -+ static let fractional: ISO8601DateFormatter = { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ }() -+} -diff --git a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -index 2a3fff3..4ce6ab7 100644 ---- a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -+++ b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -@@ -56,6 +56,8 @@ extension StoredRemoteNotification { - return "Carbs" - case .override: - return "Override" -+ case .profileSwitch: -+ return "Profile Switch" - case .overrideCancel: - return "Cancel Override" - } -@@ -76,6 +78,8 @@ extension StoredRemoteNotification { - return String(format: "%.0f g", notification.amount) - case .override(let notification): - return notification.name -+ case .profileSwitch(let notification): -+ return notification.profileName - case .overrideCancel: - return "" - } -diff --git a/profiles.loopcustomization b/profiles.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/profiles/nextdev_profiles.patch b/profiles/nextdev_profiles.patch index 09eaa25..d2f4339 100644 --- a/profiles/nextdev_profiles.patch +++ b/profiles/nextdev_profiles.patch @@ -1,9 +1,9 @@ Submodule Loop contains modified content diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index 3c9d61cb..5591c9a5 100644 +index 856aaca6..1cd6181e 100644 --- a/Loop/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -1407,6 +1407,13 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { +@@ -1408,6 +1408,13 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { return try await pumpManager?.syncDeliveryLimits(limits: deliveryLimits) ?? deliveryLimits } @@ -29,64 +29,22 @@ index 3e1110d1..cc990cd0 100644 let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, temporaryPresetsManager.presetHistory, deviceDataManager.deviceLog, alertManager.alertStore] criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, -diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift -index 30ab8fb2..28f07307 100644 ---- a/Loop/Loop/Managers/LoopDataManager.swift -+++ b/Loop/Loop/Managers/LoopDataManager.swift -@@ -1684,7 +1684,7 @@ extension LoopDataManager: ManualDoseViewModelDelegate { - var settings: StoredSettings { - settingsProvider.settings - } -- -+ - var scheduleOverride: TemporaryScheduleOverride? { - temporaryPresetsManager.scheduleOverride - } -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 42eff19f..c4d0f32e 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -298,6 +298,38 @@ extension NotificationManager { - UNUserNotificationCenter.current().add(request) - } - -+ static func sendRemoteProfileNotification(name: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfile", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteProfileFailureNotification(name: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfileFailure", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ private static func remoteProfileNotificationBody(name: String) -> String { -+ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) -+ } -+ - private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { - return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) +diff --git a/Loop/Loop/Managers/RemoteDataServicesManager.swift b/Loop/Loop/Managers/RemoteDataServicesManager.swift +index b28583fb..5a738613 100644 +--- a/Loop/Loop/Managers/RemoteDataServicesManager.swift ++++ b/Loop/Loop/Managers/RemoteDataServicesManager.swift +@@ -634,6 +634,9 @@ extension RemoteDataServicesManager { + + public func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async throws { + let service = try serviceForPushNotification(notification) ++ if let receiver = service as? RemoteProfileSwitchReceiver, receiver.isProfileSwitch(notification) { ++ return await receiver.profileSwitchWasReceived(notification) ++ } + return try await service.remoteNotificationWasReceived(notification) } + diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index b557f184..a7da8548 100644 +index b557f184..7fd5b74d 100644 --- a/Loop/Loop/Managers/ServicesManager.swift +++ b/Loop/Loop/Managers/ServicesManager.swift @@ -30,6 +30,7 @@ class ServicesManager { @@ -97,7 +55,7 @@ index b557f184..a7da8548 100644 private var services = [Service]() -@@ -423,3 +424,57 @@ extension Service { +@@ -423,3 +424,73 @@ extension Service { ] } } @@ -105,6 +63,7 @@ index b557f184..a7da8548 100644 +// MARK: - RemoteProfileActionDelegate + +extension ServicesManager: RemoteProfileActionDelegate { ++ @MainActor + func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { + do { + guard let therapySettingsViewModelDelegate else { @@ -131,13 +90,28 @@ index b557f184..a7da8548 100644 + } + } + } -+ NotificationManager.sendRemoteProfileNotification(name: name) ++ sendRemoteProfileNotification(name: name, error: nil) + } catch { -+ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) ++ sendRemoteProfileNotification(name: name, error: error) + throw error + } + } + ++ private func sendRemoteProfileNotification(name: String, error: Error?) { ++ let notification = UNMutableNotificationContent() ++ let title = String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) ++ notification.body = [title, error?.localizedDescription ?? "Success!"].joined(separator: "\n") ++ notification.sound = .default ++ ++ let request = UNNotificationRequest( ++ identifier: error == nil ? "com.loopkit.Loop.RemoteProfile" : "com.loopkit.Loop.RemoteProfileFailure", ++ content: notification, ++ trigger: nil ++ ) ++ ++ UNUserNotificationCenter.current().add(request) ++ } ++ + enum RemoteProfileActionError: LocalizedError { + case unavailable + case noPump @@ -156,7 +130,7 @@ index b557f184..a7da8548 100644 + } +} diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift -index 1faefbc5..737acf28 100644 +index e02112db..7a713a9f 100644 --- a/Loop/Loop/Views/SettingsView.swift +++ b/Loop/Loop/Views/SettingsView.swift @@ -55,6 +55,7 @@ struct SettingsView: View { @@ -167,7 +141,7 @@ index 1faefbc5..737acf28 100644 } } -@@ -155,6 +156,9 @@ struct SettingsView: View { +@@ -156,6 +157,9 @@ struct SettingsView: View { } case .favoriteFoods: FavoriteFoodsView(insightsDelegate: viewModel.favoriteFoodInsightsDelegate) @@ -177,7 +151,7 @@ index 1faefbc5..737acf28 100644 } } .environmentObject(displayGlucosePreference) -@@ -416,6 +420,11 @@ extension SettingsView { +@@ -463,6 +467,11 @@ extension SettingsView { .accessibilityIdentifier("button_TherapySettings") } @@ -253,23 +227,28 @@ index cc88ad17..c543b73a 100644 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, 892A5DA02231E130008961AB /* CompletionNotifying.swift in Sources */, -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 8f824906..a7c9c653 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -8,6 +8,12 @@ - - import Foundation - +diff --git a/LoopKit/LoopKit/Service/RemoteDataService.swift b/LoopKit/LoopKit/Service/RemoteDataService.swift +index 89543d1a..ce8f6c36 100644 +--- a/LoopKit/LoopKit/Service/RemoteDataService.swift ++++ b/LoopKit/LoopKit/Service/RemoteDataService.swift +@@ -137,3 +137,17 @@ public extension RemoteDataService { + var pumpEventDataLimit: Int? { return nil } + var settingsDataLimit: Int? { return nil } + } ++ ++/// A remote data service that handles remote profile switch commands itself. ++public protocol RemoteProfileSwitchReceiver: RemoteDataService { ++ /// True when the push notification is a profile switch command. ++ func isProfileSwitch(_ notification: [String: AnyObject]) -> Bool ++ ++ func profileSwitchWasReceived(_ notification: [String: AnyObject]) async ++} ++ +/// Switches to a saved therapy profile on a remote command. +public protocol RemoteProfileActionDelegate: AnyObject { + /// `verify` receives the schedules about to be activated and throws to reject them. + func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws +} -+ - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws - func cancelRemoteOverride() async throws diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift new file mode 100644 index 00000000..6c27aa9f @@ -1663,87 +1642,393 @@ index 1ddc82b..54421a1 100644 Submodule NightscoutService contains modified content diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index 00096e2..cf6883e 100644 +index 00096e2..eaf139d 100644 --- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -23,6 +23,10 @@ +@@ -23,6 +23,9 @@ A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; ++ 3E7A51C22F0D4B8100A1C6E2 /* NightscoutService+RemoteProfileSwitch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* NightscoutService+RemoteProfileSwitch.swift */; }; + 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; ++ 3E7A51C62F0D4B8100A1C6E2 /* RemoteProfileSwitchTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* RemoteProfileSwitchTestCase.swift */; }; A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; -@@ -156,6 +160,10 @@ +@@ -156,6 +159,9 @@ A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; ++ 3E7A51C32F0D4B8100A1C6E2 /* NightscoutService+RemoteProfileSwitch.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NightscoutService+RemoteProfileSwitch.swift"; sourceTree = ""; }; + 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; ++ 3E7A51C72F0D4B8100A1C6E2 /* RemoteProfileSwitchTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteProfileSwitchTestCase.swift; sourceTree = ""; }; A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; -@@ -265,6 +273,7 @@ +@@ -265,6 +271,8 @@ A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, A934B6172358F49300949C8B /* StoredSettings.swift */, + 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, ++ 3E7A51C32F0D4B8100A1C6E2 /* NightscoutService+RemoteProfileSwitch.swift */, A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, -@@ -321,6 +330,7 @@ +@@ -321,6 +329,7 @@ A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* RemoteProfileSwitchTestCase.swift */, A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, ); path = NightscoutServiceKitTests; -@@ -361,6 +371,7 @@ - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, - ); - path = Notifications; - sourceTree = ""; -@@ -409,6 +420,7 @@ - A9AA6E5729EB06AA008FFA78 /* Actions */ = { - isa = PBXGroup; - children = ( -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -@@ -740,6 +752,9 @@ +@@ -740,6 +749,8 @@ C1398D2227C434FB00416AD6 /* Data.swift in Sources */, A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, ++ 3E7A51C22F0D4B8100A1C6E2 /* NightscoutService+RemoteProfileSwitch.swift in Sources */, + 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, ); -@@ -753,6 +768,7 @@ +@@ -753,6 +764,7 @@ A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, ++ 3E7A51C62F0D4B8100A1C6E2 /* RemoteProfileSwitchTestCase.swift in Sources */, A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; +diff --git a/NightscoutService/NightscoutServiceKit/Extensions/NightscoutService+RemoteProfileSwitch.swift b/NightscoutService/NightscoutServiceKit/Extensions/NightscoutService+RemoteProfileSwitch.swift +new file mode 100644 +index 0000000..2225b1c +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/NightscoutService+RemoteProfileSwitch.swift +@@ -0,0 +1,318 @@ ++// ++// NightscoutService+RemoteProfileSwitch.swift ++// NightscoutServiceKit ++// ++ ++import Foundation ++import LoopKit ++import NightscoutKit ++import OSLog ++ ++/// Remote profile switch commands. They are handled apart from `RemoteCommandSourceV1` with the same steps: ++/// decode, skip duplicates, check expiration and OTP, act, reply through the return push, and post a ++/// Nightscout note on any error. ++extension NightscoutService: RemoteProfileSwitchReceiver { ++ ++ private static let profileSwitchLog = OSLog(category: "RemoteProfileSwitch") ++ private static let recentProfileSwitches = RecentProfileSwitches() ++ ++ public func isProfileSwitch(_ notification: [String: AnyObject]) -> Bool { ++ return notification[ProfileSwitchCommand.commandKey] != nil ++ } ++ ++ public func profileSwitchWasReceived(_ notification: [String: AnyObject]) async { ++ do { ++ let command = try ProfileSwitchCommand(dictionary: notification) ++ guard await Self.recentProfileSwitches.isNew(command) else { ++ // Duplicate notifications are expected after app is force killed ++ return ++ } ++ try command.validate(otpManager: otpManager) ++ try await activateProfile(command) ++ } catch { ++ os_log("Remote profile switch: %{public}@. Error: %{public}@", log: Self.profileSwitchLog, type: .error, String(describing: notification), String(describing: error)) ++ try? await uploadProfileSwitchNote(error: error, notification: notification) ++ } ++ } ++ ++ private func activateProfile(_ command: ProfileSwitchCommand) async throws { ++ let returnInfo = command.returnNotificationInfo() ++ do { ++ guard let profileDelegate = serviceDelegate as? RemoteProfileActionDelegate else { ++ throw RemoteProfileSwitchError.unavailable ++ } ++ try await profileDelegate.activateRemoteProfile(name: command.profileName) { correctionRange, carbRatio, basalRate, insulinSensitivity in ++ let savedProfile = SavedProfile(name: command.profileName, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) ++ guard savedProfile.matches(fingerprint: command.fingerprint) else { ++ throw RemoteProfileSwitchError.profileChanged ++ } ++ } ++ } catch { ++ let failure = RemoteProfileSwitchError.notActivated(command.profileName, error) ++ await ProfileSwitchResponse.send(to: returnInfo, success: false, message: "Command failed: \(failure.localizedDescription)") ++ throw failure ++ } ++ await ProfileSwitchResponse.send(to: returnInfo, success: true, message: "Profile '\(command.profileName)' activated") ++ } ++ ++ private func uploadProfileSwitchNote(error: Error, notification: [String: AnyObject]) async throws { ++ guard let siteURL, let apiSecret else { ++ throw NightscoutServiceError.missingCredentials ++ } ++ let notificationJSON = try JSONSerialization.data(withJSONObject: notification) ++ let notificationJSONString = String(data: notificationJSON, encoding: .utf8) ?? "" ++ ++ let treatment = NightscoutTreatment( ++ timestamp: Date(), ++ enteredBy: LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch"), ++ notes: """ ++ \(error.localizedDescription) ++ \(notificationJSONString) ++ """, ++ eventType: .note ++ ) ++ ++ let uploader = NightscoutClient(siteURL: siteURL, apiSecret: apiSecret) ++ return try await withCheckedThrowingContinuation { continuation in ++ uploader.upload([treatment]) { result in ++ switch result { ++ case .success: ++ continuation.resume() ++ case .failure(let error): ++ continuation.resume(throwing: error) ++ } ++ } ++ } ++ } ++} ++ ++/// A profile switch push. `expiration` is required: decoding fails without it. ++struct ProfileSwitchCommand: Codable { ++ ++ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. ++ static let commandKey = "profile-switch" ++ ++ let profileName: String ++ let fingerprint: String ++ let remoteAddress: String ++ let expiration: Date ++ let sentAt: Date? ++ let otp: String? ++ let enteredBy: String? ++ let encryptedReturnNotification: String? ++ ++ enum CodingKeys: String, CodingKey { ++ case profileName = "profile-switch" ++ case fingerprint = "profile-switch-fingerprint" ++ case remoteAddress = "remote-address" ++ case expiration = "expiration" ++ case sentAt = "sent-at" ++ case otp = "otp" ++ case enteredBy = "entered-by" ++ case encryptedReturnNotification = "encrypted_return_notification" ++ } ++ ++ init(dictionary: [String: Any]) throws { ++ let data = try JSONSerialization.data(withJSONObject: dictionary) ++ let jsonDecoder = JSONDecoder() ++ jsonDecoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601DateDecoder) ++ self = try jsonDecoder.decode(Self.self, from: data) ++ } ++ ++ /// Expiration first, so an expired command does not use up its OTP. ++ func validate(otpManager: OTPManager, now: Date = Date()) throws { ++ guard now <= expiration else { ++ throw RemoteProfileSwitchError.expired ++ } ++ guard let otp else { ++ throw RemoteProfileSwitchError.missingOTP ++ } ++ try otpManager.validatePassword(password: otp, deliveryDate: sentAt) ++ } ++ ++ func returnNotificationInfo() -> ReturnNotificationInfo? { ++ guard let encryptedReturnNotification, let otp, let messenger = OTPSecureMessenger(otpCode: otp) else { ++ return nil ++ } ++ return try? messenger.decrypt(base64EncodedString: encryptedReturnNotification) ++ } ++} ++ ++private actor RecentProfileSwitches { ++ private var sentDates = [Date]() ++ ++ /// Commands without `sent-at` cannot be matched and always count as new. ++ func isNew(_ command: ProfileSwitchCommand) -> Bool { ++ guard let sentAt = command.sentAt else { ++ return true ++ } ++ if sentDates.contains(sentAt) { ++ return false ++ } ++ sentDates.append(sentAt) ++ return true ++ } ++} ++ ++/// The return push to the follower, in the format of `RemoteNotificationResponseManager` with ++/// `command_type` `profile_switch`. Followers match the reply by the profile name in the body. ++enum ProfileSwitchResponse { ++ private static let log = OSLog(category: "RemoteProfileSwitch") ++ ++ struct Payload: Encodable { ++ let aps: APS ++ let commandStatus: String ++ let commandType: String ++ let timestamp: TimeInterval ++ ++ enum CodingKeys: String, CodingKey { ++ case aps ++ case commandStatus = "command_status" ++ case commandType = "command_type" ++ case timestamp ++ } ++ } ++ ++ struct APS: Encodable { ++ let alert: Alert ++ let sound: String = "default" ++ } ++ ++ struct Alert: Encodable { ++ let title: String ++ let body: String ++ } ++ ++ static func payload(success: Bool, message: String) -> Payload { ++ return Payload( ++ aps: APS(alert: Alert(title: success ? "Command Successful" : "Command Failed", body: message)), ++ commandStatus: success ? "success" : "failed", ++ commandType: "profile_switch", ++ timestamp: Date().timeIntervalSince1970 ++ ) ++ } ++ ++ static func send(to returnInfo: ReturnNotificationInfo?, success: Bool, message: String) async { ++ guard let returnInfo, !returnInfo.deviceToken.isEmpty else { ++ os_log("No return notification info, response not sent", log: log, type: .info) ++ return ++ } ++ guard let jwt = APNSJWTManager.shared.getOrGenerateJWT(keyId: returnInfo.keyId, teamId: returnInfo.teamId, apnsKey: returnInfo.apnsKey) else { ++ os_log("Failed to generate JWT for response notification", log: log, type: .error) ++ return ++ } ++ let host = returnInfo.productionEnvironment ? "api.push.apple.com" : "api.sandbox.push.apple.com" ++ guard let url = URL(string: "https://\(host)/3/device/\(returnInfo.deviceToken)") else { ++ os_log("Failed to construct APNs URL", log: log, type: .error) ++ return ++ } ++ ++ var request = URLRequest(url: url) ++ request.httpMethod = "POST" ++ request.setValue("bearer \(jwt)", forHTTPHeaderField: "authorization") ++ request.setValue("application/json", forHTTPHeaderField: "content-type") ++ request.setValue("10", forHTTPHeaderField: "apns-priority") ++ request.setValue("0", forHTTPHeaderField: "apns-expiration") ++ request.setValue(returnInfo.bundleId, forHTTPHeaderField: "apns-topic") ++ request.setValue("alert", forHTTPHeaderField: "apns-push-type") ++ ++ do { ++ request.httpBody = try JSONEncoder().encode(payload(success: success, message: message)) ++ let (_, response) = try await URLSession.shared.data(for: request) ++ if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { ++ os_log("Failed to send response notification: %d", log: log, type: .error, httpResponse.statusCode) ++ } ++ } catch { ++ os_log("Error sending response notification: %{public}@", log: log, type: .error, error.localizedDescription) ++ } ++ } ++} ++ ++enum RemoteProfileSwitchError: LocalizedError { ++ case profileChanged ++ case unavailable ++ case expired ++ case missingOTP ++ case notActivated(String, Error) ++ ++ /// Followers read the profile name from this text to match the reply to their command. ++ var errorDescription: String? { ++ switch self { ++ case .profileChanged: ++ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") ++ case .unavailable: ++ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") ++ case .expired: ++ return LocalizedString("Expired", comment: "Remote command error description: expired.") ++ case .missingOTP: ++ return LocalizedString("Missing OTP", comment: "Remote command error description: Missing OTP.") ++ case .notActivated(let name, let error): ++ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) ++ } ++ } ++} ++ ++extension DeviceStatus { ++ /// Adds `command` to `loop.testingDetails.remoteCommands`, keeping commands already listed there. ++ /// `LoopStatus` is copied field by field; when the copy differs from the original in anything but ++ /// `testingDetails`, the original is returned unchanged so no uploaded data is lost. ++ func addingRemoteCommand(_ command: String) -> DeviceStatus { ++ guard let loopStatus else { ++ return self ++ } ++ var testingDetails = loopStatus.testingDetails ?? [:] ++ let remoteCommands = testingDetails["remoteCommands"] as? [String] ?? [] ++ guard !remoteCommands.contains(command) else { ++ return self ++ } ++ testingDetails["remoteCommands"] = remoteCommands + [command] ++ ++ let updated = DeviceStatus( ++ device: device, ++ timestamp: timestamp, ++ pumpStatus: pumpStatus, ++ uploaderStatus: uploaderStatus, ++ loopStatus: loopStatus.replacingTestingDetails(testingDetails), ++ radioAdapter: radioAdapter, ++ overrideStatus: overrideStatus, ++ identifier: identifier) ++ ++ guard updated.dictionaryRepresentationWithoutTestingDetails == dictionaryRepresentationWithoutTestingDetails else { ++ return self ++ } ++ return updated ++ } ++ ++ var dictionaryRepresentationWithoutTestingDetails: NSDictionary { ++ var representation = dictionaryRepresentation ++ if var loop = representation["loop"] as? [String: Any] { ++ loop["testingDetails"] = nil ++ representation["loop"] = loop ++ } ++ return NSDictionary(dictionary: representation) ++ } ++} ++ ++extension LoopStatus { ++ func replacingTestingDetails(_ testingDetails: [String: Any]) -> LoopStatus { ++ return LoopStatus( ++ name: name, ++ version: version, ++ timestamp: timestamp, ++ iob: iob, ++ cob: cob, ++ predicted: predicted, ++ automaticDoseRecommendation: automaticDoseRecommendation, ++ recommendedBolus: recommendedBolus, ++ bolusCalculation: bolusCalculation, ++ enacted: enacted, ++ rileylinks: rileylinks, ++ failureReason: failureReason, ++ failureCode: failureCode, ++ currentCorrectionRange: currentCorrectionRange, ++ forecastError: forecastError, ++ testingDetails: testingDetails, ++ algorithmExperiments: algorithmExperiments ++ ) ++ } ++} diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift new file mode 100644 -index 0000000..588ded1 +index 0000000..4f5fdeb --- /dev/null +++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -@@ -0,0 +1,104 @@ +@@ -0,0 +1,86 @@ +// +// SavedProfile.swift +// NightscoutServiceKit @@ -1830,36 +2115,6 @@ index 0000000..588ded1 + return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) + } +} -+ -+enum RemoteProfileSwitchError: LocalizedError { -+ case profileChanged -+ case unavailable -+ case notActivated(String, Error) -+ -+ /// Followers read the profile name from this text to match the reply to their command. -+ var errorDescription: String? { -+ switch self { -+ case .profileChanged: -+ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") -+ case .unavailable: -+ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") -+ case .notActivated(let name, let error): -+ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index 2f12b47..2dec600 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -180,6 +180,7 @@ extension StoredDosingDecision { - enacted: automaticDoseDecision?.loopStatusEnacted, - failureReason: automaticDoseDecision?.loopStatusFailureReason, - failureCode: automaticDoseDecision?.errors.first?.id, -+ testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]], - algorithmExperiments: (automaticDoseDecision?.algorithmExperiments ?? algorithmExperiments)?.map { $0.jsonObject }), - overrideStatus: overrideStatus) - } diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift index 3142223..ae6fb9a 100644 --- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift @@ -1889,176 +2144,26 @@ index 3142223..ae6fb9a 100644 syncIdentifier: syncIdentifier.uuidString, isAPNSProduction: Bundle.main.isAPNSProductionEnvironment, diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index cd6d2c6..73db786 100644 +index cd6d2c6..3561e04 100644 --- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift +++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -459,6 +459,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - success = true - message = "Override '\(overrideCommand.name)' enacted successfully" - -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ success = true -+ message = "Profile '\(profileSwitchCommand.name)' activated" -+ - case .cancelTemporaryOverride: - commandType = .cancelOverride - try await self.serviceDelegate?.cancelRemoteOverride() -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index a92b509..29313b6 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -9,6 +9,7 @@ - import Foundation - - public enum Action: Codable { -+ case profileSwitch(ProfileSwitchAction) - case temporaryScheduleOverride(OverrideAction) - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) -@@ -28,6 +29,8 @@ public enum Action: Codable { - return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") - case .temporaryScheduleOverride: - return LocalizedString("Override", comment: "The remote action name for Override") -+ case .profileSwitch: -+ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") - } - } - -@@ -43,6 +46,8 @@ public enum Action: Codable { - return "" - case .temporaryScheduleOverride(let overrideAction): - return overrideAction.name -+ case .profileSwitch(let profileSwitchAction): -+ return profileSwitchAction.name +@@ -292,7 +292,7 @@ extension NightscoutService: RemoteDataService { } - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -new file mode 100644 -index 0000000..ce03ded ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -@@ -0,0 +1,17 @@ -+// -+// ProfileSwitchAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct ProfileSwitchAction: Codable { -+ -+ public let name: String -+ public let fingerprint: String -+ -+ public init(name: String, fingerprint: String) { -+ self.name = name -+ self.fingerprint = fingerprint -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 95274e4..4457f80 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -45,6 +45,7 @@ class RemoteNotificationResponseManager { - case carbs = "carbs" - case override = "override" - case cancelOverride = "cancel_override" -+ case profileSwitch = "profile_switch" - } - - func sendResponseNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -new file mode 100644 -index 0000000..487c620 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -0,0 +1,50 @@ -+// -+// ProfileSwitchRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Switches to a saved profile. `expiration` is required: decoding fails without it. -+public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { -+ -+ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. -+ static let commandKey = "profile-switch" -+ -+ public let profileName: String -+ public let fingerprint: String -+ public let remoteAddress: String -+ public let expirationDate: Date -+ public let sentAt: Date? -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ public var expiration: Date? { -+ return expirationDate -+ } -+ -+ enum CodingKeys: String, CodingKey { -+ case profileName = "profile-switch" -+ case fingerprint = "profile-switch-fingerprint" -+ case remoteAddress = "remote-address" -+ case expirationDate = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification[commandKey] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index 0c5f8e3..3362bf7 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -108,6 +108,8 @@ extension Dictionary { - return try OverrideRemoteNotification(dictionary: self) - } else if OverrideCancelRemoteNotification.includedInNotification(self) { - return try OverrideCancelRemoteNotification(dictionary: self) -+ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { -+ return try ProfileSwitchRemoteNotification(dictionary: self) - } else { - throw RemoteNotificationError.unhandledNotification(self) + + let statuses = uploadPairs.map { (decision, automaticDoseDecision) in +- return decision.deviceStatus(automaticDoseDecision: automaticDoseDecision) ++ return decision.deviceStatus(automaticDoseDecision: automaticDoseDecision).addingRemoteCommand(ProfileSwitchCommand.commandKey) } -diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift + + guard statuses.count > 0 else { +diff --git a/NightscoutService/NightscoutServiceKitTests/RemoteProfileSwitchTestCase.swift b/NightscoutService/NightscoutServiceKitTests/RemoteProfileSwitchTestCase.swift new file mode 100644 -index 0000000..2fcec94 +index 0000000..369fb3e --- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -@@ -0,0 +1,71 @@ ++++ b/NightscoutService/NightscoutServiceKitTests/RemoteProfileSwitchTestCase.swift +@@ -0,0 +1,135 @@ +// -+// ProfileSwitchRemoteNotificationTestCase.swift ++// RemoteProfileSwitchTestCase.swift +// NightscoutServiceKitTests +// + @@ -2067,9 +2172,9 @@ index 0000000..2fcec94 +import NightscoutKit +@testable import NightscoutServiceKit + -+final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { ++final class RemoteProfileSwitchTestCase: XCTestCase { + -+ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { ++ func testParseProfileSwitchCommand_ValidPayload_Succeeds() throws { + let notification: [String: Any] = [ + "profile-switch": "Sport", + "profile-switch-fingerprint": "fec4f066b5a54ecd", @@ -2079,20 +2184,15 @@ index 0000000..2fcec94 + "otp": "123456" + ] + -+ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) -+ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) ++ let command = try ProfileSwitchCommand(dictionary: notification) + -+ XCTAssertEqual(profileSwitch.profileName, "Sport") -+ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") -+ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) -+ XCTAssertTrue(profileSwitch.otpValidationRequired()) -+ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { -+ return XCTFail("Expected a profile switch action") -+ } -+ XCTAssertEqual(action.name, "Sport") ++ XCTAssertEqual(command.profileName, "Sport") ++ XCTAssertEqual(command.fingerprint, "fec4f066b5a54ecd") ++ XCTAssertEqual(command.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) ++ XCTAssertEqual(command.sentAt, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:46:35.778Z")) + } + -+ func testParseProfileSwitchNotification_MissingExpiration_Fails() { ++ func testParseProfileSwitchCommand_MissingExpiration_Fails() { + let notification: [String: Any] = [ + "profile-switch": "Sport", + "profile-switch-fingerprint": "fec4f066b5a54ecd", @@ -2100,13 +2200,26 @@ index 0000000..2fcec94 + "otp": "123456" + ] + -+ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) ++ XCTAssertThrowsError(try ProfileSwitchCommand(dictionary: notification)) + } + -+ func testFingerprint_MatchesFollowerVector() { -+ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { -+ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ func testValidate_Expired_FailsBeforeOTP() throws { ++ let command = try ProfileSwitchCommand(dictionary: [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "expiration": "2023-02-25T20:51:35.778Z", ++ "otp": "123456" ++ ]) ++ ++ XCTAssertThrowsError(try command.validate(otpManager: OTPManager(secretStore: MockSecretStore()))) { error in ++ guard case RemoteProfileSwitchError.expired = error else { ++ return XCTFail("Expected expired, got \(error)") ++ } + } ++ } ++ ++ func testFingerprint_MatchesFollowerVector() { + let profile = ProfileSet.Profile( + timezone: TimeZone(secondsFromGMT: 3600)!, + dia: .hours(6), @@ -2119,6 +2232,62 @@ index 0000000..2fcec94 + + XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") + } ++ ++ func testAddingRemoteCommand_KeepsListedCommandsAndOtherFields() { ++ let status = deviceStatus(testingDetails: ["remoteCommands": ["carbs-delete"], "other": 1]) ++ ++ let updated = status.addingRemoteCommand(ProfileSwitchCommand.commandKey) ++ ++ XCTAssertEqual(updated.loopStatus?.testingDetails?["remoteCommands"] as? [String], ["carbs-delete", "profile-switch"]) ++ XCTAssertEqual(updated.loopStatus?.testingDetails?["other"] as? Int, 1) ++ XCTAssertEqual(updated.dictionaryRepresentationWithoutTestingDetails, status.dictionaryRepresentationWithoutTestingDetails) ++ } ++ ++ func testAddingRemoteCommand_WithoutTestingDetails_AddsList() { ++ let updated = deviceStatus(testingDetails: nil).addingRemoteCommand(ProfileSwitchCommand.commandKey) ++ ++ XCTAssertEqual(updated.loopStatus?.testingDetails?["remoteCommands"] as? [String], ["profile-switch"]) ++ } ++ ++ func testResponsePayload_UsesProfileSwitchCommandType() throws { ++ let data = try JSONEncoder().encode(ProfileSwitchResponse.payload(success: false, message: "Command failed: Profile 'Sport' not activated: Pod not connected")) ++ let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) ++ ++ XCTAssertEqual(json["command_type"] as? String, "profile_switch") ++ XCTAssertEqual(json["command_status"] as? String, "failed") ++ let alert = try XCTUnwrap((json["aps"] as? [String: Any])?["alert"] as? [String: Any]) ++ XCTAssertEqual(alert["body"] as? String, "Command failed: Profile 'Sport' not activated: Pod not connected") ++ } ++ ++ private func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { ++ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } ++ } ++ ++ private func deviceStatus(testingDetails: [String: Any]?) -> DeviceStatus { ++ let date = Date(timeIntervalSince1970: 1_700_000_000) ++ let loopStatus = LoopStatus( ++ name: "Loop", ++ version: "3.0", ++ timestamp: date, ++ iob: IOBStatus(timestamp: date, iob: 1.5), ++ recommendedBolus: 0.4, ++ failureReason: "failure", ++ testingDetails: testingDetails) ++ return DeviceStatus(device: "loop://iPhone", timestamp: date, uploaderStatus: UploaderStatus(name: "iPhone", timestamp: date, battery: 50), loopStatus: loopStatus, identifier: "id") ++ } ++} ++ ++private final class MockSecretStore: OTPSecretStore { ++ private var key: String? = "2KBHFASYM6DKWFGW" ++ private var keyName: String? = "Mock" ++ private var accepted = [String]() ++ ++ func tokenSecretKey() -> String? { key } ++ func tokenSecretKeyName() -> String? { keyName } ++ func setTokenSecretKey(_ key: String?) throws { self.key = key } ++ func setTokenSecretKeyName(_ name: String?) throws { self.keyName = name } ++ func recentAcceptedPasswords() -> [String] { accepted } ++ func setRecentAcceptedPasswords(_ passwords: [String]) throws { accepted = passwords } +} + +private extension ISO8601DateFormatter { diff --git a/profiles/nextdev_profiles_remote_carb_edit.patch b/profiles/nextdev_profiles_remote_carb_edit.patch deleted file mode 100644 index a5cec6d..0000000 --- a/profiles/nextdev_profiles_remote_carb_edit.patch +++ /dev/null @@ -1,2134 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index 3c9d61cb..5591c9a5 100644 ---- a/Loop/Loop/Managers/DeviceDataManager.swift -+++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -1407,6 +1407,13 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { - return try await pumpManager?.syncDeliveryLimits(limits: deliveryLimits) ?? deliveryLimits - } - -+ func updateCurrentProfileName() { -+ NotificationCenter.default.post(name: .LoopDataUpdated, -+ object: nil, -+ userInfo: [LoopDataManager.LoopUpdateContextKey: LoopUpdateContext.preferences.rawValue]) -+ settingsManager.storeSettings() -+ } -+ - func saveCompletion(therapySettings: TherapySettings) { - settingsManager.mutateLoopSettings { settings in - settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule -diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift -index 3e1110d1..cc990cd0 100644 ---- a/Loop/Loop/Managers/LoopAppManager.swift -+++ b/Loop/Loop/Managers/LoopAppManager.swift -@@ -401,6 +401,7 @@ class LoopAppManager: NSObject { - - dosingDecisionStore.delegate = deviceDataManager - remoteDataServicesManager.delegate = deviceDataManager -+ servicesManager.therapySettingsViewModelDelegate = deviceDataManager - - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, temporaryPresetsManager.presetHistory, deviceDataManager.deviceLog, alertManager.alertStore] - criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, -diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift -index 7ecb16d1..61cdb5c0 100644 ---- a/Loop/Loop/Managers/LoopDataManager.swift -+++ b/Loop/Loop/Managers/LoopDataManager.swift -@@ -1723,7 +1723,7 @@ extension LoopDataManager: ManualDoseViewModelDelegate { - var settings: StoredSettings { - settingsProvider.settings - } -- -+ - var scheduleOverride: TemporaryScheduleOverride? { - temporaryPresetsManager.scheduleOverride - } -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 18975ce1..1cf442dd 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -326,6 +326,38 @@ extension NotificationManager { - UNUserNotificationCenter.current().add(request) - } - -+ static func sendRemoteProfileNotification(name: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfile", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteProfileFailureNotification(name: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfileFailure", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ private static func remoteProfileNotificationBody(name: String) -> String { -+ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) -+ } -+ - private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { - return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) - } -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 56cb3642..20d2f3d4 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -30,6 +30,7 @@ class ServicesManager { - weak var servicesManagerDelegate: ServicesManagerDelegate? - weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? - weak var supportManager: SupportManager? -+ weak var therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? - - private var services = [Service]() - -@@ -467,3 +468,57 @@ extension Service { - ] - } - } -+ -+// MARK: - RemoteProfileActionDelegate -+ -+extension ServicesManager: RemoteProfileActionDelegate { -+ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { -+ do { -+ guard let therapySettingsViewModelDelegate else { -+ throw RemoteProfileActionError.unavailable -+ } -+ let viewModel = ProfileViewModel(therapySettings: settingsManager.therapySettings, delegate: therapySettingsViewModelDelegate) -+ guard therapySettingsViewModelDelegate.pumpSupportedIncrements() != nil else { -+ throw RemoteProfileActionError.noPump -+ } -+ guard let profile = try viewModel.savedProfile(named: name) else { -+ throw RemoteProfileActionError.profileNotFound(name) -+ } -+ try profile.verify(with: verify) -+ if case .failure(let error) = viewModel.validateProfile(profile) { -+ throw error -+ } -+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ continuation.resume() -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ NotificationManager.sendRemoteProfileNotification(name: name) -+ } catch { -+ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) -+ throw error -+ } -+ } -+ -+ enum RemoteProfileActionError: LocalizedError { -+ case unavailable -+ case noPump -+ case profileNotFound(String) -+ -+ var errorDescription: String? { -+ switch self { -+ case .unavailable: -+ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: profile view model delegate missing.") -+ case .noPump: -+ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") -+ case .profileNotFound(let name): -+ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) -+ } -+ } -+ } -+} -diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift -index 1faefbc5..737acf28 100644 ---- a/Loop/Loop/Views/SettingsView.swift -+++ b/Loop/Loop/Views/SettingsView.swift -@@ -55,6 +55,7 @@ struct SettingsView: View { - - case favoriteFoods - case presets -+ case profiles - } - } - -@@ -155,6 +156,9 @@ struct SettingsView: View { - } - case .favoriteFoods: - FavoriteFoodsView(insightsDelegate: viewModel.favoriteFoodInsightsDelegate) -+ case .profiles: -+ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), -+ delegate: self.viewModel.therapySettingsViewModelDelegate)) - } - } - .environmentObject(displayGlucosePreference) -@@ -416,6 +420,11 @@ extension SettingsView { - .accessibilityIdentifier("button_TherapySettings") - } - -+ LargeButton(action: { sheet = .profiles }, -+ includeArrow: true, -+ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), -+ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), -+ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) - ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in - item.view - } -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj -index cc88ad17..c543b73a 100644 ---- a/LoopKit/LoopKit.xcodeproj/project.pbxproj -+++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj -@@ -7,6 +7,12 @@ - objects = { - - /* Begin PBXBuildFile section */ -+ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; -+ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; -+ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; -+ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; -+ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; -+ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; - 9D3D2DA3B9824EF48981F0E4 /* StoredInsulinModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CAB9E826A3254800A57273 /* StoredInsulinModel.swift */; }; - 485ACF65C30744BFBC979CEF /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A985464E251449FE0099C1A6 /* NotificationSettings.swift */; }; - 2383C3D7967B4602B8EB4A5B /* SettingsObject+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = A912BE2A245B9E8600CBE199 /* SettingsObject+CoreDataProperties.swift */; }; -@@ -1065,6 +1071,12 @@ - /* End PBXCopyFilesBuildPhase section */ - - /* Begin PBXFileReference section */ -+ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; -+ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; -+ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; -+ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; -+ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; -+ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; - D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; - 142CB75C2A60C01E0075748A /* ListButtonStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListButtonStyle.swift; sourceTree = ""; }; - 142CB7602A60C0440075748A /* FavoriteFood.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FavoriteFood.swift; sourceTree = ""; }; -@@ -2971,6 +2983,8 @@ - B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, - B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, - 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, -+ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, -+ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, - ); - path = ViewModels; - sourceTree = ""; -@@ -3146,6 +3160,10 @@ - E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, - 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, - 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, -+ DDAF746629F422C600719F0A /* ProfileView.swift */, -+ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, -+ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, -+ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, - ); - path = "Settings Editors"; - sourceTree = ""; -@@ -3815,6 +3833,12 @@ - E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, - B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, - 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, -+ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, -+ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, -+ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, -+ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, -+ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, -+ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, - 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, - 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, - 892A5DA02231E130008961AB /* CompletionNotifying.swift in Sources */, -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 6d3f9db2..5f1f37f3 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -8,6 +8,12 @@ - - import Foundation - -+/// Switches to a saved therapy profile on a remote command. -+public protocol RemoteProfileActionDelegate: AnyObject { -+ /// `verify` receives the schedules about to be activated and throws to reject them. -+ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws -+} -+ - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws - func cancelRemoteOverride() async throws -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -new file mode 100644 -index 00000000..6c27aa9f ---- /dev/null -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,467 @@ -+// -+// ProfileViewModel+FileManagement.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-19. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import LoopKit -+import HealthKit -+import LoopAlgorithm -+ -+public enum ProfileValidationError: Error, LocalizedError { -+ case fileLoadError -+ case unsupportedIncrement -+ case correctionRangeError -+ case insulinSensitivityError -+ case carbRatioError -+ case basalRateError -+ case maxBasalRateNotSet -+ -+ public var errorDescription: String? { -+ switch self { -+ case .fileLoadError: -+ return "Unable to load the profile file." -+ case .unsupportedIncrement: -+ return "The profile contains unsupported increments." -+ case .correctionRangeError: -+ return "Correction Range values are out of bounds." -+ case .insulinSensitivityError: -+ return "Insulin Sensitivity values are out of bounds." -+ case .carbRatioError: -+ return "Carb Ratio values are out of bounds." -+ case .basalRateError: -+ return "Basal Rate values are out of bounds." -+ case .maxBasalRateNotSet: -+ return "Maximum Basal Rate is not set." -+ } -+ } -+} -+ -+public enum LoadProfileResult { -+ case success -+ case failure(Error) -+} -+ -+// MARK: File management -+extension ProfileViewModel { -+ private func setCurrentProfile(name: String, syncChange: Bool = false) { -+ let sanitizedProfileName = sanitizeProfileName(name) -+ currentProfileName = sanitizedProfileName -+ if syncChange { -+ triggerProfileSync() -+ } -+ } -+ -+ private func clearCurrentProfile() { -+ currentProfileName = nil -+ triggerProfileSync() -+ } -+ -+ private func getCurrentProfileName() -> String? { -+ return currentProfileName -+ } -+ -+ private var profilesDirectory: URL { -+ let fileManager = FileManager.default -+ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] -+ return documentsDirectory.appendingPathComponent("LoopProfile") -+ } -+ -+ private func createProfileDirectoryIfNotExists() throws { -+ let fileManager = FileManager.default -+ var isDir : ObjCBool = false -+ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { -+ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) -+ } else if !isDir.boolValue { -+ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) -+ } -+ } -+ -+ private func getAllProfileFiles() throws -> [URL] { -+ let fileManager = FileManager.default -+ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) -+ return files.filter { $0.pathExtension == "json" } -+ } -+ -+ private func encodeProfile(_ profile: Profile) throws -> Data { -+ return try JSONEncoder().encode(profile) -+ } -+ -+ private func decodeProfile(from data: Data) throws -> Profile { -+ return try JSONDecoder().decode(Profile.self, from: data) -+ } -+ -+ public func sanitizeProfileName(_ name: String) -> String { -+ // Replace problematic characters -+ var sanitized = name.replacingOccurrences(of: ".", with: "_") -+ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") -+ -+ // Limit the name to 32 characters -+ if sanitized.count > 32 { -+ sanitized = String(sanitized.prefix(32)) -+ } -+ -+ return sanitized -+ } -+ -+ public func saveProfile(withName name: String) { -+ let sanitizedName = sanitizeProfileName(name) -+ -+ do { -+ try createProfileDirectoryIfNotExists() -+ -+ if let existingProfile = getProfileReference(withName: sanitizedName) { -+ removeProfile(profileReference: existingProfile) -+ } -+ -+ let profile = Profile( -+ name: sanitizedName, -+ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, -+ carbRatioSchedule: therapySettings.carbRatioSchedule!, -+ basalRateSchedule: therapySettings.basalRateSchedule!, -+ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, -+ sortOrder: -1 -+ ) -+ -+ let jsonData = try encodeProfile(profile) -+ -+ let dateFormatter = DateFormatter() -+ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" -+ let fileName = dateFormatter.string(from: Date()) + ".json" -+ let fileURL = profilesDirectory.appendingPathComponent(fileName) -+ -+ try jsonData.write(to: fileURL) -+ setCurrentProfile(name: sanitizedName, syncChange: true) -+ -+ self.loadProfiles() -+ } catch { -+ print("An error occurred while saving profile: \(error)") -+ } -+ } -+ -+ public func renameProfile(oldName: String, newName: String) { -+ let sanitizedProfileName = sanitizeProfileName(newName) -+ -+ if currentProfileName == oldName { -+ setCurrentProfile(name: sanitizedProfileName, syncChange: true) -+ } -+ -+ do { -+ guard let profileReference = getProfileReference(withName: oldName) else { -+ print("Profile with old name not found.") -+ return -+ } -+ -+ let profile = try getProfile(from: profileReference) -+ -+ let newProfile = Profile( -+ name: sanitizedProfileName, -+ correctionRange: profile.correctionRange, -+ carbRatioSchedule: profile.carbRatioSchedule, -+ basalRateSchedule: profile.basalRateSchedule, -+ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, -+ sortOrder: profile.sortOrder -+ ) -+ -+ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { -+ removeProfile(profileReference: existingProfile) -+ } -+ -+ let jsonData = try encodeProfile(newProfile) -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ -+ try jsonData.write(to: fileURL) -+ -+ self.loadProfiles() -+ triggerProfileSync() -+ } catch { -+ print("An error occurred while renaming profile: \(error)") -+ } -+ } -+ -+ public func updateProfilesOrder() { -+ for (index, profileReference) in profiles.enumerated() { -+ do { -+ var profile = try getProfile(from: profileReference) -+ let newProfile = Profile( -+ name: profile.name, -+ correctionRange: profile.correctionRange, -+ carbRatioSchedule: profile.carbRatioSchedule, -+ basalRateSchedule: profile.basalRateSchedule, -+ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, -+ sortOrder: index -+ ) -+ -+ let jsonData = try encodeProfile(newProfile) -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ try jsonData.write(to: fileURL) -+ -+ } catch { -+ print("An error occurred while updating profile order: \(error)") -+ } -+ } -+ loadProfiles() -+ } -+ -+ public func loadProfiles() { -+ do { -+ let profileFiles = try getAllProfileFiles() -+ -+ var newProfiles = [ProfileReference]() -+ for fileURL in profileFiles { -+ let data = try Data(contentsOf: fileURL) -+ let profile = try decodeProfile(from: data) -+ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) -+ newProfiles.append(profileRef) -+ } -+ -+ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } -+ -+ self.profiles = newProfiles -+ } catch { -+ print("An error occurred while loading profiles: \(error)") -+ } -+ } -+ -+ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { -+ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && -+ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && -+ therapySettings.basalRateSchedule == profile.basalRateSchedule && -+ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { -+ self.setCurrentProfile(name: profile.name, syncChange: true) -+ completion(.success) -+ } -+ else -+ { -+ do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in -+ switch result { -+ case .success(let syncedSchedule): -+ DispatchQueue.main.async { -+ let previousSettings = self.therapySettings -+ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter -+ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! -+ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule -+ self.therapySettings.basalRateSchedule = syncedSchedule -+ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! -+ // One settings change, so the stored and uploaded settings never mix two profiles. -+ self.setCurrentProfile(name: profile.name, syncChange: false) -+ self.delegate?.saveCompletion(therapySettings: self.therapySettings) -+ if self.therapySettings == previousSettings { -+ // Nothing changed, so only the profile name needs to reach the uploaded settings. -+ self.triggerProfileSync() -+ } -+ print("New profile loaded") -+ completion(.success) -+ } -+ case .failure(let error): -+ print("An error occurred while syncing basal rates: \(error)") -+ completion(.failure(error)) -+ } -+ }) -+ } -+ } -+ } -+ -+ func getProfileReference(withName name: String) -> ProfileReference? { -+ let sanitizedInputName = sanitizeProfileName(name) -+ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) -+ } -+ -+ public func removeProfile(profile: Profile) { -+ guard let profileReference = getProfileReference(withName: profile.name) else { -+ print("No ProfileReference found for given Profile") -+ return -+ } -+ -+ removeProfile(profileReference: profileReference) -+ if getCurrentProfileName() == profile.name { -+ clearCurrentProfile() -+ } else { -+ triggerProfileSync() -+ } -+ } -+ -+ public func removeProfile(profileReference: ProfileReference) { -+ do { -+ let fileManager = FileManager.default -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ try fileManager.removeItem(at: fileURL) -+ loadProfiles() -+ } catch { -+ print("An error occurred while removing profile: \(error)") -+ } -+ } -+ -+ func doesProfileExist(withName name: String) -> Bool { -+ return profiles.contains(where: { $0.name == name }) -+ } -+ -+ public func getProfile(from profileReference: ProfileReference) throws -> Profile { -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ let data = try Data(contentsOf: fileURL) -+ let getProfile = try decodeProfile(from: data) -+ return getProfile -+ } -+ -+ /// The saved profile named `name`, or nil when there is none. -+ public func savedProfile(named name: String) throws -> Profile? { -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ return nil -+ } -+ return try getProfile(from: profileReference) -+ } -+ -+ public func validateProfile(_ profile: Profile) -> Result { -+ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { -+ return .failure(.fileLoadError) -+ } -+ -+ // Checking Correction Range Schedule -+ for item in profile.correctionRange.items { -+ let minValue = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) -+ let maxValue = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) -+ -+ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { -+ return .failure(.correctionRangeError) -+ } -+ } -+ -+ // Checking Insulin Sensitivity Schedule -+ for item in profile.insulinSensitivitySchedule.items { -+ let value = LoopQuantity(unit: .milligramsPerDeciliterPerInternationalUnit, doubleValue: item.value) -+ -+ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { -+ return .failure(.insulinSensitivityError) -+ } -+ } -+ -+ // Checking Carb Ratio Schedule -+ for item in profile.carbRatioSchedule.items { -+ let value = LoopQuantity(unit: .gramsPerUnit, doubleValue: item.value) -+ -+ if !Guardrail.carbRatio.absoluteBounds.contains(value) { -+ return .failure(.carbRatioError) -+ } -+ } -+ -+ // Checking Basal Rate Schedule -+ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { -+ for item in profile.basalRateSchedule.items { -+ let value = item.value -+ -+ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { -+ return .failure(.basalRateError) -+ } -+ } -+ } else { -+ return .failure(.maxBasalRateNotSet) -+ } -+ -+ return .success(()) -+ } -+ -+ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { -+ guard a.items.count == b.items.count else { -+ return false -+ } -+ -+ for (item1, item2) in zip(a.items, b.items) { -+ if item1.startTime != item2.startTime || -+ abs(item1.value.minValue - item2.value.minValue) > 0.01 || -+ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { -+ return false -+ } -+ } -+ -+ return true -+ } -+ -+ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { -+ guard a.items.count == b.items.count else { -+ return false -+ } -+ -+ for (item1, item2) in zip(a.items, b.items) { -+ if item1.startTime != item2.startTime || -+ abs(item1.value - item2.value) > 0.01 { -+ return false -+ } -+ } -+ -+ return true -+ } -+ -+ public func isCurrentProfileOutOfSync() -> Bool { -+ guard let currentProfileName = self.currentProfileName, -+ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), -+ let profile = try? getProfile(from: currentProfileReference), -+ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: LoopUnit.milligramsPerDeciliter), -+ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: LoopUnit.milligramsPerDeciliter) else { -+ return false // No current profile set or profile not found or schedules not available -+ } -+ -+ var isOutOfSync = false -+ -+ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: LoopUnit.milligramsPerDeciliter)!) { -+ print("Glucose target range schedule differs") -+ isOutOfSync = true -+ } -+ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { -+ print("Carb ratio schedule differs") -+ isOutOfSync = true -+ } -+ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { -+ print("Basal rate schedule differs") -+ isOutOfSync = true -+ } -+ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: LoopUnit.milligramsPerDeciliter)!) { -+ print("Insulin sensitivity schedule differs") -+ isOutOfSync = true -+ } -+ -+ return isOutOfSync -+ } -+} -+ -+extension Profile { -+ /// Passes the schedules this profile activates to `check`. -+ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { -+ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) -+ } -+} -+ -+extension ProfileViewModel { -+ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. -+ func therapySettings(applying profile: Profile) -> TherapySettings { -+ var settings = therapySettings -+ let unit = settings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter -+ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) -+ settings.carbRatioSchedule = profile.carbRatioSchedule -+ settings.basalRateSchedule = profile.basalRateSchedule -+ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) -+ return settings -+ } -+ -+ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. -+ func updateProfile(named name: String, from settings: TherapySettings) throws { -+ loadProfiles() -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ throw ProfileValidationError.fileLoadError -+ } -+ let profile = try getProfile(from: profileReference) -+ let updatedProfile = Profile( -+ name: profile.name, -+ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, -+ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, -+ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, -+ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, -+ sortOrder: profile.sortOrder -+ ) -+ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) -+ loadProfiles() -+ triggerProfileSync() -+ } -+} -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift -new file mode 100644 -index 00000000..a48fa667 ---- /dev/null -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift -@@ -0,0 +1,86 @@ -+// -+// ProfileViewModel.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-04-22. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import Combine -+import LoopKit -+import HealthKit -+import SwiftUI -+ -+public class ProfileViewModel: ObservableObject { -+ -+ @Published public var therapySettings: TherapySettings -+ @Published public var profiles = [ProfileReference]() -+ @Published public var currentProfileName: String? { -+ didSet { -+ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") -+ } -+ } -+ -+ internal weak var delegate: TherapySettingsViewModelDelegate? -+ -+ public init(therapySettings: TherapySettings, -+ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, -+ sensitivityOverridesEnabled: Bool = false, -+ adultChildInsulinModelSelectionEnabled: Bool = false, -+ prescription: Prescription? = nil, -+ delegate: TherapySettingsViewModelDelegate? = nil) { -+ self.therapySettings = therapySettings -+ self.delegate = delegate -+ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") -+ self.loadProfiles() -+ } -+ -+ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { -+ return delegate?.pumpSupportedIncrements() -+ } -+} -+ -+public struct Profile: Codable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ var sortOrder: Int? -+} -+ -+public struct ProfileReference: Codable, Equatable { -+ var name: String -+ var fileName: String -+ var sortOrder: Int? -+} -+ -+ -+// MARK: Saving -+extension ProfileViewModel { -+ -+ public func saveCorrectionRange(range: GlucoseRangeSchedule) { -+ therapySettings.glucoseTargetRangeSchedule = range -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveBasalRates(basalRates: BasalRateSchedule) { -+ therapySettings.basalRateSchedule = basalRates -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { -+ therapySettings.carbRatioSchedule = carbRatioSchedule -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { -+ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func triggerProfileSync() { -+ delegate?.updateCurrentProfileName() -+ } -+} -diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -index 81be1865..913c82ac 100644 ---- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -+++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { - func syncDeliveryLimits(deliveryLimits: DeliveryLimits) async throws -> DeliveryLimits - func saveCompletion(therapySettings: TherapySettings) - func pumpSupportedIncrements() -> PumpSupportedIncrements? -+ func updateCurrentProfileName() - } - - @Observable -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift -new file mode 100644 -index 00000000..446910e4 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift -@@ -0,0 +1,79 @@ -+// -+// NewProfileEditor.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-19. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+ -+struct NewProfileEditor: View { -+ @Binding var isPresented: Bool -+ @State var newProfileName: String -+ var viewModel: ProfileViewModel -+ @State private var showAlert = false -+ -+ var body: some View { -+ VStack(spacing: 0) { -+ ModalHeaderButtonBar( -+ leading: { cancelButton }, -+ center: { -+ Text("New Profile") -+ .font(.headline) -+ }, -+ trailing: { addButton } -+ ) -+ -+ TextField("Profile Name", text: $newProfileName) -+ .textFieldStyle(RoundedBorderTextFieldStyle()) -+ .padding() -+ .background( -+ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) -+ .fill(Color(.secondarySystemGroupedBackground)) -+ ) -+ } -+ .padding(.horizontal) -+ .alert(isPresented: $showAlert) { -+ Alert( -+ title: Text("Overwrite Existing Profile"), -+ message: Text("A profile with this name already exists. Would you like to overwrite it?"), -+ primaryButton: .destructive(Text("Overwrite")) { -+ self.viewModel.saveProfile(withName: self.newProfileName) -+ self.isPresented = false -+ }, -+ secondaryButton: .cancel() -+ ) -+ } -+ } -+ -+ var addButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ if viewModel.doesProfileExist(withName: self.newProfileName) { -+ self.showAlert = true -+ } else { -+ self.viewModel.saveProfile(withName: self.newProfileName) -+ self.isPresented = false -+ } -+ } -+ }, label: { -+ Text("Add") -+ } -+ ) -+ } -+ -+ var cancelButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ self.isPresented = false -+ } -+ }, label: { -+ Text("Cancel") -+ } -+ ) -+ } -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -new file mode 100644 -index 00000000..9eb00f90 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,432 @@ -+// -+// ProfilePreviewView.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-23. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+import HealthKit -+import LoopKit -+import LoopAlgorithm -+ -+enum ActiveAlert { -+ case load -+ case delete -+ case error -+ case loadEdited -+} -+ -+struct ProfilePreviewView: View { -+ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference -+ @ObservedObject var viewModel: ProfileViewModel -+ @Environment(\.dismissAction) var dismissAction -+ @Environment(\.presentationMode) var presentationMode -+ @Environment(\.appName) private var appName -+ @Environment(\.dosingStrategySelectionEnabled) private var dosingStrategySelectionEnabled -+ @State private var profile: Profile -+ @State private var editingDelegate: ProfileEditingDelegate -+ @State private var therapySettingsViewModel: TherapySettingsViewModel -+ -+ @State private var showingAlert = false -+ @State private var activeAlert: ActiveAlert = .load -+ @State private var errorText: String? -+ @State private var newProfileName: String = "" -+ @State private var isRenamingProfile = false -+ @State private var shouldDismissSelf: Bool = false -+ -+ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } -+ -+ init(viewModel: ProfileViewModel, profile: Profile) { -+ self.viewModel = viewModel -+ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) -+ _profile = State(initialValue: profile) -+ _editingDelegate = State(initialValue: editingDelegate) -+ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( -+ therapySettings: viewModel.therapySettings(applying: profile), -+ delegate: editingDelegate)) -+ } -+ -+ var body: some View { -+ ZStack { -+ ScrollView { -+ VStack(alignment: .leading) { -+ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") -+ .font(.body) -+ .foregroundColor(.primary) -+ .padding([.leading, .trailing]) -+ .fixedSize(horizontal: false, vertical: true) -+ .textCase(nil) -+ -+ profileCardStack -+ -+ Button(action: { -+ activeAlert = .load -+ showingAlert = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Load") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.blue) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ Button(action: { -+ isRenamingProfile = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Rename") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.orange) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ Button(action: { -+ activeAlert = .delete -+ showingAlert = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Delete") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.red) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ } -+ } -+ .navigationBarHidden(false) -+ .background(Color(.systemGroupedBackground)) -+ .navigationTitle(Text(profile.name)) -+ .alert(isPresented: $showingAlert) { -+ switch activeAlert { -+ case .load: -+ return Alert( -+ title: Text("Load Profile"), -+ message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: load), -+ secondaryButton: .cancel() -+ ) -+ case .delete: -+ return Alert( -+ title: Text("Delete Profile"), -+ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), -+ primaryButton: .destructive(Text("Delete"), action: { -+ viewModel.removeProfile(profile: profile) -+ self.presentationMode.wrappedValue.dismiss() -+ }), -+ secondaryButton: .cancel() -+ ) -+ case .error: -+ return Alert( -+ title: Text("Error loading profile"), -+ message: Text(errorText ?? "Unknown error"), -+ dismissButton: .default(Text("OK")) -+ ) -+ case .loadEdited: -+ return Alert( -+ title: Text("Load Edited Profile?"), -+ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), -+ primaryButton: .default(Text("Load"), action: load), -+ secondaryButton: .cancel(Text("Later")) -+ ) -+ } -+ } -+ if isRenamingProfile { -+ // Some hacks here to mimic Alert behaviour. -+ // This function should be migrateed to Alert when iOS 16 is required. -+ Color.black.opacity(0.5) -+ .edgesIgnoringSafeArea(.all) -+ .onTapGesture { -+ isRenamingProfile = false -+ } -+ -+ RenameProfileEditor( -+ isPresented: $isRenamingProfile, -+ currentProfileName: profile.name, -+ viewModel: viewModel, -+ shouldDismissParent: $shouldDismissSelf -+ ) -+ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) -+ .offset(y: -22) -+ } -+ } -+ .onAppear { -+ // Runs again when an editor closes. -+ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } -+ profile = saved -+ // The editors show the saved values, also after a save that failed. -+ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) -+ if let error = editingDelegate.saveError { -+ editingDelegate.saveError = nil -+ presentAlertLater(.error, errorText: error.localizedDescription) -+ } else if editingDelegate.savedEdits { -+ editingDelegate.savedEdits = false -+ if viewModel.currentProfileName == profile.name { -+ presentAlertLater(.loadEdited) -+ } -+ } -+ } -+ .onChange(of: shouldDismissSelf) { shouldDismiss in -+ if shouldDismiss { -+ self.presentationMode.wrappedValue.dismiss() -+ } -+ } -+ } -+ -+ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ guard !showingAlert else { return } -+ self.errorText = errorText -+ activeAlert = alert -+ showingAlert = true -+ } -+ } -+ -+ private func load() { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ -+ private var profileCardStack: CardStack { -+ var cards: [Card] = [] -+ -+ cards.append(correctionRangeSection) -+ cards.append(carbRatioSection) -+ cards.append(basalRatesSection) -+ cards.append(insulinSensitivitiesSection) -+ -+ return CardStack(cards: cards) -+ } -+ -+ private var correctionRangeSection: Card { -+ card(for: .glucoseTargetRange) { -+ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { -+ SectionDivider() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleRangeItem(time: items[index].startTime, -+ range: items[index].value, -+ unit: glucoseUnit, -+ guardrail: .correctionRange) -+ } -+ } -+ } -+ } -+ -+ private var carbRatioSection: Card { -+ card(for: .carbRatio) { -+ SectionDivider() -+ let items = profile.carbRatioSchedule.items -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: .gramsPerUnit, -+ guardrail: .carbRatio) -+ } -+ } -+ } -+ -+ private var basalRatesSection: Card { -+ card(for: .basalRate) { -+ let items = profile.basalRateSchedule.items -+ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates -+ { -+ SectionDivider() -+ let total = profile.basalRateSchedule.total() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: .internationalUnitsPerHour, -+ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) -+ } -+ HStack { -+ Text("Total") -+ .bold() -+ .foregroundColor(.primary) -+ Spacer() -+ Text(String(format: "%.2f ",total)) -+ .foregroundColor(.primary) + -+ Text("U/day") -+ .foregroundColor(.secondary) -+ } -+ } -+ } -+ } -+ -+ private var insulinSensitivitiesSection: Card { -+ card(for: .insulinSensitivity) { -+ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items -+ { -+ SectionDivider() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: sensitivityUnit, -+ guardrail: .insulinSensitivity) -+ } -+ } -+ } -+ } -+} -+ -+ -+// MARK: Utilities -+extension ProfilePreviewView { -+ -+ private var glucoseUnit: LoopUnit { -+ displayGlucosePreference.unit -+ } -+ -+ private var sensitivityUnit: LoopUnit { -+ switch glucoseUnit { -+ case .milligramsPerDeciliter: -+ return .milligramsPerDeciliterPerInternationalUnit -+ case .millimolesPerLiter: -+ return .millimolesPerLiterPerInternationalUnit -+ default: -+ return .milligramsPerDeciliterPerInternationalUnit -+ } -+ } -+ -+ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { -+ Card { -+ SectionWithTapToEdit( -+ // The basal editor needs the pump's supported rates. -+ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, -+ title: therapySetting.title, -+ descriptiveText: therapySetting.descriptiveText(appName: appName, dosingStrategySelectionEnabled: dosingStrategySelectionEnabled), -+ destination: { dismiss in -+ editor(for: therapySetting, dismiss: dismiss) -+ .environment(\.dismissAction, dismiss) -+ }, -+ content: content -+ ) -+ } -+ } -+ -+ /// The therapy settings editors, saving to this profile. -+ @ViewBuilder -+ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { -+ switch therapySetting { -+ case .glucoseTargetRange: -+ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .carbRatio: -+ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .basalRate: -+ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .insulinSensitivity: -+ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ default: -+ EmptyView() -+ } -+ } -+} -+ -+fileprivate struct SectionDivider: View { -+ var body: some View { -+ Divider() -+ .padding(.trailing, -16) -+ } -+} -+ -+fileprivate struct SettingsDivider: View { -+ var body: some View { -+ Divider() -+ .padding(.trailing, -8) -+ } -+} -+ -+/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. -+final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { -+ private let viewModel: ProfileViewModel -+ private let profileName: String -+ private let basalTimeZone: TimeZone -+ /// Set when edits were saved, so the view can offer to load the edited active profile. -+ var savedEdits = false -+ /// Set when saving edits to the profile file failed. -+ var saveError: Error? -+ -+ init(viewModel: ProfileViewModel, profile: Profile) { -+ self.viewModel = viewModel -+ self.profileName = profile.name -+ self.basalTimeZone = profile.basalRateSchedule.timeZone -+ } -+ -+ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { -+ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { -+ completion(.failure(ProfileValidationError.basalRateError)) -+ return -+ } -+ completion(.success(schedule)) -+ } -+ -+ func syncDeliveryLimits(deliveryLimits: DeliveryLimits) async throws -> DeliveryLimits { -+ return deliveryLimits -+ } -+ -+ func saveCompletion(therapySettings: TherapySettings) { -+ do { -+ try viewModel.updateProfile(named: profileName, from: therapySettings) -+ savedEdits = true -+ } catch { -+ saveError = error -+ } -+ } -+ -+ func pumpSupportedIncrements() -> PumpSupportedIncrements? { -+ return viewModel.pumpSupportedIncrements() -+ } -+ -+ func updateCurrentProfileName() {} -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift -new file mode 100644 -index 00000000..ffced54b ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift -@@ -0,0 +1,172 @@ -+// -+// ProfileView.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-04-22. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import AVFoundation -+import HealthKit -+import LoopKit -+import SwiftUI -+ -+public struct ProfileView: View { -+ @ObservedObject public var viewModel: ProfileViewModel -+ @Environment(\.dismissAction) var dismissAction -+ @State private var newProfileName: String = "" -+ @State private var isAddingNewProfile = false -+ @State private var selectedProfileIndex: Int? = nil -+ @State private var refreshID = UUID() -+ -+ enum ActiveAlert: String, Identifiable { -+ case update, info -+ -+ var id: String { -+ return self.rawValue -+ } -+ } -+ @State private var activeAlert: ActiveAlert? -+ -+ public init(viewModel: ProfileViewModel) { -+ self.viewModel = viewModel -+ } -+ -+ private var dismissButton: some View { -+ Button(action: dismissAction) { -+ Text(LocalizedString("Done", comment: "Text for dismiss button")) -+ .bold() -+ } -+ } -+ -+ public var body: some View { -+ ZStack { -+ NavigationView { -+ ConfigurationPageScrollView( -+ content: { -+ VStack(alignment: .leading) { -+ -+ if !viewModel.profiles.isEmpty { -+ List { -+ ForEach(viewModel.profiles.indices, id: \.self) { index in -+ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { -+ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { -+ HStack { -+ if viewModel.currentProfileName == viewModel.profiles[index].name { -+ Image(systemName: "checkmark") -+ .foregroundColor(Color.blue) -+ } else { -+ // Add indentation to align with the checkmark for the active profile -+ Image(systemName: "checkmark") -+ .opacity(0) // Invisible -+ } -+ -+ Text(viewModel.profiles[index].name) -+ } -+ } -+ } -+ } -+ .onMove(perform: moveProfile) -+ }.id(refreshID) -+ -+ } else { -+ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") -+ .font(.subheadline) -+ .foregroundColor(.secondary) -+ .padding([.leading, .trailing]) -+ .multilineTextAlignment(.leading) -+ .lineLimit(nil) -+ .textCase(nil) -+ } -+ } -+ }, -+ actionArea: { EmptyView() } // no action area in this case -+ ) -+ .navigationBarItems( -+ leading: HStack { -+ Button(action: { withAnimation { isAddingNewProfile = true } }) { -+ Image(systemName: "plus") -+ } -+ -+ Button(action: { activeAlert = .info }) { -+ Image(systemName: "info.circle") -+ .resizable() -+ .frame(width: 24, height: 24) -+ } -+ }, -+ trailing: dismissButton -+ ) -+ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) -+ } -+ .navigationBarHidden(false) -+ .background(Color(.systemGroupedBackground)) -+ .onAppear { -+ if viewModel.isCurrentProfileOutOfSync() { -+ activeAlert = .update -+ } -+ } -+ -+ .alert(item: $activeAlert) { alertType in -+ switch alertType { -+ case .update: -+ return Alert( -+ title: Text("Profile Outdated"), -+ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), -+ primaryButton: .default(Text("Save Current Settings")) { -+ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") -+ }, -+ secondaryButton: .cancel(Text("Keep Saved Profile")) -+ ) -+ case .info: -+ return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), -+ dismissButton: .default(Text("Got it!"))) -+ } -+ } -+ -+ if isAddingNewProfile { -+ DarkenedOverlay() -+ -+ NewProfileEditor( -+ isPresented: $isAddingNewProfile, -+ newProfileName: newProfileName, -+ viewModel: viewModel -+ ) -+ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) -+ } -+ } -+ } -+ -+ func moveProfile(from source: IndexSet, to destination: Int) { -+ viewModel.profiles.move(fromOffsets: source, toOffset: destination) -+ refreshID = UUID() -+ viewModel.updateProfilesOrder() -+ } -+} -+ -+struct ProfileView_Previews: PreviewProvider { -+ static let preview_glucoseScheduleItems = [ -+ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), -+ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), -+ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) -+ ] -+ -+ static let preview_therapySettings = TherapySettings( -+ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), -+ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), -+ unit: .milligramsPerDeciliter), -+ maximumBolus: 4, -+ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), -+ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: .milligramsPerDeciliterPerInternationalUnit, dailyItems: []), -+ carbRatioSchedule: nil, -+ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) -+ -+ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] -+ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] -+ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] -+ -+ static var previews: some View { -+ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) -+ } -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift -new file mode 100644 -index 00000000..8fd26665 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift -@@ -0,0 +1,92 @@ -+// -+// RenameProfileEditor.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-09-07. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+ -+struct RenameProfileEditor: View { -+ @Binding var isPresented: Bool -+ @State var currentProfileName: String -+ @State var newProfileName: String = "" -+ var viewModel: ProfileViewModel -+ @State private var showAlert = false -+ @Binding var shouldDismissParent: Bool -+ -+ var body: some View { -+ VStack(spacing: 0) { -+ ModalHeaderButtonBar( -+ leading: { cancelButton }, -+ center: { -+ Text("Rename Profile") -+ .font(.subheadline) -+ -+ }, -+ trailing: { renameButton } -+ ) -+ .onAppear { -+ self.newProfileName = currentProfileName -+ } -+ -+ TextField("Profile Name", text: $newProfileName) -+ .textFieldStyle(RoundedBorderTextFieldStyle()) -+ .padding() -+ .background( -+ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) -+ .fill(Color(.secondarySystemGroupedBackground)) -+ ) -+ } -+ .padding(.horizontal) -+ .alert(isPresented: $showAlert) { -+ Alert( -+ title: Text("Overwrite Existing Profile"), -+ message: Text("A profile with this name already exists. Would you like to overwrite it?"), -+ primaryButton: .destructive(Text("Overwrite")) { -+ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) -+ self.shouldDismissParent = true -+ self.isPresented = false -+ }, -+ secondaryButton: .cancel() -+ ) -+ } -+ } -+ -+ var renameButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ guard newProfileName != currentProfileName else { -+ self.isPresented = false -+ return -+ } -+ -+ if viewModel.doesProfileExist(withName: self.newProfileName) { -+ self.showAlert = true -+ } else { -+ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) -+ self.shouldDismissParent = true -+ self.isPresented = false -+ } -+ } -+ }, label: { -+ Text("Rename") -+ } -+ ) -+ } -+ -+ var cancelButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ self.isPresented = false -+ } -+ }, label: { -+ Text("Cancel") -+ } -+ ) -+ } -+} -Submodule LoopOnboarding contains modified content -diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -index 1ddc82b..54421a1 100644 ---- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -+++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -@@ -381,6 +381,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { - maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount - ) - } -+ -+ func updateCurrentProfileName() { -+ } - } - - -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index ea85f5a..eb8f03d 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -23,6 +23,10 @@ - A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; -@@ -163,6 +167,10 @@ - A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; - A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; -@@ -279,6 +287,7 @@ - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, - A934B6172358F49300949C8B /* StoredSettings.swift */, -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, - A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, - A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, -@@ -338,6 +347,7 @@ - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, - A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, - ); - path = NightscoutServiceKitTests; -@@ -380,6 +390,7 @@ - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, - ); - path = Notifications; - sourceTree = ""; -@@ -428,6 +439,7 @@ - A9AA6E5729EB06AA008FFA78 /* Actions */ = { - isa = PBXGroup; - children = ( -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -@@ -765,6 +777,9 @@ - C1398D2227C434FB00416AD6 /* Data.swift in Sources */, - A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, - A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, - C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, - ); -@@ -781,6 +796,7 @@ - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, - A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -new file mode 100644 -index 0000000..588ded1 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -@@ -0,0 +1,104 @@ -+// -+// SavedProfile.swift -+// NightscoutServiceKit -+// -+ -+import CryptoKit -+import Foundation -+import LoopAlgorithm -+import LoopKit -+import NightscoutKit -+ -+/// A profile saved with the Profiles settings screen, read from the file it was saved to. -+struct SavedProfile: Decodable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ -+ private static var directory: URL { -+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") -+ } -+ -+ static func loadAll() -> [SavedProfile] { -+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] -+ return files -+ .filter { $0.pathExtension == "json" } -+ .compactMap { try? Data(contentsOf: $0) } -+ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } -+ } -+ -+ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. -+ static func nightscoutStore(unit: LoopUnit) -> [String: ProfileSet.Profile] { -+ var store: [String: ProfileSet.Profile] = [:] -+ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. -+ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { -+ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) -+ } -+ return store -+ } -+ -+ /// True when this profile uploads with this fingerprint, in either glucose unit. -+ func matches(fingerprint: String) -> Bool { -+ [LoopUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { -+ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint -+ } -+ } -+ -+ func nightscoutProfile(unit: LoopUnit) -> ProfileSet.Profile? { -+ guard let correctionRange = correctionRange.schedule(for: unit), -+ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) -+ else { -+ return nil -+ } -+ return ProfileSet.Profile( -+ timezone: basalRateSchedule.timeZone, -+ dia: .hours(6), -+ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, -+ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, -+ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) -+ } -+} -+ -+extension ProfileSet.Profile { -+ /// Followers compute the same value from the uploaded profile and send it with a switch command, -+ /// so Loop only activates the values the follower previewed. -+ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` -+ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. -+ var remoteFingerprint: String { -+ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { -+ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") -+ } -+ let canonical = [ -+ "basal=" + items(basal), -+ "carbratio=" + items(carbratio), -+ "sens=" + items(sensitivity), -+ "target_low=" + items(targetLow), -+ "target_high=" + items(targetHigh), -+ ].joined(separator: ";") -+ let digest = SHA256.hash(data: Data(canonical.utf8)) -+ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) -+ } -+} -+ -+enum RemoteProfileSwitchError: LocalizedError { -+ case profileChanged -+ case unavailable -+ case notActivated(String, Error) -+ -+ /// Followers read the profile name from this text to match the reply to their command. -+ var errorDescription: String? { -+ switch self { -+ case .profileChanged: -+ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") -+ case .unavailable: -+ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") -+ case .notActivated(let name, let error): -+ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index e7bb1ad..1b43f76 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -180,7 +180,7 @@ extension StoredDosingDecision { - enacted: automaticDoseDecision?.loopStatusEnacted, - failureReason: automaticDoseDecision?.loopStatusFailureReason, - failureCode: automaticDoseDecision?.errors.first?.id, -- testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported], -+ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]], - algorithmExperiments: (automaticDoseDecision?.algorithmExperiments ?? algorithmExperiments)?.map { $0.jsonObject }), - overrideStatus: overrideStatus) - } -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -index 3142223..ae6fb9a 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -@@ -93,12 +93,21 @@ extension StoredSettings { - return nil - } - -+ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) -+ store["Default"] = profile -+ var defaultProfile = "Default" -+ -+ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { -+ defaultProfile = name -+ store[name] = profile -+ } -+ - return ProfileSet( - startDate: date, - units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), - enteredBy: "Loop", -- defaultProfile: "Default", -- store: ["Default": profile], -+ defaultProfile: defaultProfile, -+ store: store, - settings: loopSettings, - syncIdentifier: syncIdentifier.uuidString, - isAPNSProduction: Bundle.main.isAPNSProductionEnvironment, -diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index fe3c774..3a513fb 100644 ---- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift -+++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -460,6 +460,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - success = true - message = "Override '\(overrideCommand.name)' enacted successfully" - -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ success = true -+ message = "Profile '\(profileSwitchCommand.name)' activated" -+ - case .cancelTemporaryOverride: - commandType = .cancelOverride - try await self.serviceDelegate?.cancelRemoteOverride() -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index 91ad56c..29d3563 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -9,6 +9,7 @@ - import Foundation - - public enum Action: Codable { -+ case profileSwitch(ProfileSwitchAction) - case temporaryScheduleOverride(OverrideAction) - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) -@@ -34,6 +35,8 @@ public enum Action: Codable { - return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") - case .temporaryScheduleOverride: - return LocalizedString("Override", comment: "The remote action name for Override") -+ case .profileSwitch: -+ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") - } - } - -@@ -54,6 +57,8 @@ public enum Action: Codable { - return "" - case .temporaryScheduleOverride(let overrideAction): - return overrideAction.name -+ case .profileSwitch(let profileSwitchAction): -+ return profileSwitchAction.name - } - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -new file mode 100644 -index 0000000..ce03ded ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -@@ -0,0 +1,17 @@ -+// -+// ProfileSwitchAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct ProfileSwitchAction: Codable { -+ -+ public let name: String -+ public let fingerprint: String -+ -+ public init(name: String, fingerprint: String) { -+ self.name = name -+ self.fingerprint = fingerprint -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 6092bc9..aabb47f 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -49,6 +49,7 @@ class RemoteNotificationResponseManager { - case carbsEdit = "carbs_edit" - case override = "override" - case cancelOverride = "cancel_override" -+ case profileSwitch = "profile_switch" - } - - func sendResponseNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -new file mode 100644 -index 0000000..487c620 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -0,0 +1,50 @@ -+// -+// ProfileSwitchRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Switches to a saved profile. `expiration` is required: decoding fails without it. -+public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { -+ -+ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. -+ static let commandKey = "profile-switch" -+ -+ public let profileName: String -+ public let fingerprint: String -+ public let remoteAddress: String -+ public let expirationDate: Date -+ public let sentAt: Date? -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ public var expiration: Date? { -+ return expirationDate -+ } -+ -+ enum CodingKeys: String, CodingKey { -+ case profileName = "profile-switch" -+ case fingerprint = "profile-switch-fingerprint" -+ case remoteAddress = "remote-address" -+ case expirationDate = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification[commandKey] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index 6f0524d..87f1e92 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -122,6 +122,8 @@ extension Dictionary { - return try OverrideRemoteNotification(dictionary: self) - } else if OverrideCancelRemoteNotification.includedInNotification(self) { - return try OverrideCancelRemoteNotification(dictionary: self) -+ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { -+ return try ProfileSwitchRemoteNotification(dictionary: self) - } else { - throw RemoteNotificationError.unhandledNotification(self) - } -diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..2fcec94 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -@@ -0,0 +1,71 @@ -+// -+// ProfileSwitchRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+import LoopKit -+import NightscoutKit -+@testable import NightscoutServiceKit -+ -+final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "sent-at": "2023-02-25T20:46:35.778Z", -+ "expiration": "2023-02-25T20:51:35.778Z", -+ "otp": "123456" -+ ] -+ -+ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) -+ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) -+ -+ XCTAssertEqual(profileSwitch.profileName, "Sport") -+ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") -+ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) -+ XCTAssertTrue(profileSwitch.otpValidationRequired()) -+ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { -+ return XCTFail("Expected a profile switch action") -+ } -+ XCTAssertEqual(action.name, "Sport") -+ } -+ -+ func testParseProfileSwitchNotification_MissingExpiration_Fails() { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "otp": "123456" -+ ] -+ -+ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) -+ } -+ -+ func testFingerprint_MatchesFollowerVector() { -+ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { -+ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } -+ } -+ let profile = ProfileSet.Profile( -+ timezone: TimeZone(secondsFromGMT: 3600)!, -+ dia: .hours(6), -+ sensitivity: items([(0, 45)]), -+ carbratio: items([(0, 10)]), -+ basal: items([(0, 0.85), (21600, 1.1)]), -+ targetLow: items([(0, 100)]), -+ targetHigh: items([(0, 110)]), -+ units: "mg/dL") -+ -+ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") -+ } -+} -+ -+private extension ISO8601DateFormatter { -+ static let fractional: ISO8601DateFormatter = { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ }() -+} -diff --git a/profiles.loopcustomization b/profiles.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/profiles/nextdev_profiles_remote_carb_edit_remote_window.patch b/profiles/nextdev_profiles_remote_carb_edit_remote_window.patch deleted file mode 100644 index f69df8b..0000000 --- a/profiles/nextdev_profiles_remote_carb_edit_remote_window.patch +++ /dev/null @@ -1,2134 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index 3c9d61cb..5591c9a5 100644 ---- a/Loop/Loop/Managers/DeviceDataManager.swift -+++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -1407,6 +1407,13 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { - return try await pumpManager?.syncDeliveryLimits(limits: deliveryLimits) ?? deliveryLimits - } - -+ func updateCurrentProfileName() { -+ NotificationCenter.default.post(name: .LoopDataUpdated, -+ object: nil, -+ userInfo: [LoopDataManager.LoopUpdateContextKey: LoopUpdateContext.preferences.rawValue]) -+ settingsManager.storeSettings() -+ } -+ - func saveCompletion(therapySettings: TherapySettings) { - settingsManager.mutateLoopSettings { settings in - settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule -diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift -index 18204a86..58b4516b 100644 ---- a/Loop/Loop/Managers/LoopAppManager.swift -+++ b/Loop/Loop/Managers/LoopAppManager.swift -@@ -401,6 +401,7 @@ class LoopAppManager: NSObject { - - dosingDecisionStore.delegate = deviceDataManager - remoteDataServicesManager.delegate = deviceDataManager -+ servicesManager.therapySettingsViewModelDelegate = deviceDataManager - - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, temporaryPresetsManager.presetHistory, deviceDataManager.deviceLog, alertManager.alertStore] - criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, -diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift -index 69f660ca..feb53338 100644 ---- a/Loop/Loop/Managers/LoopDataManager.swift -+++ b/Loop/Loop/Managers/LoopDataManager.swift -@@ -1744,7 +1744,7 @@ extension LoopDataManager: ManualDoseViewModelDelegate { - var settings: StoredSettings { - settingsProvider.settings - } -- -+ - var scheduleOverride: TemporaryScheduleOverride? { - temporaryPresetsManager.scheduleOverride - } -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 18975ce1..1cf442dd 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -326,6 +326,38 @@ extension NotificationManager { - UNUserNotificationCenter.current().add(request) - } - -+ static func sendRemoteProfileNotification(name: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfile", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteProfileFailureNotification(name: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfileFailure", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ private static func remoteProfileNotificationBody(name: String) -> String { -+ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) -+ } -+ - private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { - return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) - } -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 3aaf5212..f710fb3e 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -30,6 +30,7 @@ class ServicesManager { - weak var servicesManagerDelegate: ServicesManagerDelegate? - weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? - weak var supportManager: SupportManager? -+ weak var therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? - - private var services = [Service]() - -@@ -476,3 +477,57 @@ extension Service { - ] - } - } -+ -+// MARK: - RemoteProfileActionDelegate -+ -+extension ServicesManager: RemoteProfileActionDelegate { -+ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { -+ do { -+ guard let therapySettingsViewModelDelegate else { -+ throw RemoteProfileActionError.unavailable -+ } -+ let viewModel = ProfileViewModel(therapySettings: settingsManager.therapySettings, delegate: therapySettingsViewModelDelegate) -+ guard therapySettingsViewModelDelegate.pumpSupportedIncrements() != nil else { -+ throw RemoteProfileActionError.noPump -+ } -+ guard let profile = try viewModel.savedProfile(named: name) else { -+ throw RemoteProfileActionError.profileNotFound(name) -+ } -+ try profile.verify(with: verify) -+ if case .failure(let error) = viewModel.validateProfile(profile) { -+ throw error -+ } -+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ continuation.resume() -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ NotificationManager.sendRemoteProfileNotification(name: name) -+ } catch { -+ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) -+ throw error -+ } -+ } -+ -+ enum RemoteProfileActionError: LocalizedError { -+ case unavailable -+ case noPump -+ case profileNotFound(String) -+ -+ var errorDescription: String? { -+ switch self { -+ case .unavailable: -+ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: profile view model delegate missing.") -+ case .noPump: -+ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") -+ case .profileNotFound(let name): -+ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) -+ } -+ } -+ } -+} -diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift -index 1faefbc5..737acf28 100644 ---- a/Loop/Loop/Views/SettingsView.swift -+++ b/Loop/Loop/Views/SettingsView.swift -@@ -55,6 +55,7 @@ struct SettingsView: View { - - case favoriteFoods - case presets -+ case profiles - } - } - -@@ -155,6 +156,9 @@ struct SettingsView: View { - } - case .favoriteFoods: - FavoriteFoodsView(insightsDelegate: viewModel.favoriteFoodInsightsDelegate) -+ case .profiles: -+ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), -+ delegate: self.viewModel.therapySettingsViewModelDelegate)) - } - } - .environmentObject(displayGlucosePreference) -@@ -416,6 +420,11 @@ extension SettingsView { - .accessibilityIdentifier("button_TherapySettings") - } - -+ LargeButton(action: { sheet = .profiles }, -+ includeArrow: true, -+ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), -+ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), -+ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) - ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in - item.view - } -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj -index cc88ad17..c543b73a 100644 ---- a/LoopKit/LoopKit.xcodeproj/project.pbxproj -+++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj -@@ -7,6 +7,12 @@ - objects = { - - /* Begin PBXBuildFile section */ -+ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; -+ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; -+ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; -+ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; -+ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; -+ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; - 9D3D2DA3B9824EF48981F0E4 /* StoredInsulinModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CAB9E826A3254800A57273 /* StoredInsulinModel.swift */; }; - 485ACF65C30744BFBC979CEF /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A985464E251449FE0099C1A6 /* NotificationSettings.swift */; }; - 2383C3D7967B4602B8EB4A5B /* SettingsObject+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = A912BE2A245B9E8600CBE199 /* SettingsObject+CoreDataProperties.swift */; }; -@@ -1065,6 +1071,12 @@ - /* End PBXCopyFilesBuildPhase section */ - - /* Begin PBXFileReference section */ -+ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; -+ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; -+ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; -+ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; -+ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; -+ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; - D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; - 142CB75C2A60C01E0075748A /* ListButtonStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListButtonStyle.swift; sourceTree = ""; }; - 142CB7602A60C0440075748A /* FavoriteFood.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FavoriteFood.swift; sourceTree = ""; }; -@@ -2971,6 +2983,8 @@ - B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, - B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, - 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, -+ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, -+ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, - ); - path = ViewModels; - sourceTree = ""; -@@ -3146,6 +3160,10 @@ - E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, - 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, - 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, -+ DDAF746629F422C600719F0A /* ProfileView.swift */, -+ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, -+ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, -+ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, - ); - path = "Settings Editors"; - sourceTree = ""; -@@ -3815,6 +3833,12 @@ - E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, - B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, - 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, -+ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, -+ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, -+ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, -+ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, -+ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, -+ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, - 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, - 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, - 892A5DA02231E130008961AB /* CompletionNotifying.swift in Sources */, -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 7da27b70..39c0eee9 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -8,6 +8,12 @@ - - import Foundation - -+/// Switches to a saved therapy profile on a remote command. -+public protocol RemoteProfileActionDelegate: AnyObject { -+ /// `verify` receives the schedules about to be activated and throws to reject them. -+ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws -+} -+ - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws - func cancelRemoteOverride() async throws -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -new file mode 100644 -index 00000000..6c27aa9f ---- /dev/null -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,467 @@ -+// -+// ProfileViewModel+FileManagement.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-19. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import LoopKit -+import HealthKit -+import LoopAlgorithm -+ -+public enum ProfileValidationError: Error, LocalizedError { -+ case fileLoadError -+ case unsupportedIncrement -+ case correctionRangeError -+ case insulinSensitivityError -+ case carbRatioError -+ case basalRateError -+ case maxBasalRateNotSet -+ -+ public var errorDescription: String? { -+ switch self { -+ case .fileLoadError: -+ return "Unable to load the profile file." -+ case .unsupportedIncrement: -+ return "The profile contains unsupported increments." -+ case .correctionRangeError: -+ return "Correction Range values are out of bounds." -+ case .insulinSensitivityError: -+ return "Insulin Sensitivity values are out of bounds." -+ case .carbRatioError: -+ return "Carb Ratio values are out of bounds." -+ case .basalRateError: -+ return "Basal Rate values are out of bounds." -+ case .maxBasalRateNotSet: -+ return "Maximum Basal Rate is not set." -+ } -+ } -+} -+ -+public enum LoadProfileResult { -+ case success -+ case failure(Error) -+} -+ -+// MARK: File management -+extension ProfileViewModel { -+ private func setCurrentProfile(name: String, syncChange: Bool = false) { -+ let sanitizedProfileName = sanitizeProfileName(name) -+ currentProfileName = sanitizedProfileName -+ if syncChange { -+ triggerProfileSync() -+ } -+ } -+ -+ private func clearCurrentProfile() { -+ currentProfileName = nil -+ triggerProfileSync() -+ } -+ -+ private func getCurrentProfileName() -> String? { -+ return currentProfileName -+ } -+ -+ private var profilesDirectory: URL { -+ let fileManager = FileManager.default -+ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] -+ return documentsDirectory.appendingPathComponent("LoopProfile") -+ } -+ -+ private func createProfileDirectoryIfNotExists() throws { -+ let fileManager = FileManager.default -+ var isDir : ObjCBool = false -+ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { -+ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) -+ } else if !isDir.boolValue { -+ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) -+ } -+ } -+ -+ private func getAllProfileFiles() throws -> [URL] { -+ let fileManager = FileManager.default -+ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) -+ return files.filter { $0.pathExtension == "json" } -+ } -+ -+ private func encodeProfile(_ profile: Profile) throws -> Data { -+ return try JSONEncoder().encode(profile) -+ } -+ -+ private func decodeProfile(from data: Data) throws -> Profile { -+ return try JSONDecoder().decode(Profile.self, from: data) -+ } -+ -+ public func sanitizeProfileName(_ name: String) -> String { -+ // Replace problematic characters -+ var sanitized = name.replacingOccurrences(of: ".", with: "_") -+ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") -+ -+ // Limit the name to 32 characters -+ if sanitized.count > 32 { -+ sanitized = String(sanitized.prefix(32)) -+ } -+ -+ return sanitized -+ } -+ -+ public func saveProfile(withName name: String) { -+ let sanitizedName = sanitizeProfileName(name) -+ -+ do { -+ try createProfileDirectoryIfNotExists() -+ -+ if let existingProfile = getProfileReference(withName: sanitizedName) { -+ removeProfile(profileReference: existingProfile) -+ } -+ -+ let profile = Profile( -+ name: sanitizedName, -+ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, -+ carbRatioSchedule: therapySettings.carbRatioSchedule!, -+ basalRateSchedule: therapySettings.basalRateSchedule!, -+ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, -+ sortOrder: -1 -+ ) -+ -+ let jsonData = try encodeProfile(profile) -+ -+ let dateFormatter = DateFormatter() -+ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" -+ let fileName = dateFormatter.string(from: Date()) + ".json" -+ let fileURL = profilesDirectory.appendingPathComponent(fileName) -+ -+ try jsonData.write(to: fileURL) -+ setCurrentProfile(name: sanitizedName, syncChange: true) -+ -+ self.loadProfiles() -+ } catch { -+ print("An error occurred while saving profile: \(error)") -+ } -+ } -+ -+ public func renameProfile(oldName: String, newName: String) { -+ let sanitizedProfileName = sanitizeProfileName(newName) -+ -+ if currentProfileName == oldName { -+ setCurrentProfile(name: sanitizedProfileName, syncChange: true) -+ } -+ -+ do { -+ guard let profileReference = getProfileReference(withName: oldName) else { -+ print("Profile with old name not found.") -+ return -+ } -+ -+ let profile = try getProfile(from: profileReference) -+ -+ let newProfile = Profile( -+ name: sanitizedProfileName, -+ correctionRange: profile.correctionRange, -+ carbRatioSchedule: profile.carbRatioSchedule, -+ basalRateSchedule: profile.basalRateSchedule, -+ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, -+ sortOrder: profile.sortOrder -+ ) -+ -+ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { -+ removeProfile(profileReference: existingProfile) -+ } -+ -+ let jsonData = try encodeProfile(newProfile) -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ -+ try jsonData.write(to: fileURL) -+ -+ self.loadProfiles() -+ triggerProfileSync() -+ } catch { -+ print("An error occurred while renaming profile: \(error)") -+ } -+ } -+ -+ public func updateProfilesOrder() { -+ for (index, profileReference) in profiles.enumerated() { -+ do { -+ var profile = try getProfile(from: profileReference) -+ let newProfile = Profile( -+ name: profile.name, -+ correctionRange: profile.correctionRange, -+ carbRatioSchedule: profile.carbRatioSchedule, -+ basalRateSchedule: profile.basalRateSchedule, -+ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, -+ sortOrder: index -+ ) -+ -+ let jsonData = try encodeProfile(newProfile) -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ try jsonData.write(to: fileURL) -+ -+ } catch { -+ print("An error occurred while updating profile order: \(error)") -+ } -+ } -+ loadProfiles() -+ } -+ -+ public func loadProfiles() { -+ do { -+ let profileFiles = try getAllProfileFiles() -+ -+ var newProfiles = [ProfileReference]() -+ for fileURL in profileFiles { -+ let data = try Data(contentsOf: fileURL) -+ let profile = try decodeProfile(from: data) -+ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) -+ newProfiles.append(profileRef) -+ } -+ -+ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } -+ -+ self.profiles = newProfiles -+ } catch { -+ print("An error occurred while loading profiles: \(error)") -+ } -+ } -+ -+ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { -+ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && -+ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && -+ therapySettings.basalRateSchedule == profile.basalRateSchedule && -+ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { -+ self.setCurrentProfile(name: profile.name, syncChange: true) -+ completion(.success) -+ } -+ else -+ { -+ do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in -+ switch result { -+ case .success(let syncedSchedule): -+ DispatchQueue.main.async { -+ let previousSettings = self.therapySettings -+ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter -+ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! -+ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule -+ self.therapySettings.basalRateSchedule = syncedSchedule -+ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! -+ // One settings change, so the stored and uploaded settings never mix two profiles. -+ self.setCurrentProfile(name: profile.name, syncChange: false) -+ self.delegate?.saveCompletion(therapySettings: self.therapySettings) -+ if self.therapySettings == previousSettings { -+ // Nothing changed, so only the profile name needs to reach the uploaded settings. -+ self.triggerProfileSync() -+ } -+ print("New profile loaded") -+ completion(.success) -+ } -+ case .failure(let error): -+ print("An error occurred while syncing basal rates: \(error)") -+ completion(.failure(error)) -+ } -+ }) -+ } -+ } -+ } -+ -+ func getProfileReference(withName name: String) -> ProfileReference? { -+ let sanitizedInputName = sanitizeProfileName(name) -+ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) -+ } -+ -+ public func removeProfile(profile: Profile) { -+ guard let profileReference = getProfileReference(withName: profile.name) else { -+ print("No ProfileReference found for given Profile") -+ return -+ } -+ -+ removeProfile(profileReference: profileReference) -+ if getCurrentProfileName() == profile.name { -+ clearCurrentProfile() -+ } else { -+ triggerProfileSync() -+ } -+ } -+ -+ public func removeProfile(profileReference: ProfileReference) { -+ do { -+ let fileManager = FileManager.default -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ try fileManager.removeItem(at: fileURL) -+ loadProfiles() -+ } catch { -+ print("An error occurred while removing profile: \(error)") -+ } -+ } -+ -+ func doesProfileExist(withName name: String) -> Bool { -+ return profiles.contains(where: { $0.name == name }) -+ } -+ -+ public func getProfile(from profileReference: ProfileReference) throws -> Profile { -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ let data = try Data(contentsOf: fileURL) -+ let getProfile = try decodeProfile(from: data) -+ return getProfile -+ } -+ -+ /// The saved profile named `name`, or nil when there is none. -+ public func savedProfile(named name: String) throws -> Profile? { -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ return nil -+ } -+ return try getProfile(from: profileReference) -+ } -+ -+ public func validateProfile(_ profile: Profile) -> Result { -+ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { -+ return .failure(.fileLoadError) -+ } -+ -+ // Checking Correction Range Schedule -+ for item in profile.correctionRange.items { -+ let minValue = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) -+ let maxValue = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) -+ -+ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { -+ return .failure(.correctionRangeError) -+ } -+ } -+ -+ // Checking Insulin Sensitivity Schedule -+ for item in profile.insulinSensitivitySchedule.items { -+ let value = LoopQuantity(unit: .milligramsPerDeciliterPerInternationalUnit, doubleValue: item.value) -+ -+ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { -+ return .failure(.insulinSensitivityError) -+ } -+ } -+ -+ // Checking Carb Ratio Schedule -+ for item in profile.carbRatioSchedule.items { -+ let value = LoopQuantity(unit: .gramsPerUnit, doubleValue: item.value) -+ -+ if !Guardrail.carbRatio.absoluteBounds.contains(value) { -+ return .failure(.carbRatioError) -+ } -+ } -+ -+ // Checking Basal Rate Schedule -+ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { -+ for item in profile.basalRateSchedule.items { -+ let value = item.value -+ -+ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { -+ return .failure(.basalRateError) -+ } -+ } -+ } else { -+ return .failure(.maxBasalRateNotSet) -+ } -+ -+ return .success(()) -+ } -+ -+ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { -+ guard a.items.count == b.items.count else { -+ return false -+ } -+ -+ for (item1, item2) in zip(a.items, b.items) { -+ if item1.startTime != item2.startTime || -+ abs(item1.value.minValue - item2.value.minValue) > 0.01 || -+ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { -+ return false -+ } -+ } -+ -+ return true -+ } -+ -+ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { -+ guard a.items.count == b.items.count else { -+ return false -+ } -+ -+ for (item1, item2) in zip(a.items, b.items) { -+ if item1.startTime != item2.startTime || -+ abs(item1.value - item2.value) > 0.01 { -+ return false -+ } -+ } -+ -+ return true -+ } -+ -+ public func isCurrentProfileOutOfSync() -> Bool { -+ guard let currentProfileName = self.currentProfileName, -+ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), -+ let profile = try? getProfile(from: currentProfileReference), -+ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: LoopUnit.milligramsPerDeciliter), -+ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: LoopUnit.milligramsPerDeciliter) else { -+ return false // No current profile set or profile not found or schedules not available -+ } -+ -+ var isOutOfSync = false -+ -+ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: LoopUnit.milligramsPerDeciliter)!) { -+ print("Glucose target range schedule differs") -+ isOutOfSync = true -+ } -+ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { -+ print("Carb ratio schedule differs") -+ isOutOfSync = true -+ } -+ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { -+ print("Basal rate schedule differs") -+ isOutOfSync = true -+ } -+ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: LoopUnit.milligramsPerDeciliter)!) { -+ print("Insulin sensitivity schedule differs") -+ isOutOfSync = true -+ } -+ -+ return isOutOfSync -+ } -+} -+ -+extension Profile { -+ /// Passes the schedules this profile activates to `check`. -+ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { -+ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) -+ } -+} -+ -+extension ProfileViewModel { -+ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. -+ func therapySettings(applying profile: Profile) -> TherapySettings { -+ var settings = therapySettings -+ let unit = settings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter -+ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) -+ settings.carbRatioSchedule = profile.carbRatioSchedule -+ settings.basalRateSchedule = profile.basalRateSchedule -+ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) -+ return settings -+ } -+ -+ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. -+ func updateProfile(named name: String, from settings: TherapySettings) throws { -+ loadProfiles() -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ throw ProfileValidationError.fileLoadError -+ } -+ let profile = try getProfile(from: profileReference) -+ let updatedProfile = Profile( -+ name: profile.name, -+ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, -+ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, -+ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, -+ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, -+ sortOrder: profile.sortOrder -+ ) -+ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) -+ loadProfiles() -+ triggerProfileSync() -+ } -+} -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift -new file mode 100644 -index 00000000..a48fa667 ---- /dev/null -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift -@@ -0,0 +1,86 @@ -+// -+// ProfileViewModel.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-04-22. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import Combine -+import LoopKit -+import HealthKit -+import SwiftUI -+ -+public class ProfileViewModel: ObservableObject { -+ -+ @Published public var therapySettings: TherapySettings -+ @Published public var profiles = [ProfileReference]() -+ @Published public var currentProfileName: String? { -+ didSet { -+ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") -+ } -+ } -+ -+ internal weak var delegate: TherapySettingsViewModelDelegate? -+ -+ public init(therapySettings: TherapySettings, -+ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, -+ sensitivityOverridesEnabled: Bool = false, -+ adultChildInsulinModelSelectionEnabled: Bool = false, -+ prescription: Prescription? = nil, -+ delegate: TherapySettingsViewModelDelegate? = nil) { -+ self.therapySettings = therapySettings -+ self.delegate = delegate -+ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") -+ self.loadProfiles() -+ } -+ -+ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { -+ return delegate?.pumpSupportedIncrements() -+ } -+} -+ -+public struct Profile: Codable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ var sortOrder: Int? -+} -+ -+public struct ProfileReference: Codable, Equatable { -+ var name: String -+ var fileName: String -+ var sortOrder: Int? -+} -+ -+ -+// MARK: Saving -+extension ProfileViewModel { -+ -+ public func saveCorrectionRange(range: GlucoseRangeSchedule) { -+ therapySettings.glucoseTargetRangeSchedule = range -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveBasalRates(basalRates: BasalRateSchedule) { -+ therapySettings.basalRateSchedule = basalRates -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { -+ therapySettings.carbRatioSchedule = carbRatioSchedule -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { -+ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func triggerProfileSync() { -+ delegate?.updateCurrentProfileName() -+ } -+} -diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -index 81be1865..913c82ac 100644 ---- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -+++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { - func syncDeliveryLimits(deliveryLimits: DeliveryLimits) async throws -> DeliveryLimits - func saveCompletion(therapySettings: TherapySettings) - func pumpSupportedIncrements() -> PumpSupportedIncrements? -+ func updateCurrentProfileName() - } - - @Observable -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift -new file mode 100644 -index 00000000..446910e4 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift -@@ -0,0 +1,79 @@ -+// -+// NewProfileEditor.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-19. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+ -+struct NewProfileEditor: View { -+ @Binding var isPresented: Bool -+ @State var newProfileName: String -+ var viewModel: ProfileViewModel -+ @State private var showAlert = false -+ -+ var body: some View { -+ VStack(spacing: 0) { -+ ModalHeaderButtonBar( -+ leading: { cancelButton }, -+ center: { -+ Text("New Profile") -+ .font(.headline) -+ }, -+ trailing: { addButton } -+ ) -+ -+ TextField("Profile Name", text: $newProfileName) -+ .textFieldStyle(RoundedBorderTextFieldStyle()) -+ .padding() -+ .background( -+ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) -+ .fill(Color(.secondarySystemGroupedBackground)) -+ ) -+ } -+ .padding(.horizontal) -+ .alert(isPresented: $showAlert) { -+ Alert( -+ title: Text("Overwrite Existing Profile"), -+ message: Text("A profile with this name already exists. Would you like to overwrite it?"), -+ primaryButton: .destructive(Text("Overwrite")) { -+ self.viewModel.saveProfile(withName: self.newProfileName) -+ self.isPresented = false -+ }, -+ secondaryButton: .cancel() -+ ) -+ } -+ } -+ -+ var addButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ if viewModel.doesProfileExist(withName: self.newProfileName) { -+ self.showAlert = true -+ } else { -+ self.viewModel.saveProfile(withName: self.newProfileName) -+ self.isPresented = false -+ } -+ } -+ }, label: { -+ Text("Add") -+ } -+ ) -+ } -+ -+ var cancelButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ self.isPresented = false -+ } -+ }, label: { -+ Text("Cancel") -+ } -+ ) -+ } -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -new file mode 100644 -index 00000000..9eb00f90 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,432 @@ -+// -+// ProfilePreviewView.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-23. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+import HealthKit -+import LoopKit -+import LoopAlgorithm -+ -+enum ActiveAlert { -+ case load -+ case delete -+ case error -+ case loadEdited -+} -+ -+struct ProfilePreviewView: View { -+ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference -+ @ObservedObject var viewModel: ProfileViewModel -+ @Environment(\.dismissAction) var dismissAction -+ @Environment(\.presentationMode) var presentationMode -+ @Environment(\.appName) private var appName -+ @Environment(\.dosingStrategySelectionEnabled) private var dosingStrategySelectionEnabled -+ @State private var profile: Profile -+ @State private var editingDelegate: ProfileEditingDelegate -+ @State private var therapySettingsViewModel: TherapySettingsViewModel -+ -+ @State private var showingAlert = false -+ @State private var activeAlert: ActiveAlert = .load -+ @State private var errorText: String? -+ @State private var newProfileName: String = "" -+ @State private var isRenamingProfile = false -+ @State private var shouldDismissSelf: Bool = false -+ -+ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } -+ -+ init(viewModel: ProfileViewModel, profile: Profile) { -+ self.viewModel = viewModel -+ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) -+ _profile = State(initialValue: profile) -+ _editingDelegate = State(initialValue: editingDelegate) -+ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( -+ therapySettings: viewModel.therapySettings(applying: profile), -+ delegate: editingDelegate)) -+ } -+ -+ var body: some View { -+ ZStack { -+ ScrollView { -+ VStack(alignment: .leading) { -+ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") -+ .font(.body) -+ .foregroundColor(.primary) -+ .padding([.leading, .trailing]) -+ .fixedSize(horizontal: false, vertical: true) -+ .textCase(nil) -+ -+ profileCardStack -+ -+ Button(action: { -+ activeAlert = .load -+ showingAlert = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Load") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.blue) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ Button(action: { -+ isRenamingProfile = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Rename") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.orange) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ Button(action: { -+ activeAlert = .delete -+ showingAlert = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Delete") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.red) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ } -+ } -+ .navigationBarHidden(false) -+ .background(Color(.systemGroupedBackground)) -+ .navigationTitle(Text(profile.name)) -+ .alert(isPresented: $showingAlert) { -+ switch activeAlert { -+ case .load: -+ return Alert( -+ title: Text("Load Profile"), -+ message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: load), -+ secondaryButton: .cancel() -+ ) -+ case .delete: -+ return Alert( -+ title: Text("Delete Profile"), -+ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), -+ primaryButton: .destructive(Text("Delete"), action: { -+ viewModel.removeProfile(profile: profile) -+ self.presentationMode.wrappedValue.dismiss() -+ }), -+ secondaryButton: .cancel() -+ ) -+ case .error: -+ return Alert( -+ title: Text("Error loading profile"), -+ message: Text(errorText ?? "Unknown error"), -+ dismissButton: .default(Text("OK")) -+ ) -+ case .loadEdited: -+ return Alert( -+ title: Text("Load Edited Profile?"), -+ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), -+ primaryButton: .default(Text("Load"), action: load), -+ secondaryButton: .cancel(Text("Later")) -+ ) -+ } -+ } -+ if isRenamingProfile { -+ // Some hacks here to mimic Alert behaviour. -+ // This function should be migrateed to Alert when iOS 16 is required. -+ Color.black.opacity(0.5) -+ .edgesIgnoringSafeArea(.all) -+ .onTapGesture { -+ isRenamingProfile = false -+ } -+ -+ RenameProfileEditor( -+ isPresented: $isRenamingProfile, -+ currentProfileName: profile.name, -+ viewModel: viewModel, -+ shouldDismissParent: $shouldDismissSelf -+ ) -+ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) -+ .offset(y: -22) -+ } -+ } -+ .onAppear { -+ // Runs again when an editor closes. -+ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } -+ profile = saved -+ // The editors show the saved values, also after a save that failed. -+ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) -+ if let error = editingDelegate.saveError { -+ editingDelegate.saveError = nil -+ presentAlertLater(.error, errorText: error.localizedDescription) -+ } else if editingDelegate.savedEdits { -+ editingDelegate.savedEdits = false -+ if viewModel.currentProfileName == profile.name { -+ presentAlertLater(.loadEdited) -+ } -+ } -+ } -+ .onChange(of: shouldDismissSelf) { shouldDismiss in -+ if shouldDismiss { -+ self.presentationMode.wrappedValue.dismiss() -+ } -+ } -+ } -+ -+ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ guard !showingAlert else { return } -+ self.errorText = errorText -+ activeAlert = alert -+ showingAlert = true -+ } -+ } -+ -+ private func load() { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ -+ private var profileCardStack: CardStack { -+ var cards: [Card] = [] -+ -+ cards.append(correctionRangeSection) -+ cards.append(carbRatioSection) -+ cards.append(basalRatesSection) -+ cards.append(insulinSensitivitiesSection) -+ -+ return CardStack(cards: cards) -+ } -+ -+ private var correctionRangeSection: Card { -+ card(for: .glucoseTargetRange) { -+ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { -+ SectionDivider() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleRangeItem(time: items[index].startTime, -+ range: items[index].value, -+ unit: glucoseUnit, -+ guardrail: .correctionRange) -+ } -+ } -+ } -+ } -+ -+ private var carbRatioSection: Card { -+ card(for: .carbRatio) { -+ SectionDivider() -+ let items = profile.carbRatioSchedule.items -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: .gramsPerUnit, -+ guardrail: .carbRatio) -+ } -+ } -+ } -+ -+ private var basalRatesSection: Card { -+ card(for: .basalRate) { -+ let items = profile.basalRateSchedule.items -+ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates -+ { -+ SectionDivider() -+ let total = profile.basalRateSchedule.total() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: .internationalUnitsPerHour, -+ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) -+ } -+ HStack { -+ Text("Total") -+ .bold() -+ .foregroundColor(.primary) -+ Spacer() -+ Text(String(format: "%.2f ",total)) -+ .foregroundColor(.primary) + -+ Text("U/day") -+ .foregroundColor(.secondary) -+ } -+ } -+ } -+ } -+ -+ private var insulinSensitivitiesSection: Card { -+ card(for: .insulinSensitivity) { -+ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items -+ { -+ SectionDivider() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: sensitivityUnit, -+ guardrail: .insulinSensitivity) -+ } -+ } -+ } -+ } -+} -+ -+ -+// MARK: Utilities -+extension ProfilePreviewView { -+ -+ private var glucoseUnit: LoopUnit { -+ displayGlucosePreference.unit -+ } -+ -+ private var sensitivityUnit: LoopUnit { -+ switch glucoseUnit { -+ case .milligramsPerDeciliter: -+ return .milligramsPerDeciliterPerInternationalUnit -+ case .millimolesPerLiter: -+ return .millimolesPerLiterPerInternationalUnit -+ default: -+ return .milligramsPerDeciliterPerInternationalUnit -+ } -+ } -+ -+ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { -+ Card { -+ SectionWithTapToEdit( -+ // The basal editor needs the pump's supported rates. -+ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, -+ title: therapySetting.title, -+ descriptiveText: therapySetting.descriptiveText(appName: appName, dosingStrategySelectionEnabled: dosingStrategySelectionEnabled), -+ destination: { dismiss in -+ editor(for: therapySetting, dismiss: dismiss) -+ .environment(\.dismissAction, dismiss) -+ }, -+ content: content -+ ) -+ } -+ } -+ -+ /// The therapy settings editors, saving to this profile. -+ @ViewBuilder -+ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { -+ switch therapySetting { -+ case .glucoseTargetRange: -+ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .carbRatio: -+ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .basalRate: -+ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .insulinSensitivity: -+ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ default: -+ EmptyView() -+ } -+ } -+} -+ -+fileprivate struct SectionDivider: View { -+ var body: some View { -+ Divider() -+ .padding(.trailing, -16) -+ } -+} -+ -+fileprivate struct SettingsDivider: View { -+ var body: some View { -+ Divider() -+ .padding(.trailing, -8) -+ } -+} -+ -+/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. -+final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { -+ private let viewModel: ProfileViewModel -+ private let profileName: String -+ private let basalTimeZone: TimeZone -+ /// Set when edits were saved, so the view can offer to load the edited active profile. -+ var savedEdits = false -+ /// Set when saving edits to the profile file failed. -+ var saveError: Error? -+ -+ init(viewModel: ProfileViewModel, profile: Profile) { -+ self.viewModel = viewModel -+ self.profileName = profile.name -+ self.basalTimeZone = profile.basalRateSchedule.timeZone -+ } -+ -+ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { -+ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { -+ completion(.failure(ProfileValidationError.basalRateError)) -+ return -+ } -+ completion(.success(schedule)) -+ } -+ -+ func syncDeliveryLimits(deliveryLimits: DeliveryLimits) async throws -> DeliveryLimits { -+ return deliveryLimits -+ } -+ -+ func saveCompletion(therapySettings: TherapySettings) { -+ do { -+ try viewModel.updateProfile(named: profileName, from: therapySettings) -+ savedEdits = true -+ } catch { -+ saveError = error -+ } -+ } -+ -+ func pumpSupportedIncrements() -> PumpSupportedIncrements? { -+ return viewModel.pumpSupportedIncrements() -+ } -+ -+ func updateCurrentProfileName() {} -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift -new file mode 100644 -index 00000000..ffced54b ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift -@@ -0,0 +1,172 @@ -+// -+// ProfileView.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-04-22. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import AVFoundation -+import HealthKit -+import LoopKit -+import SwiftUI -+ -+public struct ProfileView: View { -+ @ObservedObject public var viewModel: ProfileViewModel -+ @Environment(\.dismissAction) var dismissAction -+ @State private var newProfileName: String = "" -+ @State private var isAddingNewProfile = false -+ @State private var selectedProfileIndex: Int? = nil -+ @State private var refreshID = UUID() -+ -+ enum ActiveAlert: String, Identifiable { -+ case update, info -+ -+ var id: String { -+ return self.rawValue -+ } -+ } -+ @State private var activeAlert: ActiveAlert? -+ -+ public init(viewModel: ProfileViewModel) { -+ self.viewModel = viewModel -+ } -+ -+ private var dismissButton: some View { -+ Button(action: dismissAction) { -+ Text(LocalizedString("Done", comment: "Text for dismiss button")) -+ .bold() -+ } -+ } -+ -+ public var body: some View { -+ ZStack { -+ NavigationView { -+ ConfigurationPageScrollView( -+ content: { -+ VStack(alignment: .leading) { -+ -+ if !viewModel.profiles.isEmpty { -+ List { -+ ForEach(viewModel.profiles.indices, id: \.self) { index in -+ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { -+ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { -+ HStack { -+ if viewModel.currentProfileName == viewModel.profiles[index].name { -+ Image(systemName: "checkmark") -+ .foregroundColor(Color.blue) -+ } else { -+ // Add indentation to align with the checkmark for the active profile -+ Image(systemName: "checkmark") -+ .opacity(0) // Invisible -+ } -+ -+ Text(viewModel.profiles[index].name) -+ } -+ } -+ } -+ } -+ .onMove(perform: moveProfile) -+ }.id(refreshID) -+ -+ } else { -+ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") -+ .font(.subheadline) -+ .foregroundColor(.secondary) -+ .padding([.leading, .trailing]) -+ .multilineTextAlignment(.leading) -+ .lineLimit(nil) -+ .textCase(nil) -+ } -+ } -+ }, -+ actionArea: { EmptyView() } // no action area in this case -+ ) -+ .navigationBarItems( -+ leading: HStack { -+ Button(action: { withAnimation { isAddingNewProfile = true } }) { -+ Image(systemName: "plus") -+ } -+ -+ Button(action: { activeAlert = .info }) { -+ Image(systemName: "info.circle") -+ .resizable() -+ .frame(width: 24, height: 24) -+ } -+ }, -+ trailing: dismissButton -+ ) -+ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) -+ } -+ .navigationBarHidden(false) -+ .background(Color(.systemGroupedBackground)) -+ .onAppear { -+ if viewModel.isCurrentProfileOutOfSync() { -+ activeAlert = .update -+ } -+ } -+ -+ .alert(item: $activeAlert) { alertType in -+ switch alertType { -+ case .update: -+ return Alert( -+ title: Text("Profile Outdated"), -+ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), -+ primaryButton: .default(Text("Save Current Settings")) { -+ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") -+ }, -+ secondaryButton: .cancel(Text("Keep Saved Profile")) -+ ) -+ case .info: -+ return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), -+ dismissButton: .default(Text("Got it!"))) -+ } -+ } -+ -+ if isAddingNewProfile { -+ DarkenedOverlay() -+ -+ NewProfileEditor( -+ isPresented: $isAddingNewProfile, -+ newProfileName: newProfileName, -+ viewModel: viewModel -+ ) -+ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) -+ } -+ } -+ } -+ -+ func moveProfile(from source: IndexSet, to destination: Int) { -+ viewModel.profiles.move(fromOffsets: source, toOffset: destination) -+ refreshID = UUID() -+ viewModel.updateProfilesOrder() -+ } -+} -+ -+struct ProfileView_Previews: PreviewProvider { -+ static let preview_glucoseScheduleItems = [ -+ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), -+ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), -+ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) -+ ] -+ -+ static let preview_therapySettings = TherapySettings( -+ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), -+ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), -+ unit: .milligramsPerDeciliter), -+ maximumBolus: 4, -+ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), -+ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: .milligramsPerDeciliterPerInternationalUnit, dailyItems: []), -+ carbRatioSchedule: nil, -+ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) -+ -+ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] -+ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] -+ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] -+ -+ static var previews: some View { -+ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) -+ } -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift -new file mode 100644 -index 00000000..8fd26665 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift -@@ -0,0 +1,92 @@ -+// -+// RenameProfileEditor.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-09-07. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+ -+struct RenameProfileEditor: View { -+ @Binding var isPresented: Bool -+ @State var currentProfileName: String -+ @State var newProfileName: String = "" -+ var viewModel: ProfileViewModel -+ @State private var showAlert = false -+ @Binding var shouldDismissParent: Bool -+ -+ var body: some View { -+ VStack(spacing: 0) { -+ ModalHeaderButtonBar( -+ leading: { cancelButton }, -+ center: { -+ Text("Rename Profile") -+ .font(.subheadline) -+ -+ }, -+ trailing: { renameButton } -+ ) -+ .onAppear { -+ self.newProfileName = currentProfileName -+ } -+ -+ TextField("Profile Name", text: $newProfileName) -+ .textFieldStyle(RoundedBorderTextFieldStyle()) -+ .padding() -+ .background( -+ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) -+ .fill(Color(.secondarySystemGroupedBackground)) -+ ) -+ } -+ .padding(.horizontal) -+ .alert(isPresented: $showAlert) { -+ Alert( -+ title: Text("Overwrite Existing Profile"), -+ message: Text("A profile with this name already exists. Would you like to overwrite it?"), -+ primaryButton: .destructive(Text("Overwrite")) { -+ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) -+ self.shouldDismissParent = true -+ self.isPresented = false -+ }, -+ secondaryButton: .cancel() -+ ) -+ } -+ } -+ -+ var renameButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ guard newProfileName != currentProfileName else { -+ self.isPresented = false -+ return -+ } -+ -+ if viewModel.doesProfileExist(withName: self.newProfileName) { -+ self.showAlert = true -+ } else { -+ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) -+ self.shouldDismissParent = true -+ self.isPresented = false -+ } -+ } -+ }, label: { -+ Text("Rename") -+ } -+ ) -+ } -+ -+ var cancelButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ self.isPresented = false -+ } -+ }, label: { -+ Text("Cancel") -+ } -+ ) -+ } -+} -Submodule LoopOnboarding contains modified content -diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -index 1ddc82b..54421a1 100644 ---- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -+++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -@@ -381,6 +381,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { - maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount - ) - } -+ -+ func updateCurrentProfileName() { -+ } - } - - -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index ea85f5a..eb8f03d 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -23,6 +23,10 @@ - A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; -@@ -163,6 +167,10 @@ - A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; - A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; -@@ -279,6 +287,7 @@ - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, - A934B6172358F49300949C8B /* StoredSettings.swift */, -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, - A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, - A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, -@@ -338,6 +347,7 @@ - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, - A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, - ); - path = NightscoutServiceKitTests; -@@ -380,6 +390,7 @@ - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, - ); - path = Notifications; - sourceTree = ""; -@@ -428,6 +439,7 @@ - A9AA6E5729EB06AA008FFA78 /* Actions */ = { - isa = PBXGroup; - children = ( -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -@@ -765,6 +777,9 @@ - C1398D2227C434FB00416AD6 /* Data.swift in Sources */, - A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, - A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, - C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, - ); -@@ -781,6 +796,7 @@ - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, - A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -new file mode 100644 -index 0000000..588ded1 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -@@ -0,0 +1,104 @@ -+// -+// SavedProfile.swift -+// NightscoutServiceKit -+// -+ -+import CryptoKit -+import Foundation -+import LoopAlgorithm -+import LoopKit -+import NightscoutKit -+ -+/// A profile saved with the Profiles settings screen, read from the file it was saved to. -+struct SavedProfile: Decodable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ -+ private static var directory: URL { -+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") -+ } -+ -+ static func loadAll() -> [SavedProfile] { -+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] -+ return files -+ .filter { $0.pathExtension == "json" } -+ .compactMap { try? Data(contentsOf: $0) } -+ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } -+ } -+ -+ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. -+ static func nightscoutStore(unit: LoopUnit) -> [String: ProfileSet.Profile] { -+ var store: [String: ProfileSet.Profile] = [:] -+ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. -+ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { -+ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) -+ } -+ return store -+ } -+ -+ /// True when this profile uploads with this fingerprint, in either glucose unit. -+ func matches(fingerprint: String) -> Bool { -+ [LoopUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { -+ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint -+ } -+ } -+ -+ func nightscoutProfile(unit: LoopUnit) -> ProfileSet.Profile? { -+ guard let correctionRange = correctionRange.schedule(for: unit), -+ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) -+ else { -+ return nil -+ } -+ return ProfileSet.Profile( -+ timezone: basalRateSchedule.timeZone, -+ dia: .hours(6), -+ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, -+ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, -+ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) -+ } -+} -+ -+extension ProfileSet.Profile { -+ /// Followers compute the same value from the uploaded profile and send it with a switch command, -+ /// so Loop only activates the values the follower previewed. -+ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` -+ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. -+ var remoteFingerprint: String { -+ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { -+ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") -+ } -+ let canonical = [ -+ "basal=" + items(basal), -+ "carbratio=" + items(carbratio), -+ "sens=" + items(sensitivity), -+ "target_low=" + items(targetLow), -+ "target_high=" + items(targetHigh), -+ ].joined(separator: ";") -+ let digest = SHA256.hash(data: Data(canonical.utf8)) -+ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) -+ } -+} -+ -+enum RemoteProfileSwitchError: LocalizedError { -+ case profileChanged -+ case unavailable -+ case notActivated(String, Error) -+ -+ /// Followers read the profile name from this text to match the reply to their command. -+ var errorDescription: String? { -+ switch self { -+ case .profileChanged: -+ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") -+ case .unavailable: -+ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") -+ case .notActivated(let name, let error): -+ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index e7bb1ad..1b43f76 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -180,7 +180,7 @@ extension StoredDosingDecision { - enacted: automaticDoseDecision?.loopStatusEnacted, - failureReason: automaticDoseDecision?.loopStatusFailureReason, - failureCode: automaticDoseDecision?.errors.first?.id, -- testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported], -+ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]], - algorithmExperiments: (automaticDoseDecision?.algorithmExperiments ?? algorithmExperiments)?.map { $0.jsonObject }), - overrideStatus: overrideStatus) - } -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -index 3142223..ae6fb9a 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -@@ -93,12 +93,21 @@ extension StoredSettings { - return nil - } - -+ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) -+ store["Default"] = profile -+ var defaultProfile = "Default" -+ -+ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { -+ defaultProfile = name -+ store[name] = profile -+ } -+ - return ProfileSet( - startDate: date, - units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), - enteredBy: "Loop", -- defaultProfile: "Default", -- store: ["Default": profile], -+ defaultProfile: defaultProfile, -+ store: store, - settings: loopSettings, - syncIdentifier: syncIdentifier.uuidString, - isAPNSProduction: Bundle.main.isAPNSProductionEnvironment, -diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index c3fc48b..64a668c 100644 ---- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift -+++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -474,6 +474,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - success = true - message = "Override '\(overrideCommand.name)' enacted successfully" - -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ success = true -+ message = "Profile '\(profileSwitchCommand.name)' activated" -+ - case .cancelTemporaryOverride: - commandType = .cancelOverride - try await serviceDelegate.cancelRemoteOverride() -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index 91ad56c..29d3563 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -9,6 +9,7 @@ - import Foundation - - public enum Action: Codable { -+ case profileSwitch(ProfileSwitchAction) - case temporaryScheduleOverride(OverrideAction) - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) -@@ -34,6 +35,8 @@ public enum Action: Codable { - return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") - case .temporaryScheduleOverride: - return LocalizedString("Override", comment: "The remote action name for Override") -+ case .profileSwitch: -+ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") - } - } - -@@ -54,6 +57,8 @@ public enum Action: Codable { - return "" - case .temporaryScheduleOverride(let overrideAction): - return overrideAction.name -+ case .profileSwitch(let profileSwitchAction): -+ return profileSwitchAction.name - } - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -new file mode 100644 -index 0000000..ce03ded ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -@@ -0,0 +1,17 @@ -+// -+// ProfileSwitchAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct ProfileSwitchAction: Codable { -+ -+ public let name: String -+ public let fingerprint: String -+ -+ public init(name: String, fingerprint: String) { -+ self.name = name -+ self.fingerprint = fingerprint -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 6092bc9..aabb47f 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -49,6 +49,7 @@ class RemoteNotificationResponseManager { - case carbsEdit = "carbs_edit" - case override = "override" - case cancelOverride = "cancel_override" -+ case profileSwitch = "profile_switch" - } - - func sendResponseNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -new file mode 100644 -index 0000000..ebcd6ef ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -0,0 +1,50 @@ -+// -+// ProfileSwitchRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Switches to a saved profile. `expiration` is required: decoding fails without it. -+public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { -+ -+ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. -+ static let commandKey = "profile-switch" -+ -+ public let profileName: String -+ public let fingerprint: String -+ public let remoteAddress: String -+ public let expirationDate: Date -+ public let sentAt: Date -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ public var expiration: Date? { -+ return expirationDate -+ } -+ -+ enum CodingKeys: String, CodingKey { -+ case profileName = "profile-switch" -+ case fingerprint = "profile-switch-fingerprint" -+ case remoteAddress = "remote-address" -+ case expirationDate = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification[commandKey] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index be43210..1ea8b96 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -118,6 +118,8 @@ extension Dictionary { - return try OverrideRemoteNotification(dictionary: self) - } else if OverrideCancelRemoteNotification.includedInNotification(self) { - return try OverrideCancelRemoteNotification(dictionary: self) -+ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { -+ return try ProfileSwitchRemoteNotification(dictionary: self) - } else { - throw RemoteNotificationError.unhandledNotification(self) - } -diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..2fcec94 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -@@ -0,0 +1,71 @@ -+// -+// ProfileSwitchRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+import LoopKit -+import NightscoutKit -+@testable import NightscoutServiceKit -+ -+final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "sent-at": "2023-02-25T20:46:35.778Z", -+ "expiration": "2023-02-25T20:51:35.778Z", -+ "otp": "123456" -+ ] -+ -+ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) -+ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) -+ -+ XCTAssertEqual(profileSwitch.profileName, "Sport") -+ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") -+ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) -+ XCTAssertTrue(profileSwitch.otpValidationRequired()) -+ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { -+ return XCTFail("Expected a profile switch action") -+ } -+ XCTAssertEqual(action.name, "Sport") -+ } -+ -+ func testParseProfileSwitchNotification_MissingExpiration_Fails() { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "otp": "123456" -+ ] -+ -+ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) -+ } -+ -+ func testFingerprint_MatchesFollowerVector() { -+ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { -+ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } -+ } -+ let profile = ProfileSet.Profile( -+ timezone: TimeZone(secondsFromGMT: 3600)!, -+ dia: .hours(6), -+ sensitivity: items([(0, 45)]), -+ carbratio: items([(0, 10)]), -+ basal: items([(0, 0.85), (21600, 1.1)]), -+ targetLow: items([(0, 100)]), -+ targetHigh: items([(0, 110)]), -+ units: "mg/dL") -+ -+ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") -+ } -+} -+ -+private extension ISO8601DateFormatter { -+ static let fractional: ISO8601DateFormatter = { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ }() -+} -diff --git a/profiles.loopcustomization b/profiles.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/profiles/nextdev_profiles_remote_window.patch b/profiles/nextdev_profiles_remote_window.patch deleted file mode 100644 index 6d94b00..0000000 --- a/profiles/nextdev_profiles_remote_window.patch +++ /dev/null @@ -1,2133 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index 3c9d61cb..5591c9a5 100644 ---- a/Loop/Loop/Managers/DeviceDataManager.swift -+++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -1407,6 +1407,13 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { - return try await pumpManager?.syncDeliveryLimits(limits: deliveryLimits) ?? deliveryLimits - } - -+ func updateCurrentProfileName() { -+ NotificationCenter.default.post(name: .LoopDataUpdated, -+ object: nil, -+ userInfo: [LoopDataManager.LoopUpdateContextKey: LoopUpdateContext.preferences.rawValue]) -+ settingsManager.storeSettings() -+ } -+ - func saveCompletion(therapySettings: TherapySettings) { - settingsManager.mutateLoopSettings { settings in - settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule -diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift -index 18204a86..58b4516b 100644 ---- a/Loop/Loop/Managers/LoopAppManager.swift -+++ b/Loop/Loop/Managers/LoopAppManager.swift -@@ -401,6 +401,7 @@ class LoopAppManager: NSObject { - - dosingDecisionStore.delegate = deviceDataManager - remoteDataServicesManager.delegate = deviceDataManager -+ servicesManager.therapySettingsViewModelDelegate = deviceDataManager - - let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, temporaryPresetsManager.presetHistory, deviceDataManager.deviceLog, alertManager.alertStore] - criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, -diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift -index 18f5e18d..1966dcde 100644 ---- a/Loop/Loop/Managers/LoopDataManager.swift -+++ b/Loop/Loop/Managers/LoopDataManager.swift -@@ -1705,7 +1705,7 @@ extension LoopDataManager: ManualDoseViewModelDelegate { - var settings: StoredSettings { - settingsProvider.settings - } -- -+ - var scheduleOverride: TemporaryScheduleOverride? { - temporaryPresetsManager.scheduleOverride - } -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 42eff19f..c4d0f32e 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -298,6 +298,38 @@ extension NotificationManager { - UNUserNotificationCenter.current().add(request) - } - -+ static func sendRemoteProfileNotification(name: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfile", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteProfileFailureNotification(name: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [remoteProfileNotificationBody(name: name), error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: "com.loopkit.Loop.RemoteProfileFailure", -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ private static func remoteProfileNotificationBody(name: String) -> String { -+ return String(format: NSLocalizedString("Remote Profile Switch: %1$@", comment: "The message for a remote profile switch notification. (1: profile name)"), name) -+ } -+ - private static func remoteCarbEntryNotificationBody(amountInGrams: Double) -> String { - return String(format: NSLocalizedString("Remote Carbs Entry: %d grams", comment: "The carb amount message for a remote carbs entry notification. (1: Carb amount in grams)"), Int(amountInGrams)) - } -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index e81a230b..462e815b 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -30,6 +30,7 @@ class ServicesManager { - weak var servicesManagerDelegate: ServicesManagerDelegate? - weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? - weak var supportManager: SupportManager? -+ weak var therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? - - private var services = [Service]() - -@@ -432,3 +433,57 @@ extension Service { - ] - } - } -+ -+// MARK: - RemoteProfileActionDelegate -+ -+extension ServicesManager: RemoteProfileActionDelegate { -+ func activateRemoteProfile(name: String, verify: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) async throws { -+ do { -+ guard let therapySettingsViewModelDelegate else { -+ throw RemoteProfileActionError.unavailable -+ } -+ let viewModel = ProfileViewModel(therapySettings: settingsManager.therapySettings, delegate: therapySettingsViewModelDelegate) -+ guard therapySettingsViewModelDelegate.pumpSupportedIncrements() != nil else { -+ throw RemoteProfileActionError.noPump -+ } -+ guard let profile = try viewModel.savedProfile(named: name) else { -+ throw RemoteProfileActionError.profileNotFound(name) -+ } -+ try profile.verify(with: verify) -+ if case .failure(let error) = viewModel.validateProfile(profile) { -+ throw error -+ } -+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ continuation.resume() -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ NotificationManager.sendRemoteProfileNotification(name: name) -+ } catch { -+ NotificationManager.sendRemoteProfileFailureNotification(name: name, error: error) -+ throw error -+ } -+ } -+ -+ enum RemoteProfileActionError: LocalizedError { -+ case unavailable -+ case noPump -+ case profileNotFound(String) -+ -+ var errorDescription: String? { -+ switch self { -+ case .unavailable: -+ return NSLocalizedString("Profile switching is not available", comment: "Remote profile switch error description: profile view model delegate missing.") -+ case .noPump: -+ return NSLocalizedString("No pump is configured", comment: "Remote profile switch error description: no pump.") -+ case .profileNotFound(let name): -+ return String(format: NSLocalizedString("No saved profile named %1$@", comment: "Remote profile switch error description: profile not found. (1: profile name)"), name) -+ } -+ } -+ } -+} -diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift -index 1faefbc5..737acf28 100644 ---- a/Loop/Loop/Views/SettingsView.swift -+++ b/Loop/Loop/Views/SettingsView.swift -@@ -55,6 +55,7 @@ struct SettingsView: View { - - case favoriteFoods - case presets -+ case profiles - } - } - -@@ -155,6 +156,9 @@ struct SettingsView: View { - } - case .favoriteFoods: - FavoriteFoodsView(insightsDelegate: viewModel.favoriteFoodInsightsDelegate) -+ case .profiles: -+ ProfileView(viewModel: ProfileViewModel(therapySettings: self.viewModel.therapySettings(), -+ delegate: self.viewModel.therapySettingsViewModelDelegate)) - } - } - .environmentObject(displayGlucosePreference) -@@ -416,6 +420,11 @@ extension SettingsView { - .accessibilityIdentifier("button_TherapySettings") - } - -+ LargeButton(action: { sheet = .profiles }, -+ includeArrow: true, -+ imageView: AnyView(Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 30, weight: .bold))), -+ label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), -+ descriptiveText: NSLocalizedString("Switch between profiles for different scenarios", comment: "Descriptive text for Profiles")) - ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in - item.view - } -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj -index cc88ad17..c543b73a 100644 ---- a/LoopKit/LoopKit.xcodeproj/project.pbxproj -+++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj -@@ -7,6 +7,12 @@ - objects = { - - /* Begin PBXBuildFile section */ -+ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */; }; -+ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD508E072A17763700EEF8FD /* NewProfileEditor.swift */; }; -+ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */; }; -+ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746629F422C600719F0A /* ProfileView.swift */; }; -+ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF746829F4234000719F0A /* ProfileViewModel.swift */; }; -+ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */; }; - 9D3D2DA3B9824EF48981F0E4 /* StoredInsulinModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1CAB9E826A3254800A57273 /* StoredInsulinModel.swift */; }; - 485ACF65C30744BFBC979CEF /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A985464E251449FE0099C1A6 /* NotificationSettings.swift */; }; - 2383C3D7967B4602B8EB4A5B /* SettingsObject+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = A912BE2A245B9E8600CBE199 /* SettingsObject+CoreDataProperties.swift */; }; -@@ -1065,6 +1071,12 @@ - /* End PBXCopyFilesBuildPhase section */ - - /* Begin PBXFileReference section */ -+ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileViewModel+FileManagement.swift"; sourceTree = ""; }; -+ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileEditor.swift; sourceTree = ""; }; -+ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePreviewView.swift; sourceTree = ""; }; -+ DDAF746629F422C600719F0A /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; -+ DDAF746829F4234000719F0A /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = ""; }; -+ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenameProfileEditor.swift; sourceTree = ""; }; - D1A0B0000000000000000080 /* PersistenceControllerMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceControllerMigrationTests.swift; sourceTree = ""; }; - 142CB75C2A60C01E0075748A /* ListButtonStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListButtonStyle.swift; sourceTree = ""; }; - 142CB7602A60C0440075748A /* FavoriteFood.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FavoriteFood.swift; sourceTree = ""; }; -@@ -2971,6 +2983,8 @@ - B455F48025FF9A8B000ED456 /* InsulinSensitivityScheduleEditorViewModel.swift */, - B455F2A125FBE985000ED456 /* SuspendThresholdEditorViewModel.swift */, - 1D1FCE2424BD42EF000300A8 /* TherapySettingsViewModel.swift */, -+ DDAF746829F4234000719F0A /* ProfileViewModel.swift */, -+ DD508E052A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift */, - ); - path = ViewModels; - sourceTree = ""; -@@ -3146,6 +3160,10 @@ - E96DCB5924AF74AC007117BC /* SuspendThresholdEditor.swift */, - 1D1FCE2A24BE704A000300A8 /* TherapySetting+Settings.swift */, - 1D1A019D24B678BF0077D86E /* TherapySettingsView.swift */, -+ DDAF746629F422C600719F0A /* ProfileView.swift */, -+ DD508E072A17763700EEF8FD /* NewProfileEditor.swift */, -+ DD7122602A1D2AA4004FE653 /* ProfilePreviewView.swift */, -+ DDCFC91D2AA9F702003DE656 /* RenameProfileEditor.swift */, - ); - path = "Settings Editors"; - sourceTree = ""; -@@ -3815,6 +3833,12 @@ - E9086B4824B5405E0062F5C8 /* ChartAxisValueDoubleUnit.swift in Sources */, - B429D66C24BF7204003E1B4A /* GlucoseTrend.swift in Sources */, - 432CF86720D76AB90066B889 /* SettingsTableViewCell.swift in Sources */, -+ DDAF746729F422C600719F0A /* ProfileView.swift in Sources */, -+ DDAF746929F4234000719F0A /* ProfileViewModel.swift in Sources */, -+ DD508E062A17712D00EEF8FD /* ProfileViewModel+FileManagement.swift in Sources */, -+ DD508E082A17763700EEF8FD /* NewProfileEditor.swift in Sources */, -+ DD7122612A1D2AA4004FE653 /* ProfilePreviewView.swift in Sources */, -+ DDCFC91E2AA9F702003DE656 /* RenameProfileEditor.swift in Sources */, - 898B4E7B246DC6A70053C484 /* CorrectionRangeScheduleEditor.swift in Sources */, - 892A5DB42231E191008961AB /* LevelMaskView.swift in Sources */, - 892A5DA02231E130008961AB /* CompletionNotifying.swift in Sources */, -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 90d64a0f..b82f71ed 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -8,6 +8,12 @@ - - import Foundation - -+/// Switches to a saved therapy profile on a remote command. -+public protocol RemoteProfileActionDelegate: AnyObject { -+ /// `verify` receives the schedules about to be activated and throws to reject them. -+ func activateRemoteProfile(name: String, verify: (_ correctionRange: GlucoseRangeSchedule, _ carbRatio: CarbRatioSchedule, _ basalRate: BasalRateSchedule, _ insulinSensitivity: InsulinSensitivitySchedule) throws -> Void) async throws -+} -+ - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws - func cancelRemoteOverride() async throws -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -new file mode 100644 -index 00000000..6c27aa9f ---- /dev/null -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,467 @@ -+// -+// ProfileViewModel+FileManagement.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-19. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import LoopKit -+import HealthKit -+import LoopAlgorithm -+ -+public enum ProfileValidationError: Error, LocalizedError { -+ case fileLoadError -+ case unsupportedIncrement -+ case correctionRangeError -+ case insulinSensitivityError -+ case carbRatioError -+ case basalRateError -+ case maxBasalRateNotSet -+ -+ public var errorDescription: String? { -+ switch self { -+ case .fileLoadError: -+ return "Unable to load the profile file." -+ case .unsupportedIncrement: -+ return "The profile contains unsupported increments." -+ case .correctionRangeError: -+ return "Correction Range values are out of bounds." -+ case .insulinSensitivityError: -+ return "Insulin Sensitivity values are out of bounds." -+ case .carbRatioError: -+ return "Carb Ratio values are out of bounds." -+ case .basalRateError: -+ return "Basal Rate values are out of bounds." -+ case .maxBasalRateNotSet: -+ return "Maximum Basal Rate is not set." -+ } -+ } -+} -+ -+public enum LoadProfileResult { -+ case success -+ case failure(Error) -+} -+ -+// MARK: File management -+extension ProfileViewModel { -+ private func setCurrentProfile(name: String, syncChange: Bool = false) { -+ let sanitizedProfileName = sanitizeProfileName(name) -+ currentProfileName = sanitizedProfileName -+ if syncChange { -+ triggerProfileSync() -+ } -+ } -+ -+ private func clearCurrentProfile() { -+ currentProfileName = nil -+ triggerProfileSync() -+ } -+ -+ private func getCurrentProfileName() -> String? { -+ return currentProfileName -+ } -+ -+ private var profilesDirectory: URL { -+ let fileManager = FileManager.default -+ let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] -+ return documentsDirectory.appendingPathComponent("LoopProfile") -+ } -+ -+ private func createProfileDirectoryIfNotExists() throws { -+ let fileManager = FileManager.default -+ var isDir : ObjCBool = false -+ if !fileManager.fileExists(atPath: profilesDirectory.path, isDirectory: &isDir) { -+ try fileManager.createDirectory(at: profilesDirectory, withIntermediateDirectories: true, attributes: nil) -+ } else if !isDir.boolValue { -+ throw NSError(domain: NSCocoaErrorDomain, code: NSFileWriteFileExistsError, userInfo: nil) -+ } -+ } -+ -+ private func getAllProfileFiles() throws -> [URL] { -+ let fileManager = FileManager.default -+ let files = try fileManager.contentsOfDirectory(at: profilesDirectory, includingPropertiesForKeys: nil) -+ return files.filter { $0.pathExtension == "json" } -+ } -+ -+ private func encodeProfile(_ profile: Profile) throws -> Data { -+ return try JSONEncoder().encode(profile) -+ } -+ -+ private func decodeProfile(from data: Data) throws -> Profile { -+ return try JSONDecoder().decode(Profile.self, from: data) -+ } -+ -+ public func sanitizeProfileName(_ name: String) -> String { -+ // Replace problematic characters -+ var sanitized = name.replacingOccurrences(of: ".", with: "_") -+ sanitized = sanitized.replacingOccurrences(of: "<", with: "_") -+ -+ // Limit the name to 32 characters -+ if sanitized.count > 32 { -+ sanitized = String(sanitized.prefix(32)) -+ } -+ -+ return sanitized -+ } -+ -+ public func saveProfile(withName name: String) { -+ let sanitizedName = sanitizeProfileName(name) -+ -+ do { -+ try createProfileDirectoryIfNotExists() -+ -+ if let existingProfile = getProfileReference(withName: sanitizedName) { -+ removeProfile(profileReference: existingProfile) -+ } -+ -+ let profile = Profile( -+ name: sanitizedName, -+ correctionRange: (therapySettings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter)!)!, -+ carbRatioSchedule: therapySettings.carbRatioSchedule!, -+ basalRateSchedule: therapySettings.basalRateSchedule!, -+ insulinSensitivitySchedule: (therapySettings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter)!)!, -+ sortOrder: -1 -+ ) -+ -+ let jsonData = try encodeProfile(profile) -+ -+ let dateFormatter = DateFormatter() -+ dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss" -+ let fileName = dateFormatter.string(from: Date()) + ".json" -+ let fileURL = profilesDirectory.appendingPathComponent(fileName) -+ -+ try jsonData.write(to: fileURL) -+ setCurrentProfile(name: sanitizedName, syncChange: true) -+ -+ self.loadProfiles() -+ } catch { -+ print("An error occurred while saving profile: \(error)") -+ } -+ } -+ -+ public func renameProfile(oldName: String, newName: String) { -+ let sanitizedProfileName = sanitizeProfileName(newName) -+ -+ if currentProfileName == oldName { -+ setCurrentProfile(name: sanitizedProfileName, syncChange: true) -+ } -+ -+ do { -+ guard let profileReference = getProfileReference(withName: oldName) else { -+ print("Profile with old name not found.") -+ return -+ } -+ -+ let profile = try getProfile(from: profileReference) -+ -+ let newProfile = Profile( -+ name: sanitizedProfileName, -+ correctionRange: profile.correctionRange, -+ carbRatioSchedule: profile.carbRatioSchedule, -+ basalRateSchedule: profile.basalRateSchedule, -+ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, -+ sortOrder: profile.sortOrder -+ ) -+ -+ if let existingProfile = getProfileReference(withName: sanitizedProfileName) { -+ removeProfile(profileReference: existingProfile) -+ } -+ -+ let jsonData = try encodeProfile(newProfile) -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ -+ try jsonData.write(to: fileURL) -+ -+ self.loadProfiles() -+ triggerProfileSync() -+ } catch { -+ print("An error occurred while renaming profile: \(error)") -+ } -+ } -+ -+ public func updateProfilesOrder() { -+ for (index, profileReference) in profiles.enumerated() { -+ do { -+ var profile = try getProfile(from: profileReference) -+ let newProfile = Profile( -+ name: profile.name, -+ correctionRange: profile.correctionRange, -+ carbRatioSchedule: profile.carbRatioSchedule, -+ basalRateSchedule: profile.basalRateSchedule, -+ insulinSensitivitySchedule: profile.insulinSensitivitySchedule, -+ sortOrder: index -+ ) -+ -+ let jsonData = try encodeProfile(newProfile) -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ try jsonData.write(to: fileURL) -+ -+ } catch { -+ print("An error occurred while updating profile order: \(error)") -+ } -+ } -+ loadProfiles() -+ } -+ -+ public func loadProfiles() { -+ do { -+ let profileFiles = try getAllProfileFiles() -+ -+ var newProfiles = [ProfileReference]() -+ for fileURL in profileFiles { -+ let data = try Data(contentsOf: fileURL) -+ let profile = try decodeProfile(from: data) -+ let profileRef = ProfileReference(name: sanitizeProfileName(profile.name), fileName: fileURL.lastPathComponent, sortOrder: profile.sortOrder) -+ newProfiles.append(profileRef) -+ } -+ -+ newProfiles.sort { $0.sortOrder ?? 0 < $1.sortOrder ?? 0 } -+ -+ self.profiles = newProfiles -+ } catch { -+ print("An error occurred while loading profiles: \(error)") -+ } -+ } -+ -+ public func loadProfile(profile: Profile, completion: @escaping (LoadProfileResult) -> Void) { -+ if(therapySettings.glucoseTargetRangeSchedule == profile.correctionRange && -+ therapySettings.carbRatioSchedule == profile.carbRatioSchedule && -+ therapySettings.basalRateSchedule == profile.basalRateSchedule && -+ therapySettings.insulinSensitivitySchedule == profile.insulinSensitivitySchedule) { -+ self.setCurrentProfile(name: profile.name, syncChange: true) -+ completion(.success) -+ } -+ else -+ { -+ do { -+ delegate?.syncBasalRateSchedule(items: profile.basalRateSchedule.items, completion: { result in -+ switch result { -+ case .success(let syncedSchedule): -+ DispatchQueue.main.async { -+ let previousSettings = self.therapySettings -+ let unit = self.therapySettings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter -+ self.therapySettings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit)! -+ self.therapySettings.carbRatioSchedule = profile.carbRatioSchedule -+ self.therapySettings.basalRateSchedule = syncedSchedule -+ self.therapySettings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit)! -+ // One settings change, so the stored and uploaded settings never mix two profiles. -+ self.setCurrentProfile(name: profile.name, syncChange: false) -+ self.delegate?.saveCompletion(therapySettings: self.therapySettings) -+ if self.therapySettings == previousSettings { -+ // Nothing changed, so only the profile name needs to reach the uploaded settings. -+ self.triggerProfileSync() -+ } -+ print("New profile loaded") -+ completion(.success) -+ } -+ case .failure(let error): -+ print("An error occurred while syncing basal rates: \(error)") -+ completion(.failure(error)) -+ } -+ }) -+ } -+ } -+ } -+ -+ func getProfileReference(withName name: String) -> ProfileReference? { -+ let sanitizedInputName = sanitizeProfileName(name) -+ return profiles.first(where: { sanitizeProfileName($0.name) == sanitizedInputName }) -+ } -+ -+ public func removeProfile(profile: Profile) { -+ guard let profileReference = getProfileReference(withName: profile.name) else { -+ print("No ProfileReference found for given Profile") -+ return -+ } -+ -+ removeProfile(profileReference: profileReference) -+ if getCurrentProfileName() == profile.name { -+ clearCurrentProfile() -+ } else { -+ triggerProfileSync() -+ } -+ } -+ -+ public func removeProfile(profileReference: ProfileReference) { -+ do { -+ let fileManager = FileManager.default -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ try fileManager.removeItem(at: fileURL) -+ loadProfiles() -+ } catch { -+ print("An error occurred while removing profile: \(error)") -+ } -+ } -+ -+ func doesProfileExist(withName name: String) -> Bool { -+ return profiles.contains(where: { $0.name == name }) -+ } -+ -+ public func getProfile(from profileReference: ProfileReference) throws -> Profile { -+ let fileURL = profilesDirectory.appendingPathComponent(profileReference.fileName) -+ let data = try Data(contentsOf: fileURL) -+ let getProfile = try decodeProfile(from: data) -+ return getProfile -+ } -+ -+ /// The saved profile named `name`, or nil when there is none. -+ public func savedProfile(named name: String) throws -> Profile? { -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ return nil -+ } -+ return try getProfile(from: profileReference) -+ } -+ -+ public func validateProfile(_ profile: Profile) -> Result { -+ guard let supportedIncrements = delegate?.pumpSupportedIncrements() else { -+ return .failure(.fileLoadError) -+ } -+ -+ // Checking Correction Range Schedule -+ for item in profile.correctionRange.items { -+ let minValue = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.minValue) -+ let maxValue = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: item.value.maxValue) -+ -+ if !(Guardrail.correctionRange.absoluteBounds.contains(minValue) && Guardrail.correctionRange.absoluteBounds.contains(maxValue)) { -+ return .failure(.correctionRangeError) -+ } -+ } -+ -+ // Checking Insulin Sensitivity Schedule -+ for item in profile.insulinSensitivitySchedule.items { -+ let value = LoopQuantity(unit: .milligramsPerDeciliterPerInternationalUnit, doubleValue: item.value) -+ -+ if !Guardrail.insulinSensitivity.absoluteBounds.contains(value) { -+ return .failure(.insulinSensitivityError) -+ } -+ } -+ -+ // Checking Carb Ratio Schedule -+ for item in profile.carbRatioSchedule.items { -+ let value = LoopQuantity(unit: .gramsPerUnit, doubleValue: item.value) -+ -+ if !Guardrail.carbRatio.absoluteBounds.contains(value) { -+ return .failure(.carbRatioError) -+ } -+ } -+ -+ // Checking Basal Rate Schedule -+ if let maximumBasalRate = therapySettings.maximumBasalRatePerHour { -+ for item in profile.basalRateSchedule.items { -+ let value = item.value -+ -+ if value > maximumBasalRate || !supportedIncrements.basalRates.contains(value) { -+ return .failure(.basalRateError) -+ } -+ } -+ } else { -+ return .failure(.maxBasalRateNotSet) -+ } -+ -+ return .success(()) -+ } -+ -+ func isCorrectionRangeEqual(a: GlucoseRangeSchedule, b: GlucoseRangeSchedule) -> Bool { -+ guard a.items.count == b.items.count else { -+ return false -+ } -+ -+ for (item1, item2) in zip(a.items, b.items) { -+ if item1.startTime != item2.startTime || -+ abs(item1.value.minValue - item2.value.minValue) > 0.01 || -+ abs(item1.value.maxValue - item2.value.maxValue) > 0.01 { -+ return false -+ } -+ } -+ -+ return true -+ } -+ -+ func isInsulinSensitivityScheduleEqual(a: InsulinSensitivitySchedule, b: InsulinSensitivitySchedule) -> Bool { -+ guard a.items.count == b.items.count else { -+ return false -+ } -+ -+ for (item1, item2) in zip(a.items, b.items) { -+ if item1.startTime != item2.startTime || -+ abs(item1.value - item2.value) > 0.01 { -+ return false -+ } -+ } -+ -+ return true -+ } -+ -+ public func isCurrentProfileOutOfSync() -> Bool { -+ guard let currentProfileName = self.currentProfileName, -+ let currentProfileReference = profiles.first(where: { $0.name == currentProfileName }), -+ let profile = try? getProfile(from: currentProfileReference), -+ let therapyGlucoseSchedule = therapySettings.glucoseTargetRangeSchedule?.schedule(for: LoopUnit.milligramsPerDeciliter), -+ let therapyInsulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule?.schedule(for: LoopUnit.milligramsPerDeciliter) else { -+ return false // No current profile set or profile not found or schedules not available -+ } -+ -+ var isOutOfSync = false -+ -+ if !isCorrectionRangeEqual(a: therapyGlucoseSchedule, b: profile.correctionRange.schedule(for: LoopUnit.milligramsPerDeciliter)!) { -+ print("Glucose target range schedule differs") -+ isOutOfSync = true -+ } -+ if therapySettings.carbRatioSchedule?.items != profile.carbRatioSchedule.items { -+ print("Carb ratio schedule differs") -+ isOutOfSync = true -+ } -+ if therapySettings.basalRateSchedule?.items != profile.basalRateSchedule.items { -+ print("Basal rate schedule differs") -+ isOutOfSync = true -+ } -+ if !isInsulinSensitivityScheduleEqual(a: therapyInsulinSensitivitySchedule, b: profile.insulinSensitivitySchedule.schedule(for: LoopUnit.milligramsPerDeciliter)!) { -+ print("Insulin sensitivity schedule differs") -+ isOutOfSync = true -+ } -+ -+ return isOutOfSync -+ } -+} -+ -+extension Profile { -+ /// Passes the schedules this profile activates to `check`. -+ public func verify(with check: (GlucoseRangeSchedule, CarbRatioSchedule, BasalRateSchedule, InsulinSensitivitySchedule) throws -> Void) throws { -+ try check(correctionRange, carbRatioSchedule, basalRateSchedule, insulinSensitivitySchedule) -+ } -+} -+ -+extension ProfileViewModel { -+ /// The current settings with the schedules of `profile`, for editing the profile with the therapy settings editors. -+ func therapySettings(applying profile: Profile) -> TherapySettings { -+ var settings = therapySettings -+ let unit = settings.glucoseTargetRangeSchedule?.unit ?? LoopUnit.milligramsPerDeciliter -+ settings.glucoseTargetRangeSchedule = profile.correctionRange.schedule(for: unit) -+ settings.carbRatioSchedule = profile.carbRatioSchedule -+ settings.basalRateSchedule = profile.basalRateSchedule -+ settings.insulinSensitivitySchedule = profile.insulinSensitivitySchedule.schedule(for: unit) -+ return settings -+ } -+ -+ /// Saves edited schedules to the profile named `name`, keeping its file and place in the list. -+ func updateProfile(named name: String, from settings: TherapySettings) throws { -+ loadProfiles() -+ guard let profileReference = profiles.first(where: { $0.name == name }) else { -+ throw ProfileValidationError.fileLoadError -+ } -+ let profile = try getProfile(from: profileReference) -+ let updatedProfile = Profile( -+ name: profile.name, -+ correctionRange: settings.glucoseTargetRangeSchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.correctionRange, -+ carbRatioSchedule: settings.carbRatioSchedule ?? profile.carbRatioSchedule, -+ basalRateSchedule: settings.basalRateSchedule ?? profile.basalRateSchedule, -+ insulinSensitivitySchedule: settings.insulinSensitivitySchedule?.schedule(for: .milligramsPerDeciliter) ?? profile.insulinSensitivitySchedule, -+ sortOrder: profile.sortOrder -+ ) -+ try encodeProfile(updatedProfile).write(to: profilesDirectory.appendingPathComponent(profileReference.fileName), options: .atomic) -+ loadProfiles() -+ triggerProfileSync() -+ } -+} -diff --git a/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift -new file mode 100644 -index 00000000..a48fa667 ---- /dev/null -+++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel.swift -@@ -0,0 +1,86 @@ -+// -+// ProfileViewModel.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-04-22. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import Combine -+import LoopKit -+import HealthKit -+import SwiftUI -+ -+public class ProfileViewModel: ObservableObject { -+ -+ @Published public var therapySettings: TherapySettings -+ @Published public var profiles = [ProfileReference]() -+ @Published public var currentProfileName: String? { -+ didSet { -+ UserDefaults.standard.setValue(currentProfileName, forKey: "currentProfileName") -+ } -+ } -+ -+ internal weak var delegate: TherapySettingsViewModelDelegate? -+ -+ public init(therapySettings: TherapySettings, -+ pumpSupportedIncrements: (() -> PumpSupportedIncrements?)? = nil, -+ sensitivityOverridesEnabled: Bool = false, -+ adultChildInsulinModelSelectionEnabled: Bool = false, -+ prescription: Prescription? = nil, -+ delegate: TherapySettingsViewModelDelegate? = nil) { -+ self.therapySettings = therapySettings -+ self.delegate = delegate -+ self.currentProfileName = UserDefaults.standard.string(forKey: "currentProfileName") -+ self.loadProfiles() -+ } -+ -+ public func pumpSupportedIncrements() -> PumpSupportedIncrements? { -+ return delegate?.pumpSupportedIncrements() -+ } -+} -+ -+public struct Profile: Codable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ var sortOrder: Int? -+} -+ -+public struct ProfileReference: Codable, Equatable { -+ var name: String -+ var fileName: String -+ var sortOrder: Int? -+} -+ -+ -+// MARK: Saving -+extension ProfileViewModel { -+ -+ public func saveCorrectionRange(range: GlucoseRangeSchedule) { -+ therapySettings.glucoseTargetRangeSchedule = range -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveBasalRates(basalRates: BasalRateSchedule) { -+ therapySettings.basalRateSchedule = basalRates -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveCarbRatioSchedule(carbRatioSchedule: CarbRatioSchedule) { -+ therapySettings.carbRatioSchedule = carbRatioSchedule -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func saveInsulinSensitivitySchedule(insulinSensitivitySchedule: InsulinSensitivitySchedule) { -+ therapySettings.insulinSensitivitySchedule = insulinSensitivitySchedule -+ delegate?.saveCompletion(therapySettings: therapySettings) -+ } -+ -+ public func triggerProfileSync() { -+ delegate?.updateCurrentProfileName() -+ } -+} -diff --git a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -index 81be1865..913c82ac 100644 ---- a/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -+++ b/LoopKit/LoopKitUI/ViewModels/TherapySettingsViewModel.swift -@@ -16,6 +16,7 @@ public protocol TherapySettingsViewModelDelegate: AnyObject { - func syncDeliveryLimits(deliveryLimits: DeliveryLimits) async throws -> DeliveryLimits - func saveCompletion(therapySettings: TherapySettings) - func pumpSupportedIncrements() -> PumpSupportedIncrements? -+ func updateCurrentProfileName() - } - - @Observable -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift -new file mode 100644 -index 00000000..446910e4 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/NewProfileEditor.swift -@@ -0,0 +1,79 @@ -+// -+// NewProfileEditor.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-19. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+ -+struct NewProfileEditor: View { -+ @Binding var isPresented: Bool -+ @State var newProfileName: String -+ var viewModel: ProfileViewModel -+ @State private var showAlert = false -+ -+ var body: some View { -+ VStack(spacing: 0) { -+ ModalHeaderButtonBar( -+ leading: { cancelButton }, -+ center: { -+ Text("New Profile") -+ .font(.headline) -+ }, -+ trailing: { addButton } -+ ) -+ -+ TextField("Profile Name", text: $newProfileName) -+ .textFieldStyle(RoundedBorderTextFieldStyle()) -+ .padding() -+ .background( -+ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) -+ .fill(Color(.secondarySystemGroupedBackground)) -+ ) -+ } -+ .padding(.horizontal) -+ .alert(isPresented: $showAlert) { -+ Alert( -+ title: Text("Overwrite Existing Profile"), -+ message: Text("A profile with this name already exists. Would you like to overwrite it?"), -+ primaryButton: .destructive(Text("Overwrite")) { -+ self.viewModel.saveProfile(withName: self.newProfileName) -+ self.isPresented = false -+ }, -+ secondaryButton: .cancel() -+ ) -+ } -+ } -+ -+ var addButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ if viewModel.doesProfileExist(withName: self.newProfileName) { -+ self.showAlert = true -+ } else { -+ self.viewModel.saveProfile(withName: self.newProfileName) -+ self.isPresented = false -+ } -+ } -+ }, label: { -+ Text("Add") -+ } -+ ) -+ } -+ -+ var cancelButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ self.isPresented = false -+ } -+ }, label: { -+ Text("Cancel") -+ } -+ ) -+ } -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -new file mode 100644 -index 00000000..9eb00f90 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfilePreviewView.swift -@@ -0,0 +1,432 @@ -+// -+// ProfilePreviewView.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-05-23. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+import HealthKit -+import LoopKit -+import LoopAlgorithm -+ -+enum ActiveAlert { -+ case load -+ case delete -+ case error -+ case loadEdited -+} -+ -+struct ProfilePreviewView: View { -+ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference -+ @ObservedObject var viewModel: ProfileViewModel -+ @Environment(\.dismissAction) var dismissAction -+ @Environment(\.presentationMode) var presentationMode -+ @Environment(\.appName) private var appName -+ @Environment(\.dosingStrategySelectionEnabled) private var dosingStrategySelectionEnabled -+ @State private var profile: Profile -+ @State private var editingDelegate: ProfileEditingDelegate -+ @State private var therapySettingsViewModel: TherapySettingsViewModel -+ -+ @State private var showingAlert = false -+ @State private var activeAlert: ActiveAlert = .load -+ @State private var errorText: String? -+ @State private var newProfileName: String = "" -+ @State private var isRenamingProfile = false -+ @State private var shouldDismissSelf: Bool = false -+ -+ let nilDestination: (_ dismiss: @escaping () -> Void) -> AnyView = { _ in AnyView(EmptyView()) } -+ -+ init(viewModel: ProfileViewModel, profile: Profile) { -+ self.viewModel = viewModel -+ let editingDelegate = ProfileEditingDelegate(viewModel: viewModel, profile: profile) -+ _profile = State(initialValue: profile) -+ _editingDelegate = State(initialValue: editingDelegate) -+ _therapySettingsViewModel = State(initialValue: TherapySettingsViewModel( -+ therapySettings: viewModel.therapySettings(applying: profile), -+ delegate: editingDelegate)) -+ } -+ -+ var body: some View { -+ ZStack { -+ ScrollView { -+ VStack(alignment: .leading) { -+ Text("Tap a setting to edit it in this profile. Editing changes only the saved profile; \(appName) keeps using its current settings until the profile is loaded. Scroll to find options to Load, Rename, or Delete.") -+ .font(.body) -+ .foregroundColor(.primary) -+ .padding([.leading, .trailing]) -+ .fixedSize(horizontal: false, vertical: true) -+ .textCase(nil) -+ -+ profileCardStack -+ -+ Button(action: { -+ activeAlert = .load -+ showingAlert = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Load") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.blue) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ Button(action: { -+ isRenamingProfile = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Rename") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.orange) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ Button(action: { -+ activeAlert = .delete -+ showingAlert = true -+ }) { -+ HStack { -+ Spacer() -+ Text("Delete") -+ .foregroundColor(.white) -+ .padding() -+ Spacer() -+ } -+ } -+ .background(Color.red) -+ .cornerRadius(10) -+ .padding(.horizontal) -+ -+ } -+ } -+ .navigationBarHidden(false) -+ .background(Color(.systemGroupedBackground)) -+ .navigationTitle(Text(profile.name)) -+ .alert(isPresented: $showingAlert) { -+ switch activeAlert { -+ case .load: -+ return Alert( -+ title: Text("Load Profile"), -+ message: Text("Do you want to load the profile \(profile.name)?"), -+ primaryButton: .default(Text("Yes"), action: load), -+ secondaryButton: .cancel() -+ ) -+ case .delete: -+ return Alert( -+ title: Text("Delete Profile"), -+ message: Text("Are you sure you want to delete the profile \(profile.name)? This action cannot be undone."), -+ primaryButton: .destructive(Text("Delete"), action: { -+ viewModel.removeProfile(profile: profile) -+ self.presentationMode.wrappedValue.dismiss() -+ }), -+ secondaryButton: .cancel() -+ ) -+ case .error: -+ return Alert( -+ title: Text("Error loading profile"), -+ message: Text(errorText ?? "Unknown error"), -+ dismissButton: .default(Text("OK")) -+ ) -+ case .loadEdited: -+ return Alert( -+ title: Text("Load Edited Profile?"), -+ message: Text("\(profile.name) is the active profile. Load it now to use the edited settings. \(appName) writes the basal rates to the pump."), -+ primaryButton: .default(Text("Load"), action: load), -+ secondaryButton: .cancel(Text("Later")) -+ ) -+ } -+ } -+ if isRenamingProfile { -+ // Some hacks here to mimic Alert behaviour. -+ // This function should be migrateed to Alert when iOS 16 is required. -+ Color.black.opacity(0.5) -+ .edgesIgnoringSafeArea(.all) -+ .onTapGesture { -+ isRenamingProfile = false -+ } -+ -+ RenameProfileEditor( -+ isPresented: $isRenamingProfile, -+ currentProfileName: profile.name, -+ viewModel: viewModel, -+ shouldDismissParent: $shouldDismissSelf -+ ) -+ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) -+ .offset(y: -22) -+ } -+ } -+ .onAppear { -+ // Runs again when an editor closes. -+ guard let saved = try? viewModel.savedProfile(named: profile.name) else { return } -+ profile = saved -+ // The editors show the saved values, also after a save that failed. -+ therapySettingsViewModel.therapySettings = viewModel.therapySettings(applying: saved) -+ if let error = editingDelegate.saveError { -+ editingDelegate.saveError = nil -+ presentAlertLater(.error, errorText: error.localizedDescription) -+ } else if editingDelegate.savedEdits { -+ editingDelegate.savedEdits = false -+ if viewModel.currentProfileName == profile.name { -+ presentAlertLater(.loadEdited) -+ } -+ } -+ } -+ .onChange(of: shouldDismissSelf) { shouldDismiss in -+ if shouldDismiss { -+ self.presentationMode.wrappedValue.dismiss() -+ } -+ } -+ } -+ -+ private func presentAlertLater(_ alert: ActiveAlert, errorText: String? = nil) { -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ guard !showingAlert else { return } -+ self.errorText = errorText -+ activeAlert = alert -+ showingAlert = true -+ } -+ } -+ -+ private func load() { -+ let validationResult = viewModel.validateProfile(profile) -+ switch validationResult { -+ case .success: -+ viewModel.loadProfile(profile: profile) { result in -+ switch result { -+ case .success: -+ dismissAction() -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ case .failure(let error): -+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -+ self.errorText = error.localizedDescription -+ self.activeAlert = .error -+ self.showingAlert = true -+ } -+ } -+ } -+ -+ private var profileCardStack: CardStack { -+ var cards: [Card] = [] -+ -+ cards.append(correctionRangeSection) -+ cards.append(carbRatioSection) -+ cards.append(basalRatesSection) -+ cards.append(insulinSensitivitiesSection) -+ -+ return CardStack(cards: cards) -+ } -+ -+ private var correctionRangeSection: Card { -+ card(for: .glucoseTargetRange) { -+ if let items = profile.correctionRange.schedule(for: glucoseUnit)?.items { -+ SectionDivider() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleRangeItem(time: items[index].startTime, -+ range: items[index].value, -+ unit: glucoseUnit, -+ guardrail: .correctionRange) -+ } -+ } -+ } -+ } -+ -+ private var carbRatioSection: Card { -+ card(for: .carbRatio) { -+ SectionDivider() -+ let items = profile.carbRatioSchedule.items -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: .gramsPerUnit, -+ guardrail: .carbRatio) -+ } -+ } -+ } -+ -+ private var basalRatesSection: Card { -+ card(for: .basalRate) { -+ let items = profile.basalRateSchedule.items -+ if let supportedBasalRates = viewModel.pumpSupportedIncrements()?.basalRates -+ { -+ SectionDivider() -+ let total = profile.basalRateSchedule.total() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: .internationalUnitsPerHour, -+ guardrail: .basalRate(supportedBasalRates: supportedBasalRates)) -+ } -+ HStack { -+ Text("Total") -+ .bold() -+ .foregroundColor(.primary) -+ Spacer() -+ Text(String(format: "%.2f ",total)) -+ .foregroundColor(.primary) + -+ Text("U/day") -+ .foregroundColor(.secondary) -+ } -+ } -+ } -+ } -+ -+ private var insulinSensitivitiesSection: Card { -+ card(for: .insulinSensitivity) { -+ if let items = profile.insulinSensitivitySchedule.schedule(for: glucoseUnit)?.items -+ { -+ SectionDivider() -+ ForEach(items.indices, id: \.self) { index in -+ if index > 0 { -+ SettingsDivider() -+ } -+ ScheduleValueItem(time: items[index].startTime, -+ value: items[index].value, -+ unit: sensitivityUnit, -+ guardrail: .insulinSensitivity) -+ } -+ } -+ } -+ } -+} -+ -+ -+// MARK: Utilities -+extension ProfilePreviewView { -+ -+ private var glucoseUnit: LoopUnit { -+ displayGlucosePreference.unit -+ } -+ -+ private var sensitivityUnit: LoopUnit { -+ switch glucoseUnit { -+ case .milligramsPerDeciliter: -+ return .milligramsPerDeciliterPerInternationalUnit -+ case .millimolesPerLiter: -+ return .millimolesPerLiterPerInternationalUnit -+ default: -+ return .milligramsPerDeciliterPerInternationalUnit -+ } -+ } -+ -+ private func card(for therapySetting: TherapySetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { -+ Card { -+ SectionWithTapToEdit( -+ // The basal editor needs the pump's supported rates. -+ isEnabled: therapySetting != .basalRate || viewModel.pumpSupportedIncrements() != nil, -+ title: therapySetting.title, -+ descriptiveText: therapySetting.descriptiveText(appName: appName, dosingStrategySelectionEnabled: dosingStrategySelectionEnabled), -+ destination: { dismiss in -+ editor(for: therapySetting, dismiss: dismiss) -+ .environment(\.dismissAction, dismiss) -+ }, -+ content: content -+ ) -+ } -+ } -+ -+ /// The therapy settings editors, saving to this profile. -+ @ViewBuilder -+ private func editor(for therapySetting: TherapySetting, dismiss: @escaping () -> Void) -> some View { -+ switch therapySetting { -+ case .glucoseTargetRange: -+ CorrectionRangeScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .carbRatio: -+ CarbRatioScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .basalRate: -+ BasalRateScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ case .insulinSensitivity: -+ InsulinSensitivityScheduleEditor(mode: .settings, therapySettingsViewModel: therapySettingsViewModel, didSave: dismiss) -+ default: -+ EmptyView() -+ } -+ } -+} -+ -+fileprivate struct SectionDivider: View { -+ var body: some View { -+ Divider() -+ .padding(.trailing, -16) -+ } -+} -+ -+fileprivate struct SettingsDivider: View { -+ var body: some View { -+ Divider() -+ .padding(.trailing, -8) -+ } -+} -+ -+/// Saves edits made with the therapy settings editors to a saved profile. Loop's current settings and the pump are not changed. -+final class ProfileEditingDelegate: TherapySettingsViewModelDelegate { -+ private let viewModel: ProfileViewModel -+ private let profileName: String -+ private let basalTimeZone: TimeZone -+ /// Set when edits were saved, so the view can offer to load the edited active profile. -+ var savedEdits = false -+ /// Set when saving edits to the profile file failed. -+ var saveError: Error? -+ -+ init(viewModel: ProfileViewModel, profile: Profile) { -+ self.viewModel = viewModel -+ self.profileName = profile.name -+ self.basalTimeZone = profile.basalRateSchedule.timeZone -+ } -+ -+ func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { -+ guard let schedule = BasalRateSchedule(dailyItems: items, timeZone: basalTimeZone) else { -+ completion(.failure(ProfileValidationError.basalRateError)) -+ return -+ } -+ completion(.success(schedule)) -+ } -+ -+ func syncDeliveryLimits(deliveryLimits: DeliveryLimits) async throws -> DeliveryLimits { -+ return deliveryLimits -+ } -+ -+ func saveCompletion(therapySettings: TherapySettings) { -+ do { -+ try viewModel.updateProfile(named: profileName, from: therapySettings) -+ savedEdits = true -+ } catch { -+ saveError = error -+ } -+ } -+ -+ func pumpSupportedIncrements() -> PumpSupportedIncrements? { -+ return viewModel.pumpSupportedIncrements() -+ } -+ -+ func updateCurrentProfileName() {} -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift -new file mode 100644 -index 00000000..ffced54b ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/ProfileView.swift -@@ -0,0 +1,172 @@ -+// -+// ProfileView.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-04-22. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import AVFoundation -+import HealthKit -+import LoopKit -+import SwiftUI -+ -+public struct ProfileView: View { -+ @ObservedObject public var viewModel: ProfileViewModel -+ @Environment(\.dismissAction) var dismissAction -+ @State private var newProfileName: String = "" -+ @State private var isAddingNewProfile = false -+ @State private var selectedProfileIndex: Int? = nil -+ @State private var refreshID = UUID() -+ -+ enum ActiveAlert: String, Identifiable { -+ case update, info -+ -+ var id: String { -+ return self.rawValue -+ } -+ } -+ @State private var activeAlert: ActiveAlert? -+ -+ public init(viewModel: ProfileViewModel) { -+ self.viewModel = viewModel -+ } -+ -+ private var dismissButton: some View { -+ Button(action: dismissAction) { -+ Text(LocalizedString("Done", comment: "Text for dismiss button")) -+ .bold() -+ } -+ } -+ -+ public var body: some View { -+ ZStack { -+ NavigationView { -+ ConfigurationPageScrollView( -+ content: { -+ VStack(alignment: .leading) { -+ -+ if !viewModel.profiles.isEmpty { -+ List { -+ ForEach(viewModel.profiles.indices, id: \.self) { index in -+ if let profile = try? viewModel.getProfile(from: viewModel.profiles[index]) { -+ NavigationLink(destination: ProfilePreviewView(viewModel: viewModel, profile: profile)) { -+ HStack { -+ if viewModel.currentProfileName == viewModel.profiles[index].name { -+ Image(systemName: "checkmark") -+ .foregroundColor(Color.blue) -+ } else { -+ // Add indentation to align with the checkmark for the active profile -+ Image(systemName: "checkmark") -+ .opacity(0) // Invisible -+ } -+ -+ Text(viewModel.profiles[index].name) -+ } -+ } -+ } -+ } -+ .onMove(perform: moveProfile) -+ }.id(refreshID) -+ -+ } else { -+ Text("Use ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.") -+ .font(.subheadline) -+ .foregroundColor(.secondary) -+ .padding([.leading, .trailing]) -+ .multilineTextAlignment(.leading) -+ .lineLimit(nil) -+ .textCase(nil) -+ } -+ } -+ }, -+ actionArea: { EmptyView() } // no action area in this case -+ ) -+ .navigationBarItems( -+ leading: HStack { -+ Button(action: { withAnimation { isAddingNewProfile = true } }) { -+ Image(systemName: "plus") -+ } -+ -+ Button(action: { activeAlert = .info }) { -+ Image(systemName: "info.circle") -+ .resizable() -+ .frame(width: 24, height: 24) -+ } -+ }, -+ trailing: dismissButton -+ ) -+ .navigationTitle(Text(LocalizedString("Profiles", comment: "Title on ProfileView"))) -+ } -+ .navigationBarHidden(false) -+ .background(Color(.systemGroupedBackground)) -+ .onAppear { -+ if viewModel.isCurrentProfileOutOfSync() { -+ activeAlert = .update -+ } -+ } -+ -+ .alert(item: $activeAlert) { alertType in -+ switch alertType { -+ case .update: -+ return Alert( -+ title: Text("Profile Outdated"), -+ message: Text("The current therapy settings differ from the saved profile \(viewModel.currentProfileName ?? ""). Save the current settings to it, or keep the saved profile and load it later to use its settings."), -+ primaryButton: .default(Text("Save Current Settings")) { -+ viewModel.saveProfile(withName: viewModel.currentProfileName ?? "") -+ }, -+ secondaryButton: .cancel(Text("Keep Saved Profile")) -+ ) -+ case .info: -+ return Alert(title: Text("Information"), -+ message: Text("A checkmark next to a profile name indicates that it's the active one.\n\nUse ‘+’ to create a new profile capturing your glucose targets, carb ratios, basal rates, and insulin sensitivities.\n\nTap a profile to review its settings. There you can edit them without loading the profile, and Load, Rename or Delete it.\n\nWant to change the order of profiles? Simply press and hold on a profile, then drag it to your desired position."), -+ dismissButton: .default(Text("Got it!"))) -+ } -+ } -+ -+ if isAddingNewProfile { -+ DarkenedOverlay() -+ -+ NewProfileEditor( -+ isPresented: $isAddingNewProfile, -+ newProfileName: newProfileName, -+ viewModel: viewModel -+ ) -+ .transition(AnyTransition.move(edge: .bottom).combined(with: .opacity).animation(.default)) -+ } -+ } -+ } -+ -+ func moveProfile(from source: IndexSet, to destination: Int) { -+ viewModel.profiles.move(fromOffsets: source, toOffset: destination) -+ refreshID = UUID() -+ viewModel.updateProfilesOrder() -+ } -+} -+ -+struct ProfileView_Previews: PreviewProvider { -+ static let preview_glucoseScheduleItems = [ -+ RepeatingScheduleValue(startTime: 0, value: DoubleRange(80...90)), -+ RepeatingScheduleValue(startTime: 1800, value: DoubleRange(90...100)), -+ RepeatingScheduleValue(startTime: 3600, value: DoubleRange(100...110)) -+ ] -+ -+ static let preview_therapySettings = TherapySettings( -+ glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: preview_glucoseScheduleItems), -+ correctionRangeOverrides: CorrectionRangeOverrides(preMeal: DoubleRange(88...99), -+ unit: .milligramsPerDeciliter), -+ maximumBolus: 4, -+ suspendThreshold: GlucoseThreshold.init(unit: .milligramsPerDeciliter, value: 60), -+ insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: .milligramsPerDeciliterPerInternationalUnit, dailyItems: []), -+ carbRatioSchedule: nil, -+ basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.2), RepeatingScheduleValue(startTime: 1800, value: 0.75)])) -+ -+ static let preview_supportedBasalRates = [0.2, 0.5, 0.75, 1.0] -+ static let preview_supportedBolusVolumes = [1.0, 2.0, 3.0] -+ static let preview_supportedMaximumBolusVolumes = [5.0, 10.0, 15.0] -+ -+ static var previews: some View { -+ ProfileView(viewModel: ProfileViewModel(therapySettings: preview_therapySettings)) -+ } -+} -diff --git a/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift -new file mode 100644 -index 00000000..8fd26665 ---- /dev/null -+++ b/LoopKit/LoopKitUI/Views/Settings Editors/RenameProfileEditor.swift -@@ -0,0 +1,92 @@ -+// -+// RenameProfileEditor.swift -+// LoopKitUI -+// -+// Created by Jonas Björkert on 2023-09-07. -+// Copyright © 2023 LoopKit Authors. All rights reserved. -+// -+ -+import Foundation -+import SwiftUI -+ -+struct RenameProfileEditor: View { -+ @Binding var isPresented: Bool -+ @State var currentProfileName: String -+ @State var newProfileName: String = "" -+ var viewModel: ProfileViewModel -+ @State private var showAlert = false -+ @Binding var shouldDismissParent: Bool -+ -+ var body: some View { -+ VStack(spacing: 0) { -+ ModalHeaderButtonBar( -+ leading: { cancelButton }, -+ center: { -+ Text("Rename Profile") -+ .font(.subheadline) -+ -+ }, -+ trailing: { renameButton } -+ ) -+ .onAppear { -+ self.newProfileName = currentProfileName -+ } -+ -+ TextField("Profile Name", text: $newProfileName) -+ .textFieldStyle(RoundedBorderTextFieldStyle()) -+ .padding() -+ .background( -+ RoundedCorners(radius: 10, corners: [.bottomLeft, .bottomRight]) -+ .fill(Color(.secondarySystemGroupedBackground)) -+ ) -+ } -+ .padding(.horizontal) -+ .alert(isPresented: $showAlert) { -+ Alert( -+ title: Text("Overwrite Existing Profile"), -+ message: Text("A profile with this name already exists. Would you like to overwrite it?"), -+ primaryButton: .destructive(Text("Overwrite")) { -+ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) -+ self.shouldDismissParent = true -+ self.isPresented = false -+ }, -+ secondaryButton: .cancel() -+ ) -+ } -+ } -+ -+ var renameButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ guard newProfileName != currentProfileName else { -+ self.isPresented = false -+ return -+ } -+ -+ if viewModel.doesProfileExist(withName: self.newProfileName) { -+ self.showAlert = true -+ } else { -+ self.viewModel.renameProfile(oldName: currentProfileName, newName: self.newProfileName) -+ self.shouldDismissParent = true -+ self.isPresented = false -+ } -+ } -+ }, label: { -+ Text("Rename") -+ } -+ ) -+ } -+ -+ var cancelButton: some View { -+ Button( -+ action: { -+ withAnimation { -+ self.isPresented = false -+ } -+ }, label: { -+ Text("Cancel") -+ } -+ ) -+ } -+} -Submodule LoopOnboarding contains modified content -diff --git a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -index 1ddc82b..54421a1 100644 ---- a/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -+++ b/LoopOnboarding/LoopOnboardingKitUI/View Controllers/OnboardingUICoordinator.swift -@@ -381,6 +381,9 @@ extension OnboardingUICoordinator: TherapySettingsViewModelDelegate { - maximumBasalScheduleEntryCount: maximumBasalScheduleEntryCount - ) - } -+ -+ func updateCurrentProfileName() { -+ } - } - - -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index 00096e2..cf6883e 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -23,6 +23,10 @@ - A9246B2526231B3A00CCDCB3 /* OTPManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9246B2426231B3A00CCDCB3 /* OTPManager.swift */; }; - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6152358F42A00949C8B /* StoredDosingDecision.swift */; }; - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6172358F49300949C8B /* StoredSettings.swift */; }; -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */; }; -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */; }; -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */; }; -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */; }; - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */ = {isa = PBXBuildFile; fileRef = A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */; }; - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; -@@ -156,6 +160,10 @@ - A9246B2426231B3A00CCDCB3 /* OTPManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPManager.swift; sourceTree = ""; }; - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredDosingDecision.swift; sourceTree = ""; }; - A934B6172358F49300949C8B /* StoredSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredSettings.swift; sourceTree = ""; }; -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchAction.swift; sourceTree = ""; }; -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotification.swift; sourceTree = ""; }; -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SavedProfile.swift; sourceTree = ""; }; -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileSwitchRemoteNotificationTestCase.swift; sourceTree = ""; }; - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemporaryScheduleOverride.swift; sourceTree = ""; }; - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; -@@ -265,6 +273,7 @@ - A934B6152358F42A00949C8B /* StoredDosingDecision.swift */, - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */, - A934B6172358F49300949C8B /* StoredSettings.swift */, -+ 3E7A51C52F0D4B8100A1C6E2 /* SavedProfile.swift */, - A90E39A222BC782C0016DFE8 /* SyncCarbObject.swift */, - A934B6192358F5FC00949C8B /* TemporaryScheduleOverride.swift */, - A90E399D22BC76DB0016DFE8 /* TimeInterval.swift */, -@@ -321,6 +330,7 @@ - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -+ 3E7A51C72F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift */, - A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, - ); - path = NightscoutServiceKitTests; -@@ -361,6 +371,7 @@ - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -+ 3E7A51C32F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift */, - ); - path = Notifications; - sourceTree = ""; -@@ -409,6 +420,7 @@ - A9AA6E5729EB06AA008FFA78 /* Actions */ = { - isa = PBXGroup; - children = ( -+ 3E7A51C12F0D4B8100A1C6E2 /* ProfileSwitchAction.swift */, - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -@@ -740,6 +752,9 @@ - C1398D2227C434FB00416AD6 /* Data.swift in Sources */, - A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, - A934B6182358F49300949C8B /* StoredSettings.swift in Sources */, -+ 3E7A51C02F0D4B8100A1C6E2 /* ProfileSwitchAction.swift in Sources */, -+ 3E7A51C22F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotification.swift in Sources */, -+ 3E7A51C42F0D4B8100A1C6E2 /* SavedProfile.swift in Sources */, - A9C62DA42331CAD400535612 /* OSLog.swift in Sources */, - C1F7822827CD57A100C0919A /* OverrideTreament.swift in Sources */, - ); -@@ -753,6 +768,7 @@ - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -+ 3E7A51C62F0D4B8100A1C6E2 /* ProfileSwitchRemoteNotificationTestCase.swift in Sources */, - A95F887526C694E900D8A009 /* OTPManagerTestCase.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -new file mode 100644 -index 0000000..588ded1 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift -@@ -0,0 +1,104 @@ -+// -+// SavedProfile.swift -+// NightscoutServiceKit -+// -+ -+import CryptoKit -+import Foundation -+import LoopAlgorithm -+import LoopKit -+import NightscoutKit -+ -+/// A profile saved with the Profiles settings screen, read from the file it was saved to. -+struct SavedProfile: Decodable { -+ let name: String -+ let correctionRange: GlucoseRangeSchedule -+ let carbRatioSchedule: CarbRatioSchedule -+ let basalRateSchedule: BasalRateSchedule -+ let insulinSensitivitySchedule: InsulinSensitivitySchedule -+ -+ private static var directory: URL { -+ FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("LoopProfile") -+ } -+ -+ static func loadAll() -> [SavedProfile] { -+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] -+ return files -+ .filter { $0.pathExtension == "json" } -+ .compactMap { try? Data(contentsOf: $0) } -+ .compactMap { try? JSONDecoder().decode(SavedProfile.self, from: $0) } -+ } -+ -+ /// Saved profiles keyed by name, in the form they are uploaded to the Nightscout profile store. -+ static func nightscoutStore(unit: LoopUnit) -> [String: ProfileSet.Profile] { -+ var store: [String: ProfileSet.Profile] = [:] -+ // Nightscout cannot store keys starting with "$"; "Default" holds the live settings. -+ for savedProfile in loadAll() where !savedProfile.name.hasPrefix("$") && savedProfile.name != "Default" { -+ store[savedProfile.name] = savedProfile.nightscoutProfile(unit: unit) -+ } -+ return store -+ } -+ -+ /// True when this profile uploads with this fingerprint, in either glucose unit. -+ func matches(fingerprint: String) -> Bool { -+ [LoopUnit.milligramsPerDeciliter, .millimolesPerLiter].contains { -+ nightscoutProfile(unit: $0)?.remoteFingerprint == fingerprint -+ } -+ } -+ -+ func nightscoutProfile(unit: LoopUnit) -> ProfileSet.Profile? { -+ guard let correctionRange = correctionRange.schedule(for: unit), -+ let insulinSensitivity = insulinSensitivitySchedule.schedule(for: unit) -+ else { -+ return nil -+ } -+ return ProfileSet.Profile( -+ timezone: basalRateSchedule.timeZone, -+ dia: .hours(6), -+ sensitivity: insulinSensitivity.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ carbratio: carbRatioSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ basal: basalRateSchedule.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value) }, -+ targetLow: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.minValue) }, -+ targetHigh: correctionRange.items.map { ProfileSet.ScheduleItem(offset: $0.startTime, value: $0.value.maxValue) }, -+ units: unit.shortLocalizedUnitString(avoidLineBreaking: false)) -+ } -+} -+ -+extension ProfileSet.Profile { -+ /// Followers compute the same value from the uploaded profile and send it with a switch command, -+ /// so Loop only activates the values the follower previewed. -+ /// Format: `basal=:,...;carbratio=...;sens=...;target_low=...;target_high=...` -+ /// with values to three decimals, hashed with SHA-256, first 16 hex characters. -+ var remoteFingerprint: String { -+ func items(_ schedule: [ProfileSet.ScheduleItem]) -> String { -+ schedule.map { "\(Int($0.offset)):" + String(format: "%.3f", $0.value) }.joined(separator: ",") -+ } -+ let canonical = [ -+ "basal=" + items(basal), -+ "carbratio=" + items(carbratio), -+ "sens=" + items(sensitivity), -+ "target_low=" + items(targetLow), -+ "target_high=" + items(targetHigh), -+ ].joined(separator: ";") -+ let digest = SHA256.hash(data: Data(canonical.utf8)) -+ return String(digest.map { String(format: "%02x", $0) }.joined().prefix(16)) -+ } -+} -+ -+enum RemoteProfileSwitchError: LocalizedError { -+ case profileChanged -+ case unavailable -+ case notActivated(String, Error) -+ -+ /// Followers read the profile name from this text to match the reply to their command. -+ var errorDescription: String? { -+ switch self { -+ case .profileChanged: -+ return LocalizedString("It changed after it was uploaded. Review it again and resend.", comment: "Remote profile switch error description: profile differs from the previewed one.") -+ case .unavailable: -+ return LocalizedString("Profile switching is not available", comment: "Remote profile switch error description: host cannot switch profiles.") -+ case .notActivated(let name, let error): -+ return String(format: LocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure. (1: profile name, 2: reason)"), name, error.localizedDescription) -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index 2f12b47..2dec600 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -180,6 +180,7 @@ extension StoredDosingDecision { - enacted: automaticDoseDecision?.loopStatusEnacted, - failureReason: automaticDoseDecision?.loopStatusFailureReason, - failureCode: automaticDoseDecision?.errors.first?.id, -+ testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]], - algorithmExperiments: (automaticDoseDecision?.algorithmExperiments ?? algorithmExperiments)?.map { $0.jsonObject }), - overrideStatus: overrideStatus) - } -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -index 3142223..ae6fb9a 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredSettings.swift -@@ -93,12 +93,21 @@ extension StoredSettings { - return nil - } - -+ var store = SavedProfile.nightscoutStore(unit: glucoseTargetRangeSchedule?.unit ?? bloodGlucoseUnit) -+ store["Default"] = profile -+ var defaultProfile = "Default" -+ -+ if let name = UserDefaults.standard.string(forKey: "currentProfileName") { -+ defaultProfile = name -+ store[name] = profile -+ } -+ - return ProfileSet( - startDate: date, - units: bloodGlucoseUnit.shortLocalizedUnitString(avoidLineBreaking: false), - enteredBy: "Loop", -- defaultProfile: "Default", -- store: ["Default": profile], -+ defaultProfile: defaultProfile, -+ store: store, - settings: loopSettings, - syncIdentifier: syncIdentifier.uuidString, - isAPNSProduction: Bundle.main.isAPNSProductionEnvironment, -diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index c625152..ec997ef 100644 ---- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift -+++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -473,6 +473,24 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - success = true - message = "Override '\(overrideCommand.name)' enacted successfully" - -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ success = true -+ message = "Profile '\(profileSwitchCommand.name)' activated" -+ - case .cancelTemporaryOverride: - commandType = .cancelOverride - try await serviceDelegate.cancelRemoteOverride() -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index a92b509..29313b6 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -9,6 +9,7 @@ - import Foundation - - public enum Action: Codable { -+ case profileSwitch(ProfileSwitchAction) - case temporaryScheduleOverride(OverrideAction) - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) -@@ -28,6 +29,8 @@ public enum Action: Codable { - return LocalizedString("Cancel Override", comment: "The remote action name for Cancel Override") - case .temporaryScheduleOverride: - return LocalizedString("Override", comment: "The remote action name for Override") -+ case .profileSwitch: -+ return LocalizedString("Profile Switch", comment: "The remote action name for Profile Switch") - } - } - -@@ -43,6 +46,8 @@ public enum Action: Codable { - return "" - case .temporaryScheduleOverride(let overrideAction): - return overrideAction.name -+ case .profileSwitch(let profileSwitchAction): -+ return profileSwitchAction.name - } - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -new file mode 100644 -index 0000000..ce03ded ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/ProfileSwitchAction.swift -@@ -0,0 +1,17 @@ -+// -+// ProfileSwitchAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct ProfileSwitchAction: Codable { -+ -+ public let name: String -+ public let fingerprint: String -+ -+ public init(name: String, fingerprint: String) { -+ self.name = name -+ self.fingerprint = fingerprint -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 95274e4..4457f80 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -45,6 +45,7 @@ class RemoteNotificationResponseManager { - case carbs = "carbs" - case override = "override" - case cancelOverride = "cancel_override" -+ case profileSwitch = "profile_switch" - } - - func sendResponseNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -new file mode 100644 -index 0000000..ebcd6ef ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -0,0 +1,50 @@ -+// -+// ProfileSwitchRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Switches to a saved profile. `expiration` is required: decoding fails without it. -+public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { -+ -+ /// Listed in the uploaded `remoteCommands` so followers know this build accepts the command. -+ static let commandKey = "profile-switch" -+ -+ public let profileName: String -+ public let fingerprint: String -+ public let remoteAddress: String -+ public let expirationDate: Date -+ public let sentAt: Date -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ public var expiration: Date? { -+ return expirationDate -+ } -+ -+ enum CodingKeys: String, CodingKey { -+ case profileName = "profile-switch" -+ case fingerprint = "profile-switch-fingerprint" -+ case remoteAddress = "remote-address" -+ case expirationDate = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .profileSwitch(ProfileSwitchAction(name: profileName, fingerprint: fingerprint)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification[commandKey] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index 24e63d7..a0f58a0 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -104,6 +104,8 @@ extension Dictionary { - return try OverrideRemoteNotification(dictionary: self) - } else if OverrideCancelRemoteNotification.includedInNotification(self) { - return try OverrideCancelRemoteNotification(dictionary: self) -+ } else if ProfileSwitchRemoteNotification.includedInNotification(self) { -+ return try ProfileSwitchRemoteNotification(dictionary: self) - } else { - throw RemoteNotificationError.unhandledNotification(self) - } -diff --git a/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..2fcec94 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/ProfileSwitchRemoteNotificationTestCase.swift -@@ -0,0 +1,71 @@ -+// -+// ProfileSwitchRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+import LoopKit -+import NightscoutKit -+@testable import NightscoutServiceKit -+ -+final class ProfileSwitchRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseProfileSwitchNotification_ValidPayload_Succeeds() throws { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "sent-at": "2023-02-25T20:46:35.778Z", -+ "expiration": "2023-02-25T20:51:35.778Z", -+ "otp": "123456" -+ ] -+ -+ XCTAssertTrue(ProfileSwitchRemoteNotification.includedInNotification(notification)) -+ let profileSwitch = try ProfileSwitchRemoteNotification(dictionary: notification) -+ -+ XCTAssertEqual(profileSwitch.profileName, "Sport") -+ XCTAssertEqual(profileSwitch.fingerprint, "fec4f066b5a54ecd") -+ XCTAssertEqual(profileSwitch.expiration, ISO8601DateFormatter.fractional.date(from: "2023-02-25T20:51:35.778Z")) -+ XCTAssertTrue(profileSwitch.otpValidationRequired()) -+ guard case .profileSwitch(let action) = profileSwitch.toRemoteAction() else { -+ return XCTFail("Expected a profile switch action") -+ } -+ XCTAssertEqual(action.name, "Sport") -+ } -+ -+ func testParseProfileSwitchNotification_MissingExpiration_Fails() { -+ let notification: [String: Any] = [ -+ "profile-switch": "Sport", -+ "profile-switch-fingerprint": "fec4f066b5a54ecd", -+ "remote-address": "LoopFollow", -+ "otp": "123456" -+ ] -+ -+ XCTAssertThrowsError(try ProfileSwitchRemoteNotification(dictionary: notification)) -+ } -+ -+ func testFingerprint_MatchesFollowerVector() { -+ func items(_ values: [(TimeInterval, Double)]) -> [ProfileSet.ScheduleItem] { -+ values.map { ProfileSet.ScheduleItem(offset: $0.0, value: $0.1) } -+ } -+ let profile = ProfileSet.Profile( -+ timezone: TimeZone(secondsFromGMT: 3600)!, -+ dia: .hours(6), -+ sensitivity: items([(0, 45)]), -+ carbratio: items([(0, 10)]), -+ basal: items([(0, 0.85), (21600, 1.1)]), -+ targetLow: items([(0, 100)]), -+ targetHigh: items([(0, 110)]), -+ units: "mg/dL") -+ -+ XCTAssertEqual(profile.remoteFingerprint, "fec4f066b5a54ecd") -+ } -+} -+ -+private extension ISO8601DateFormatter { -+ static let fractional: ISO8601DateFormatter = { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ }() -+} -diff --git a/profiles.loopcustomization b/profiles.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/remote_carb_edit/README.md b/remote_carb_edit/README.md index 902c045..9b40ba4 100644 --- a/remote_carb_edit/README.md +++ b/remote_carb_edit/README.md @@ -38,12 +38,3 @@ For `next-dev`: Both orders produce the same tree. The selector script tries each folder's patches in sorted order and uses the first one that applies, so choosing both customizations picks the right file whichever is applied first. Applying by hand, use the plain patch for the first customization and the combined one for the second. With remote_window, `sent-at` is required on every remote command (the notification is rejected without it). On `dev`, delete and edit commands also appear in the remote command history with the entry's `syncIdentifier`; the `next-dev` remote_window has no command history. - -## Combining with profiles - -`profiles` also adds a remote command (profile switch), so the two have variants for each other, also together with remote_window: - -- `_remote_carb_edit_profiles.patch` and `_remote_carb_edit_profiles_remote_window.patch`: this customization on top of profiles (and remote_window). -- `profiles/_profiles_remote_carb_edit.patch` and `..._remote_carb_edit_remote_window.patch`: the reverse. - -With both applied, `remoteCommands` lists `carbs-delete`, `carbs-edit` and `profile-switch`. See `profiles/README.md`. diff --git a/remote_carb_edit/dev_remote_carb_edit_profiles.patch b/remote_carb_edit/dev_remote_carb_edit_profiles.patch deleted file mode 100644 index 3a6792b..0000000 --- a/remote_carb_edit/dev_remote_carb_edit_profiles.patch +++ /dev/null @@ -1,1010 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift -index d0d2b312..02359815 100644 ---- a/Loop/Loop/Managers/LoopDataManager.swift -+++ b/Loop/Loop/Managers/LoopDataManager.swift -@@ -2540,32 +2540,83 @@ extension LoopDataManager: ServicesManagerDelegate { - //Carb Entry - - func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { -- - let absorptionTime = absorptionTime ?? carbStore.defaultAbsorptionTimes.medium -+ let startDate = startDate ?? Date() -+ try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) -+ -+ let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) -+ let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate, foodType: foodType, absorptionTime: absorptionTime) -+ -+ let _ = try await devliverCarbEntry(candidateCarbEntry) -+ } -+ -+ /// Remote edits and deletes may only target entries this app created within the last 24 hours. -+ static let remoteCarbEditWindow: TimeInterval = .hours(24) -+ -+ func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry { -+ let entry = try await remoteEditableCarbEntry(syncIdentifier: syncIdentifier) -+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in -+ deleteCarbEntry(entry) { result in -+ switch result { -+ case .success: -+ continuation.resume() -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ return entry -+ } -+ -+ func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry { -+ let existing = try await remoteEditableCarbEntry(syncIdentifier: syncIdentifier) -+ try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) -+ -+ let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) -+ let replacement = NewCarbEntry(quantity: quantity, startDate: startDate, foodType: foodType ?? existing.foodType, absorptionTime: absorptionTime) -+ -+ return try await withCheckedThrowingContinuation { continuation in -+ addCarbEntry(replacement, replacing: existing) { result in -+ switch result { -+ case .success(let storedCarbEntry): -+ continuation.resume(returning: storedCarbEntry) -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ } -+ -+ private func validateRemoteCarbParameters(amountInGrams: Double, absorptionTime: TimeInterval, startDate: Date) throws { - if absorptionTime < LoopConstants.minCarbAbsorptionTime || absorptionTime > LoopConstants.maxCarbAbsorptionTime { - throw CarbActionError.invalidAbsorptionTime(absorptionTime) - } -- -+ - guard amountInGrams > 0.0 else { - throw CarbActionError.invalidCarbs - } -- -+ - guard amountInGrams <= LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram()) else { - throw CarbActionError.exceedsMaxCarbs - } -- -- if let startDate = startDate { -- let maxStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) -- let minStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryPastTime) -- guard startDate <= maxStartDate && startDate >= minStartDate else { -- throw CarbActionError.invalidStartDate(startDate) -- } -+ -+ let maxStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) -+ let minStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryPastTime) -+ guard startDate <= maxStartDate && startDate >= minStartDate else { -+ throw CarbActionError.invalidStartDate(startDate) - } -- -- let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) -- let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate ?? Date(), foodType: foodType, absorptionTime: absorptionTime) -- -- let _ = try await devliverCarbEntry(candidateCarbEntry) -+ } -+ -+ private func remoteEditableCarbEntry(syncIdentifier: String) async throws -> StoredCarbEntry { -+ let now = Date() -+ let entries = try await carbStore.getCarbEntries(start: now.addingTimeInterval(-Self.remoteCarbEditWindow), end: now.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime)) -+ guard let entry = entries.first(where: { $0.syncIdentifier == syncIdentifier }) else { -+ throw CarbActionError.entryNotFound -+ } -+ guard entry.createdByCurrentApp else { -+ throw CarbActionError.entryNotEditable -+ } -+ return entry - } - - enum CarbActionError: LocalizedError { -@@ -2574,6 +2625,8 @@ extension LoopDataManager: ServicesManagerDelegate { - case invalidStartDate(Date) - case exceedsMaxCarbs - case invalidCarbs -+ case entryNotFound -+ case entryNotEditable - - var errorDescription: String? { - switch self { -@@ -2581,6 +2634,10 @@ extension LoopDataManager: ServicesManagerDelegate { - return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Carb error description: carbs exceed maximum amount.") - case .invalidCarbs: - return NSLocalizedString("Invalid carb amount", comment: "Carb error description: invalid carb amount.") -+ case .entryNotFound: -+ return NSLocalizedString("Carb entry not found or older than 24 hours", comment: "Carb error description: remote edit target not found.") -+ case .entryNotEditable: -+ return NSLocalizedString("Carb entry was not created by Loop", comment: "Carb error description: remote edit target not created by this app.") - case .invalidAbsorptionTime(let absorptionTime): - let absorptionHoursFormatted = Self.numberFormatter.string(from: absorptionTime.hours) ?? "" - return String(format: NSLocalizedString("Invalid absorption time: %1$@ hours", comment: "Carb error description: invalid absorption time. (1: Input duration in hours)."), absorptionHoursFormatted) -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 17cff75d..6cefc092 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -201,6 +201,36 @@ extension NotificationManager { - UNUserNotificationCenter.current().add(request) - } - -+ @MainActor -+ static func sendRemoteCarbChangeNotification(body: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [body, "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: LoopNotificationCategory.remoteCarbs.rawValue, -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ @MainActor -+ static func sendRemoteCarbChangeFailureNotification(body: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [body, error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: LoopNotificationCategory.remoteCarbsFailure.rawValue, -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ - static func sendMissedMealNotification(mealStart: Date, amountInGrams: Double, delay: TimeInterval? = nil) { - let notification = UNMutableNotificationContent() - /// Notifications should expire after the missed meal is no longer relevant -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index c61eda65..7a8dc92f 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -240,6 +240,8 @@ public protocol ServicesManagerDelegate: AnyObject { - func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws - func cancelCurrentOverride() async throws - func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws -+ func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry -+ func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry - } - - // MARK: - StatefulPluggableDelegate -@@ -330,6 +332,48 @@ extension ServicesManager: ServiceDelegate { - } - } - -+ func deleteRemoteCarbs(syncIdentifier: String) async throws { -+ do { -+ guard let servicesManagerDelegate else { -+ throw RemoteCarbCommandError.internalError -+ } -+ let deleted = try await servicesManagerDelegate.deleteCarbs(syncIdentifier: syncIdentifier) -+ let body = String(format: NSLocalizedString("Remote Carbs Delete: %d grams removed", comment: "The notification body for a successful remote carb delete. (1: Carb amount in grams)"), Int(deleted.quantity.doubleValue(for: .gram()))) -+ await NotificationManager.sendRemoteCarbChangeNotification(body: body) -+ await remoteDataServicesManager.triggerUpload(for: .carb) -+ } catch { -+ let body = NSLocalizedString("Remote Carbs Delete", comment: "The notification body prefix for a failed remote carb delete") -+ await NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) -+ throw error -+ } -+ } -+ -+ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws { -+ let body = String(format: NSLocalizedString("Remote Carbs Edit: %d grams", comment: "The carb amount message for a remote carb edit notification. (1: Carb amount in grams)"), Int(amountInGrams)) -+ do { -+ guard let servicesManagerDelegate else { -+ throw RemoteCarbCommandError.internalError -+ } -+ _ = try await servicesManagerDelegate.replaceCarbs(syncIdentifier: syncIdentifier, amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) -+ await NotificationManager.sendRemoteCarbChangeNotification(body: body) -+ await remoteDataServicesManager.triggerUpload(for: .carb) -+ } catch { -+ await NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) -+ throw error -+ } -+ } -+ -+ enum RemoteCarbCommandError: LocalizedError { -+ case internalError -+ -+ var errorDescription: String? { -+ switch self { -+ case .internalError: -+ return NSLocalizedString("Internal error", comment: "Remote carb command error description: internal error.") -+ } -+ } -+ } -+ - func deliverRemoteBolus(amountInUnits: Double) async throws { - do { - -diff --git a/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift -index a7ffef2e..692a4ec2 100644 ---- a/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift -+++ b/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift -@@ -34,6 +34,8 @@ protocol CarbStoreProtocol: AnyObject { - func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) - - func addCarbEntry(_ entry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) -+ -+ func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] - - func getCarbStatus(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult<[CarbStatus]>) -> Void) - -diff --git a/Loop/LoopTests/Mock Stores/MockCarbStore.swift b/Loop/LoopTests/Mock Stores/MockCarbStore.swift -index 4a5c016e..dd35a787 100644 ---- a/Loop/LoopTests/Mock Stores/MockCarbStore.swift -+++ b/Loop/LoopTests/Mock Stores/MockCarbStore.swift -@@ -12,6 +12,7 @@ import LoopKit - - class MockCarbStore: CarbStoreProtocol { - var carbHistory: [StoredCarbEntry]? -+ var carbEntries: [StoredCarbEntry] = [] - - init(for scenario: DosingTestScenario = .flatAndStable) { - self.scenario = scenario // The store returns different effect values based on the scenario -@@ -72,6 +73,10 @@ class MockCarbStore: CarbStoreProtocol { - completion(.failure(.notConfigured)) - } - -+ func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] { -+ return carbEntries -+ } -+ - func getCarbStatus(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult<[CarbStatus]>) -> Void) { - completion(.failure(.notConfigured)) - } -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 00fe25ab..56742c96 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -19,4 +19,6 @@ public protocol RemoteActionDelegate: AnyObject { - func cancelRemoteOverride() async throws - func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws - func deliverRemoteBolus(amountInUnits: Double) async throws -+ func deleteRemoteCarbs(syncIdentifier: String) async throws -+ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws - } -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index 14a7691..18143ab 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -32,12 +32,17 @@ - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; - A941B09029BD4A9E00F91340 /* CarbRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */; }; -+ FB42DD0865235D441918DA5E /* CarbEditRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */; }; -+ A2C1A69F3A61FD4E207637A7 /* CarbDeleteRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */; }; - A941B09129BD4A9E00F91340 /* BolusRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */; }; - A941B09229BD4A9F00F91340 /* OverrideCancelRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */; }; - A941B09329BD4A9F00F91340 /* RemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */; }; - A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */; }; - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */; }; - A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */; }; -+ AFFFFDFBBBA4F7DF3997E63E /* CarbEditRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */; }; -+ 73056A2FC515352D51EFFECF /* CarbDeleteRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */; }; -+ 5C1D2E3F4A5B6C7D8E9F0A1B /* RemoteNotificationCapabilitiesTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */; }; - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */; }; - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */; }; - A94DA7642353CF930082E26D /* StoredGlucoseSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */; }; -@@ -51,6 +56,8 @@ - A9AA6E5B29EB072E008FFA78 /* OverrideAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */; }; - A9AA6E6329EB07D7008FFA78 /* Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5D29EB07D6008FFA78 /* Action.swift */; }; - A9AA6E6429EB07D7008FFA78 /* CarbAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */; }; -+ 843DF14B73EC57EB6F0033DA /* CarbEditAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04073CA740189C4259808331 /* CarbEditAction.swift */; }; -+ 7E7294EAE3456F0C6DC10F37 /* CarbDeleteAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */; }; - A9AA6E6529EB07D7008FFA78 /* OverrideCancelAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */; }; - A9AA6E6629EB07D7008FFA78 /* BolusAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */; }; - A9B007B322BD6FF7000131DE /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B007B222BD6FF7000131DE /* LocalizedString.swift */; }; -@@ -170,12 +177,17 @@ - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; - A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbRemoteNotification.swift; sourceTree = ""; }; -+ 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditRemoteNotification.swift; sourceTree = ""; }; -+ A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteRemoteNotification.swift; sourceTree = ""; }; - A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRemoteNotification.swift; sourceTree = ""; }; - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotification.swift; sourceTree = ""; }; - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotification.swift; sourceTree = ""; }; - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideRemoteNotification.swift; sourceTree = ""; }; - A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRemoteNotificationTestCase.swift; sourceTree = ""; }; - A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbRemoteNotificationTestCase.swift; sourceTree = ""; }; -+ E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditRemoteNotificationTestCase.swift; sourceTree = ""; }; -+ 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteRemoteNotificationTestCase.swift; sourceTree = ""; }; -+ 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotificationCapabilitiesTestCase.swift; sourceTree = ""; }; - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotificationTestCase.swift; sourceTree = ""; }; - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideRemoteNotificationTestCase.swift; sourceTree = ""; }; - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredGlucoseSample.swift; sourceTree = ""; }; -@@ -191,6 +203,8 @@ - A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideAction.swift; sourceTree = ""; }; - A9AA6E5D29EB07D6008FFA78 /* Action.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbAction.swift; sourceTree = ""; }; -+ 04073CA740189C4259808331 /* CarbEditAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditAction.swift; sourceTree = ""; }; -+ 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteAction.swift; sourceTree = ""; }; - A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelAction.swift; sourceTree = ""; }; - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusAction.swift; sourceTree = ""; }; - A9B007B222BD6FF7000131DE /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; -@@ -330,6 +344,9 @@ - A91BAC2B22BC691A00ABF1BB /* Info.plist */, - A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */, - A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */, -+ E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */, -+ 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */, -+ 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */, - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -@@ -371,6 +388,8 @@ - children = ( - A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */, - A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */, -+ 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */, -+ A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */, - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -@@ -427,6 +446,8 @@ - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -+ 04073CA740189C4259808331 /* CarbEditAction.swift */, -+ 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */, - A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */, - A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */, - ); -@@ -728,12 +749,16 @@ - A993990322BC7095005E39D3 /* NightscoutService.swift in Sources */, - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */, - A9AA6E6429EB07D7008FFA78 /* CarbAction.swift in Sources */, -+ 843DF14B73EC57EB6F0033DA /* CarbEditAction.swift in Sources */, -+ 7E7294EAE3456F0C6DC10F37 /* CarbDeleteAction.swift in Sources */, - A90E39A922BC7AD10016DFE8 /* Bundle.swift in Sources */, - A90E39A322BC782C0016DFE8 /* SyncCarbObject.swift in Sources */, - A941B09329BD4A9F00F91340 /* RemoteNotification.swift in Sources */, - A9AA6E6629EB07D7008FFA78 /* BolusAction.swift in Sources */, - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */, - A941B09029BD4A9E00F91340 /* CarbRemoteNotification.swift in Sources */, -+ FB42DD0865235D441918DA5E /* CarbEditRemoteNotification.swift in Sources */, -+ A2C1A69F3A61FD4E207637A7 /* CarbDeleteRemoteNotification.swift in Sources */, - A94DA7642353CF930082E26D /* StoredGlucoseSample.swift in Sources */, - A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */, - C1E703642506BE9400DAB534 /* ObjectIdCache.swift in Sources */, -@@ -769,6 +794,9 @@ - buildActionMask = 2147483647; - files = ( - A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */, -+ AFFFFDFBBBA4F7DF3997E63E /* CarbEditRemoteNotificationTestCase.swift in Sources */, -+ 73056A2FC515352D51EFFECF /* CarbDeleteRemoteNotificationTestCase.swift in Sources */, -+ 5C1D2E3F4A5B6C7D8E9F0A1B /* RemoteNotificationCapabilitiesTestCase.swift in Sources */, - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index b66fb97..7c780ea 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -160,7 +160,7 @@ extension StoredDosingDecision { - recommendedBolus: loopStatusRecommendedBolus, - enacted: automaticDoseDecision?.loopStatusEnacted, - failureReason: automaticDoseDecision?.loopStatusFailureReason, -- testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]]), -+ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]]), - overrideStatus: overrideStatus) - } - -diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index ef69236..18ac515 100644 ---- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift -+++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -24,7 +24,7 @@ public final class NightscoutService: Service { - - public static let localizedTitle = LocalizedString("Nightscout", comment: "The title of the Nightscout service") - -- public let objectIdCacheKeepTime = TimeInterval(24 * 60 * 60) -+ public let objectIdCacheKeepTime = TimeInterval(48 * 60 * 60) - - public weak var serviceDelegate: ServiceDelegate? - -@@ -423,6 +423,7 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - var commandType: RemoteNotificationResponseManager.CommandType = .bolus // Default, will be set in switch - var success = false - var message = "" -+ var syncIdentifier: String? = nil - - do { - switch action { -@@ -476,6 +477,26 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - ) - success = true - message = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) -+ -+ case .carbsDelete(let deleteCommand): -+ commandType = .carbsDelete -+ syncIdentifier = deleteCommand.syncIdentifier -+ try await self.serviceDelegate?.deleteRemoteCarbs(syncIdentifier: deleteCommand.syncIdentifier) -+ success = true -+ message = "Carb entry deleted successfully" -+ -+ case .carbsEdit(let editCommand): -+ commandType = .carbsEdit -+ syncIdentifier = editCommand.syncIdentifier -+ try await self.serviceDelegate?.replaceRemoteCarbs( -+ syncIdentifier: editCommand.syncIdentifier, -+ amountInGrams: editCommand.amountInGrams, -+ absorptionTime: editCommand.absorptionTime, -+ foodType: editCommand.foodType, -+ startDate: editCommand.startDate -+ ) -+ success = true -+ message = String(format: "Carb entry updated to %.1f g", editCommand.amountInGrams) - } - } catch { - message = "Command failed: \(error.localizedDescription)" -@@ -485,7 +506,8 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - to: returnInfo, - commandType: commandType, - success: false, -- message: message -+ message: message, -+ syncIdentifier: syncIdentifier - ) - } - throw error -@@ -497,7 +519,8 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - to: returnInfo, - commandType: commandType, - success: success, -- message: message -+ message: message, -+ syncIdentifier: syncIdentifier - ) - } - } -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index 29313b6..29d3563 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -14,6 +14,8 @@ public enum Action: Codable { - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) - case carbsEntry(CarbAction) -+ case carbsDelete(CarbDeleteAction) -+ case carbsEdit(CarbEditAction) - - public var description: String { - return "\(actionName) \(actionParameterDescription)" -@@ -23,6 +25,10 @@ public enum Action: Codable { - switch self { - case .carbsEntry: - return LocalizedString("Carb Entry", comment: "The remote action name for Carb Entry") -+ case .carbsDelete: -+ return LocalizedString("Delete Carbs", comment: "The remote action name for deleting a carb entry") -+ case .carbsEdit: -+ return LocalizedString("Edit Carbs", comment: "The remote action name for editing a carb entry") - case .bolusEntry: - return LocalizedString("Bolus Entry", comment: "The remote action name for Bolus Entry") - case .cancelTemporaryOverride: -@@ -39,6 +45,11 @@ public enum Action: Codable { - case .carbsEntry(let carbAction): - let amountFormatted = Self.numberFormatter.string(from: carbAction.amountInGrams as NSNumber) ?? "" - return "\(amountFormatted)" + " " + carbGramAbbreviation -+ case .carbsDelete(let deleteAction): -+ return String(deleteAction.syncIdentifier.prefix(8)) -+ case .carbsEdit(let editAction): -+ let amountFormatted = Self.numberFormatter.string(from: editAction.amountInGrams as NSNumber) ?? "" -+ return "\(amountFormatted)" + " " + carbGramAbbreviation - case .bolusEntry(let bolusAction): - let amountFormatted = Self.numberFormatter.string(from: bolusAction.amountInUnits as NSNumber) ?? "" - return "\(amountFormatted)" + " " + bolusUnitAbbreviation -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift -new file mode 100644 -index 0000000..77e9900 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift -@@ -0,0 +1,15 @@ -+// -+// CarbDeleteAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct CarbDeleteAction: Codable { -+ -+ public let syncIdentifier: String -+ -+ public init(syncIdentifier: String) { -+ self.syncIdentifier = syncIdentifier -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift -new file mode 100644 -index 0000000..0aea20b ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift -@@ -0,0 +1,23 @@ -+// -+// CarbEditAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct CarbEditAction: Codable { -+ -+ public let syncIdentifier: String -+ public let amountInGrams: Double -+ public let absorptionTime: TimeInterval -+ public let foodType: String? -+ public let startDate: Date -+ -+ public init(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String? = nil, startDate: Date) { -+ self.syncIdentifier = syncIdentifier -+ self.amountInGrams = amountInGrams -+ self.absorptionTime = absorptionTime -+ self.foodType = foodType -+ self.startDate = startDate -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 4457f80..aabb47f 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -21,12 +21,14 @@ class RemoteNotificationResponseManager { - let commandStatus: String - let commandType: String - let timestamp: TimeInterval -+ let syncIdentifier: String? - - enum CodingKeys: String, CodingKey { - case aps - case commandStatus = "command_status" - case commandType = "command_type" - case timestamp -+ case syncIdentifier = "sync_identifier" - } - } - -@@ -43,6 +45,8 @@ class RemoteNotificationResponseManager { - enum CommandType: String { - case bolus = "bolus" - case carbs = "carbs" -+ case carbsDelete = "carbs_delete" -+ case carbsEdit = "carbs_edit" - case override = "override" - case cancelOverride = "cancel_override" - case profileSwitch = "profile_switch" -@@ -52,7 +56,8 @@ class RemoteNotificationResponseManager { - to returnInfo: ReturnNotificationInfo?, - commandType: CommandType, - success: Bool, -- message: String -+ message: String, -+ syncIdentifier: String? = nil - ) async { - guard let returnInfo = returnInfo else { - os_log("No return notification info provided, skipping response", log: log, type: .info) -@@ -75,7 +80,8 @@ class RemoteNotificationResponseManager { - ), - commandStatus: success ? "success" : "failed", - commandType: commandType.rawValue, -- timestamp: Date().timeIntervalSince1970 -+ timestamp: Date().timeIntervalSince1970, -+ syncIdentifier: syncIdentifier - ) - - await sendPushNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift -new file mode 100644 -index 0000000..1593a74 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift -@@ -0,0 +1,44 @@ -+// -+// CarbDeleteRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+public struct CarbDeleteRemoteNotification: RemoteNotification, Codable { -+ -+ public let syncIdentifier: String -+ public let remoteAddress: String -+ public let expiration: Date? -+ public let sentAt: Date? -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ enum CodingKeys: String, CodingKey { -+ case syncIdentifier = "carbs-delete" -+ case remoteAddress = "remote-address" -+ case expiration = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .carbsDelete(CarbDeleteAction(syncIdentifier: syncIdentifier)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ func expirationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification["carbs-delete"] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift -new file mode 100644 -index 0000000..fde3e4c ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift -@@ -0,0 +1,58 @@ -+// -+// CarbEditRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Replacement values use prefixed keys so an unpatched Loop never reads them as a new carb entry. -+public struct CarbEditRemoteNotification: RemoteNotification, Codable { -+ -+ public let syncIdentifier: String -+ public let amount: Double -+ public let absorptionInHours: Double -+ public let foodType: String? -+ public let startDate: Date -+ public let remoteAddress: String -+ public let expiration: Date? -+ public let sentAt: Date? -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ enum CodingKeys: String, CodingKey { -+ case syncIdentifier = "carbs-edit" -+ case amount = "carbs-edit-entry" -+ case absorptionInHours = "carbs-edit-absorption-time" -+ case foodType = "carbs-edit-food-type" -+ case startDate = "carbs-edit-start-time" -+ case remoteAddress = "remote-address" -+ case expiration = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ public func absorptionTime() -> TimeInterval { -+ return TimeInterval(hours: absorptionInHours) -+ } -+ -+ func toRemoteAction() -> Action { -+ let action = CarbEditAction(syncIdentifier: syncIdentifier, amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate) -+ return .carbsEdit(action) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ func expirationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification["carbs-edit"] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index 3362bf7..87f1e92 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -22,12 +22,22 @@ protocol RemoteNotification: Codable { - - func toRemoteAction() -> Action - func otpValidationRequired() -> Bool -+ func expirationRequired() -> Bool - func getReturnNotificationInfo() -> ReturnNotificationInfo? - - static func includedInNotification(_ notification: [String: Any]) -> Bool - } - -+/// Followers read this to know which remote commands this build accepts. -+enum RemoteNotificationCapabilities { -+ static let supported: [String] = ["carbs-delete", "carbs-edit"] -+} -+ - extension RemoteNotification { -+ -+ func expirationRequired() -> Bool { -+ return false -+ } - - var id: String { - //There is no unique identifier so we use the sent date when available -@@ -102,6 +112,10 @@ extension Dictionary { - func toRemoteNotification() throws -> RemoteNotification { - if BolusRemoteNotification.includedInNotification(self) { - return try BolusRemoteNotification(dictionary: self) -+ } else if CarbDeleteRemoteNotification.includedInNotification(self) { -+ return try CarbDeleteRemoteNotification(dictionary: self) -+ } else if CarbEditRemoteNotification.includedInNotification(self) { -+ return try CarbEditRemoteNotification(dictionary: self) - } else if CarbRemoteNotification.includedInNotification(self) { - return try CarbRemoteNotification(dictionary: self) - } else if OverrideRemoteNotification.includedInNotification(self) { -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -index 634eb4e..21d0e0a 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -@@ -16,6 +16,14 @@ struct RemoteCommandValidator { - } - - func validate(remoteNotification: RemoteNotification) throws { -+ if remoteNotification.expirationRequired() { -+ guard remoteNotification.expiration != nil else { -+ throw NotificationValidationError.missingExpiration -+ } -+ guard remoteNotification.sentAt != nil else { -+ throw NotificationValidationError.missingSentAt -+ } -+ } - try validateExpirationDate(remoteNotification: remoteNotification) - if remoteNotification.otpValidationRequired() { - try validateOTP(remoteNotification: remoteNotification) -@@ -45,11 +53,17 @@ struct RemoteCommandValidator { - enum NotificationValidationError: LocalizedError { - case missingOTP - case expiredNotification -+ case missingExpiration -+ case missingSentAt - - var errorDescription: String? { - switch self { - case .missingOTP: - return LocalizedString("Missing OTP", comment: "Remote command error description: Missing OTP.") -+ case .missingExpiration: -+ return LocalizedString("Missing expiration", comment: "Remote command error description: missing expiration.") -+ case .missingSentAt: -+ return LocalizedString("Missing sent-at time", comment: "Remote command error description: missing sent-at.") - case .expiredNotification: - return LocalizedString("Expired", comment: "Remote command error description: expired.") - } -diff --git a/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..6a3aba6 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift -@@ -0,0 +1,67 @@ -+// -+// CarbDeleteRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+@testable import NightscoutServiceKit -+ -+final class CarbDeleteRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseCarbDeleteNotification_ValidPayload_Succeeds() throws { -+ let syncIdentifier = "3F2504E0-4F89-11D3-9A0C-0305E82C3301" -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-delete": syncIdentifier as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ let remoteNotification = try notification.toRemoteNotification() -+ -+ let deleteNotification = try XCTUnwrap(remoteNotification as? CarbDeleteRemoteNotification) -+ XCTAssertEqual(deleteNotification.syncIdentifier, syncIdentifier) -+ XCTAssertEqual(deleteNotification.otp, "12345") -+ XCTAssertTrue(deleteNotification.otpValidationRequired()) -+ XCTAssertTrue(deleteNotification.expirationRequired()) -+ guard case .carbsDelete(let action) = deleteNotification.toRemoteAction() else { -+ return XCTFail("Expected carbsDelete action") -+ } -+ XCTAssertEqual(action.syncIdentifier, syncIdentifier) -+ } -+ -+ func testValidate_MissingExpiration_Throws() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "carbs-delete": "abc" as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ let remoteNotification = try notification.toRemoteNotification() -+ let validator = RemoteCommandValidator(otpManager: OTPTestCoordinator().createOTPManager()) -+ -+ XCTAssertThrowsError(try validator.validate(remoteNotification: remoteNotification)) { error in -+ guard case RemoteCommandValidator.NotificationValidationError.missingExpiration = error else { -+ return XCTFail("Unexpected error \(error)") -+ } -+ } -+ } -+ -+ func testValidate_MissingSentAt_Throws() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "expiration": "2099-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-delete": "abc" as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ let remoteNotification = try notification.toRemoteNotification() -+ let validator = RemoteCommandValidator(otpManager: OTPTestCoordinator().createOTPManager()) -+ -+ XCTAssertThrowsError(try validator.validate(remoteNotification: remoteNotification)) { error in -+ guard case RemoteCommandValidator.NotificationValidationError.missingSentAt = error else { -+ return XCTFail("Unexpected error \(error)") -+ } -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..989f7c8 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift -@@ -0,0 +1,108 @@ -+// -+// CarbEditRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+@testable import NightscoutServiceKit -+ -+final class CarbEditRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseCarbEditNotification_ValidPayload_Succeeds() throws { -+ let syncIdentifier = "3F2504E0-4F89-11D3-9A0C-0305E82C3301" -+ let startDateString = "2023-02-25T20:46:35.778Z" -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-edit": syncIdentifier as AnyObject, -+ "carbs-edit-entry": 22.0 as AnyObject, -+ "carbs-edit-absorption-time": 3.0 as AnyObject, -+ "carbs-edit-food-type": "🍕" as AnyObject, -+ "carbs-edit-start-time": startDateString as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ let remoteNotification = try notification.toRemoteNotification() -+ -+ let editNotification = try XCTUnwrap(remoteNotification as? CarbEditRemoteNotification) -+ XCTAssertEqual(editNotification.syncIdentifier, syncIdentifier) -+ XCTAssertEqual(editNotification.amount, 22.0) -+ XCTAssertEqual(editNotification.absorptionTime(), TimeInterval(hours: 3.0)) -+ XCTAssertEqual(editNotification.foodType, "🍕") -+ XCTAssertEqual(editNotification.startDate, dateFormatter().date(from: startDateString)) -+ XCTAssertTrue(editNotification.otpValidationRequired()) -+ XCTAssertTrue(editNotification.expirationRequired()) -+ guard case .carbsEdit(let action) = editNotification.toRemoteAction() else { -+ return XCTFail("Expected carbsEdit action") -+ } -+ XCTAssertEqual(action.syncIdentifier, syncIdentifier) -+ XCTAssertEqual(action.amountInGrams, 22.0) -+ XCTAssertEqual(action.absorptionTime, TimeInterval(hours: 3.0)) -+ XCTAssertEqual(action.startDate, dateFormatter().date(from: startDateString)) -+ } -+ -+ func testParseCarbEditNotification_FoodTypeAbsent_Succeeds() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-edit": "abc" as AnyObject, -+ "carbs-edit-entry": 10.0 as AnyObject, -+ "carbs-edit-absorption-time": 2.0 as AnyObject, -+ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ let editNotification = try XCTUnwrap(try notification.toRemoteNotification() as? CarbEditRemoteNotification) -+ XCTAssertNil(editNotification.foodType) -+ } -+ -+ func testParseCarbEditNotification_MissingStartTime_Throws() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-edit": "abc" as AnyObject, -+ "carbs-edit-entry": 10.0 as AnyObject, -+ "carbs-edit-absorption-time": 2.0 as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ XCTAssertThrowsError(try notification.toRemoteNotification()) -+ } -+ -+ func testParseCarbEditNotification_MissingAbsorptionTime_Throws() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-edit": "abc" as AnyObject, -+ "carbs-edit-entry": 10.0 as AnyObject, -+ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ XCTAssertThrowsError(try notification.toRemoteNotification()) -+ } -+ -+ func testParseCarbEditNotification_DoesNotDecodeAsCarbEntry() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "carbs-edit": "abc" as AnyObject, -+ "carbs-edit-entry": 10.0 as AnyObject, -+ "carbs-edit-absorption-time": 2.0 as AnyObject, -+ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ XCTAssertFalse(CarbRemoteNotification.includedInNotification(notification)) -+ XCTAssertTrue(CarbEditRemoteNotification.includedInNotification(notification)) -+ } -+ -+ func dateFormatter() -> ISO8601DateFormatter { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift b/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift -new file mode 100644 -index 0000000..e284d3f ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift -@@ -0,0 +1,21 @@ -+// -+// RemoteNotificationCapabilitiesTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+@testable import NightscoutServiceKit -+ -+final class RemoteNotificationCapabilitiesTestCase: XCTestCase { -+ -+ func testSupportedCommands_MatchNotificationKeys() { -+ XCTAssertEqual(RemoteNotificationCapabilities.supported, ["carbs-delete", "carbs-edit"]) -+ XCTAssertTrue(CarbDeleteRemoteNotification.includedInNotification(["carbs-delete": "id"])) -+ XCTAssertTrue(CarbEditRemoteNotification.includedInNotification(["carbs-edit": "id"])) -+ for key in RemoteNotificationCapabilities.supported { -+ let notification: [String: Any] = [key: "id"] -+ XCTAssertTrue(CarbDeleteRemoteNotification.includedInNotification(notification) -+ || CarbEditRemoteNotification.includedInNotification(notification), key) -+ } -+ } -+} -diff --git a/remote_carb_edit.loopcustomization b/remote_carb_edit.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/remote_carb_edit/dev_remote_carb_edit_profiles_remote_window.patch b/remote_carb_edit/dev_remote_carb_edit_profiles_remote_window.patch deleted file mode 100644 index 2750ebe..0000000 --- a/remote_carb_edit/dev_remote_carb_edit_profiles_remote_window.patch +++ /dev/null @@ -1,1073 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift -index 0a5c29eb..03ffe6c7 100644 ---- a/Loop/Loop/Managers/LoopDataManager.swift -+++ b/Loop/Loop/Managers/LoopDataManager.swift -@@ -2556,19 +2556,9 @@ extension LoopDataManager: ServicesManagerDelegate { - //Carb Entry - - func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry { -- - let absorptionTime = absorptionTime ?? carbStore.defaultAbsorptionTimes.medium -- if absorptionTime < LoopConstants.minCarbAbsorptionTime || absorptionTime > LoopConstants.maxCarbAbsorptionTime { -- throw CarbActionError.invalidAbsorptionTime(absorptionTime) -- } -- -- guard amountInGrams > 0.0 else { -- throw CarbActionError.invalidCarbs -- } -- -- guard amountInGrams <= LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram()) else { -- throw CarbActionError.exceedsMaxCarbs -- } -+ let startDate = startDate ?? Date() -+ try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) - - let maxAllowedConflictingAmountInGrams = 0.0 // Not allowing any carbs - let minTreatmentIntervalInMinutes = 10.0 -@@ -2583,19 +2573,80 @@ extension LoopDataManager: ServicesManagerDelegate { - throw CarbActionError.conflictingTreatments(conflictingCarbAmount, minTreatmentIntervalInMinutes) - } - -- if let startDate = startDate { -- let maxStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) -- let minStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryPastTime) -- guard startDate <= maxStartDate && startDate >= minStartDate else { -- throw CarbActionError.invalidStartDate(startDate) -- } -- } -- - let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) -- let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate ?? Date(), foodType: foodType, absorptionTime: absorptionTime) -+ let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate, foodType: foodType, absorptionTime: absorptionTime) - - return try await deliverCarbEntry(candidateCarbEntry) - } -+ -+ /// Remote edits and deletes may only target entries this app created within the last 24 hours. -+ static let remoteCarbEditWindow: TimeInterval = .hours(24) -+ -+ func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry { -+ let entry = try await remoteEditableCarbEntry(syncIdentifier: syncIdentifier) -+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in -+ deleteCarbEntry(entry) { result in -+ switch result { -+ case .success: -+ continuation.resume() -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ return entry -+ } -+ -+ func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry { -+ let existing = try await remoteEditableCarbEntry(syncIdentifier: syncIdentifier) -+ try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) -+ -+ let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) -+ let replacement = NewCarbEntry(quantity: quantity, startDate: startDate, foodType: foodType ?? existing.foodType, absorptionTime: absorptionTime) -+ -+ return try await withCheckedThrowingContinuation { continuation in -+ addCarbEntry(replacement, replacing: existing) { result in -+ switch result { -+ case .success(let storedCarbEntry): -+ continuation.resume(returning: storedCarbEntry) -+ case .failure(let error): -+ continuation.resume(throwing: error) -+ } -+ } -+ } -+ } -+ -+ private func validateRemoteCarbParameters(amountInGrams: Double, absorptionTime: TimeInterval, startDate: Date) throws { -+ if absorptionTime < LoopConstants.minCarbAbsorptionTime || absorptionTime > LoopConstants.maxCarbAbsorptionTime { -+ throw CarbActionError.invalidAbsorptionTime(absorptionTime) -+ } -+ -+ guard amountInGrams > 0.0 else { -+ throw CarbActionError.invalidCarbs -+ } -+ -+ guard amountInGrams <= LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram()) else { -+ throw CarbActionError.exceedsMaxCarbs -+ } -+ -+ let maxStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) -+ let minStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryPastTime) -+ guard startDate <= maxStartDate && startDate >= minStartDate else { -+ throw CarbActionError.invalidStartDate(startDate) -+ } -+ } -+ -+ private func remoteEditableCarbEntry(syncIdentifier: String) async throws -> StoredCarbEntry { -+ let now = Date() -+ let entries = try await carbStore.getCarbEntries(start: now.addingTimeInterval(-Self.remoteCarbEditWindow), end: now.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime)) -+ guard let entry = entries.first(where: { $0.syncIdentifier == syncIdentifier }) else { -+ throw CarbActionError.entryNotFound -+ } -+ guard entry.createdByCurrentApp else { -+ throw CarbActionError.entryNotEditable -+ } -+ return entry -+ } - - enum CarbActionError: LocalizedError { - -@@ -2604,6 +2655,8 @@ extension LoopDataManager: ServicesManagerDelegate { - case exceedsMaxCarbs - case invalidCarbs - case conflictingTreatments(_ conflictingAmountInGrams: Double, _ minutesRequiredBetweenTreatments: TimeInterval) -+ case entryNotFound -+ case entryNotEditable - - var errorDescription: String? { - switch self { -@@ -2611,6 +2664,10 @@ extension LoopDataManager: ServicesManagerDelegate { - return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Carb error description: carbs exceed maximum amount.") - case .invalidCarbs: - return NSLocalizedString("Invalid carb amount", comment: "Carb error description: invalid carb amount.") -+ case .entryNotFound: -+ return NSLocalizedString("Carb entry not found or older than 24 hours", comment: "Carb error description: remote edit target not found.") -+ case .entryNotEditable: -+ return NSLocalizedString("Carb entry was not created by Loop", comment: "Carb error description: remote edit target not created by this app.") - case .invalidAbsorptionTime(let absorptionTime): - let absorptionHoursFormatted = Self.numberFormatter.string(from: absorptionTime.hours) ?? "" - return String(format: NSLocalizedString("Invalid absorption time: %1$@ hours", comment: "Carb error description: invalid absorption time. (1: Input duration in hours)."), absorptionHoursFormatted) -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index 17cff75d..6cefc092 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -201,6 +201,36 @@ extension NotificationManager { - UNUserNotificationCenter.current().add(request) - } - -+ @MainActor -+ static func sendRemoteCarbChangeNotification(body: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [body, "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: LoopNotificationCategory.remoteCarbs.rawValue, -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ @MainActor -+ static func sendRemoteCarbChangeFailureNotification(body: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [body, error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: LoopNotificationCategory.remoteCarbsFailure.rawValue, -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ - static func sendMissedMealNotification(mealStart: Date, amountInGrams: Double, delay: TimeInterval? = nil) { - let notification = UNMutableNotificationContent() - /// Notifications should expire after the missed meal is no longer relevant -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index a9d9177d..d804456f 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -240,6 +240,8 @@ public protocol ServicesManagerDelegate: AnyObject { - func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws -> TemporaryScheduleOverride - func cancelCurrentOverride() async throws -> TemporaryScheduleOverride - func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry -+ func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry -+ func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry - } - - // MARK: - StatefulPluggableDelegate -@@ -358,6 +360,39 @@ extension ServicesManager: ServiceDelegate { - } - } - -+ func deleteRemoteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry { -+ do { -+ guard let servicesManagerDelegate else { -+ throw CarbActionError.internalError -+ } -+ let deleted = try await servicesManagerDelegate.deleteCarbs(syncIdentifier: syncIdentifier) -+ let body = String(format: NSLocalizedString("Remote Carbs Delete: %d grams removed", comment: "The notification body for a successful remote carb delete. (1: Carb amount in grams)"), Int(deleted.quantity.doubleValue(for: .gram()))) -+ await NotificationManager.sendRemoteCarbChangeNotification(body: body) -+ await remoteDataServicesManager.triggerUpload(for: .carb) -+ return deleted -+ } catch { -+ let body = NSLocalizedString("Remote Carbs Delete", comment: "The notification body prefix for a failed remote carb delete") -+ await NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) -+ throw error -+ } -+ } -+ -+ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry { -+ let body = String(format: NSLocalizedString("Remote Carbs Edit: %d grams", comment: "The carb amount message for a remote carb edit notification. (1: Carb amount in grams)"), Int(amountInGrams)) -+ do { -+ guard let servicesManagerDelegate else { -+ throw CarbActionError.internalError -+ } -+ let replacement = try await servicesManagerDelegate.replaceCarbs(syncIdentifier: syncIdentifier, amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) -+ await NotificationManager.sendRemoteCarbChangeNotification(body: body) -+ await remoteDataServicesManager.triggerUpload(for: .carb) -+ return replacement -+ } catch { -+ await NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) -+ throw error -+ } -+ } -+ - func deliverRemoteBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry { - do { - guard let servicesManagerDosingDelegate else { -diff --git a/Loop/LoopTests/Mock Stores/MockCarbStore.swift b/Loop/LoopTests/Mock Stores/MockCarbStore.swift -index 4a5c016e..dd35a787 100644 ---- a/Loop/LoopTests/Mock Stores/MockCarbStore.swift -+++ b/Loop/LoopTests/Mock Stores/MockCarbStore.swift -@@ -12,6 +12,7 @@ import LoopKit - - class MockCarbStore: CarbStoreProtocol { - var carbHistory: [StoredCarbEntry]? -+ var carbEntries: [StoredCarbEntry] = [] - - init(for scenario: DosingTestScenario = .flatAndStable) { - self.scenario = scenario // The store returns different effect values based on the scenario -@@ -72,6 +73,10 @@ class MockCarbStore: CarbStoreProtocol { - completion(.failure(.notConfigured)) - } - -+ func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] { -+ return carbEntries -+ } -+ - func getCarbStatus(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult<[CarbStatus]>) -> Void) { - completion(.failure(.notConfigured)) - } -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 6f691598..858a5621 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -19,4 +19,6 @@ public protocol RemoteActionDelegate: AnyObject { - func cancelRemoteOverride() async throws -> TemporaryScheduleOverride - func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry - func deliverRemoteBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry -+ func deleteRemoteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry -+ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry - } -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index 14a7691..18143ab 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -32,12 +32,17 @@ - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; - A941B09029BD4A9E00F91340 /* CarbRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */; }; -+ FB42DD0865235D441918DA5E /* CarbEditRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */; }; -+ A2C1A69F3A61FD4E207637A7 /* CarbDeleteRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */; }; - A941B09129BD4A9E00F91340 /* BolusRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */; }; - A941B09229BD4A9F00F91340 /* OverrideCancelRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */; }; - A941B09329BD4A9F00F91340 /* RemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */; }; - A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */; }; - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */; }; - A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */; }; -+ AFFFFDFBBBA4F7DF3997E63E /* CarbEditRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */; }; -+ 73056A2FC515352D51EFFECF /* CarbDeleteRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */; }; -+ 5C1D2E3F4A5B6C7D8E9F0A1B /* RemoteNotificationCapabilitiesTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */; }; - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */; }; - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */; }; - A94DA7642353CF930082E26D /* StoredGlucoseSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */; }; -@@ -51,6 +56,8 @@ - A9AA6E5B29EB072E008FFA78 /* OverrideAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */; }; - A9AA6E6329EB07D7008FFA78 /* Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5D29EB07D6008FFA78 /* Action.swift */; }; - A9AA6E6429EB07D7008FFA78 /* CarbAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */; }; -+ 843DF14B73EC57EB6F0033DA /* CarbEditAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04073CA740189C4259808331 /* CarbEditAction.swift */; }; -+ 7E7294EAE3456F0C6DC10F37 /* CarbDeleteAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */; }; - A9AA6E6529EB07D7008FFA78 /* OverrideCancelAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */; }; - A9AA6E6629EB07D7008FFA78 /* BolusAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */; }; - A9B007B322BD6FF7000131DE /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B007B222BD6FF7000131DE /* LocalizedString.swift */; }; -@@ -170,12 +177,17 @@ - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; - A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbRemoteNotification.swift; sourceTree = ""; }; -+ 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditRemoteNotification.swift; sourceTree = ""; }; -+ A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteRemoteNotification.swift; sourceTree = ""; }; - A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRemoteNotification.swift; sourceTree = ""; }; - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotification.swift; sourceTree = ""; }; - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotification.swift; sourceTree = ""; }; - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideRemoteNotification.swift; sourceTree = ""; }; - A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRemoteNotificationTestCase.swift; sourceTree = ""; }; - A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbRemoteNotificationTestCase.swift; sourceTree = ""; }; -+ E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditRemoteNotificationTestCase.swift; sourceTree = ""; }; -+ 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteRemoteNotificationTestCase.swift; sourceTree = ""; }; -+ 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotificationCapabilitiesTestCase.swift; sourceTree = ""; }; - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotificationTestCase.swift; sourceTree = ""; }; - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideRemoteNotificationTestCase.swift; sourceTree = ""; }; - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredGlucoseSample.swift; sourceTree = ""; }; -@@ -191,6 +203,8 @@ - A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideAction.swift; sourceTree = ""; }; - A9AA6E5D29EB07D6008FFA78 /* Action.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbAction.swift; sourceTree = ""; }; -+ 04073CA740189C4259808331 /* CarbEditAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditAction.swift; sourceTree = ""; }; -+ 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteAction.swift; sourceTree = ""; }; - A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelAction.swift; sourceTree = ""; }; - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusAction.swift; sourceTree = ""; }; - A9B007B222BD6FF7000131DE /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; -@@ -330,6 +344,9 @@ - A91BAC2B22BC691A00ABF1BB /* Info.plist */, - A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */, - A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */, -+ E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */, -+ 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */, -+ 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */, - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -@@ -371,6 +388,8 @@ - children = ( - A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */, - A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */, -+ 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */, -+ A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */, - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -@@ -427,6 +446,8 @@ - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -+ 04073CA740189C4259808331 /* CarbEditAction.swift */, -+ 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */, - A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */, - A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */, - ); -@@ -728,12 +749,16 @@ - A993990322BC7095005E39D3 /* NightscoutService.swift in Sources */, - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */, - A9AA6E6429EB07D7008FFA78 /* CarbAction.swift in Sources */, -+ 843DF14B73EC57EB6F0033DA /* CarbEditAction.swift in Sources */, -+ 7E7294EAE3456F0C6DC10F37 /* CarbDeleteAction.swift in Sources */, - A90E39A922BC7AD10016DFE8 /* Bundle.swift in Sources */, - A90E39A322BC782C0016DFE8 /* SyncCarbObject.swift in Sources */, - A941B09329BD4A9F00F91340 /* RemoteNotification.swift in Sources */, - A9AA6E6629EB07D7008FFA78 /* BolusAction.swift in Sources */, - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */, - A941B09029BD4A9E00F91340 /* CarbRemoteNotification.swift in Sources */, -+ FB42DD0865235D441918DA5E /* CarbEditRemoteNotification.swift in Sources */, -+ A2C1A69F3A61FD4E207637A7 /* CarbDeleteRemoteNotification.swift in Sources */, - A94DA7642353CF930082E26D /* StoredGlucoseSample.swift in Sources */, - A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */, - C1E703642506BE9400DAB534 /* ObjectIdCache.swift in Sources */, -@@ -769,6 +794,9 @@ - buildActionMask = 2147483647; - files = ( - A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */, -+ AFFFFDFBBBA4F7DF3997E63E /* CarbEditRemoteNotificationTestCase.swift in Sources */, -+ 73056A2FC515352D51EFFECF /* CarbDeleteRemoteNotificationTestCase.swift in Sources */, -+ 5C1D2E3F4A5B6C7D8E9F0A1B /* RemoteNotificationCapabilitiesTestCase.swift in Sources */, - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index b66fb97..7c780ea 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -160,7 +160,7 @@ extension StoredDosingDecision { - recommendedBolus: loopStatusRecommendedBolus, - enacted: automaticDoseDecision?.loopStatusEnacted, - failureReason: automaticDoseDecision?.loopStatusFailureReason, -- testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]]), -+ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]]), - overrideStatus: overrideStatus) - } - -diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index 7adb37d..a968a64 100644 ---- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift -+++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -24,7 +24,7 @@ public final class NightscoutService: Service { - - public static let localizedTitle = LocalizedString("Nightscout", comment: "The title of the Nightscout service") - -- public let objectIdCacheKeepTime = TimeInterval(24 * 60 * 60) -+ public let objectIdCacheKeepTime = TimeInterval(48 * 60 * 60) - - public weak var serviceDelegate: ServiceDelegate? - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index 29313b6..29d3563 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -14,6 +14,8 @@ public enum Action: Codable { - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) - case carbsEntry(CarbAction) -+ case carbsDelete(CarbDeleteAction) -+ case carbsEdit(CarbEditAction) - - public var description: String { - return "\(actionName) \(actionParameterDescription)" -@@ -23,6 +25,10 @@ public enum Action: Codable { - switch self { - case .carbsEntry: - return LocalizedString("Carb Entry", comment: "The remote action name for Carb Entry") -+ case .carbsDelete: -+ return LocalizedString("Delete Carbs", comment: "The remote action name for deleting a carb entry") -+ case .carbsEdit: -+ return LocalizedString("Edit Carbs", comment: "The remote action name for editing a carb entry") - case .bolusEntry: - return LocalizedString("Bolus Entry", comment: "The remote action name for Bolus Entry") - case .cancelTemporaryOverride: -@@ -39,6 +45,11 @@ public enum Action: Codable { - case .carbsEntry(let carbAction): - let amountFormatted = Self.numberFormatter.string(from: carbAction.amountInGrams as NSNumber) ?? "" - return "\(amountFormatted)" + " " + carbGramAbbreviation -+ case .carbsDelete(let deleteAction): -+ return String(deleteAction.syncIdentifier.prefix(8)) -+ case .carbsEdit(let editAction): -+ let amountFormatted = Self.numberFormatter.string(from: editAction.amountInGrams as NSNumber) ?? "" -+ return "\(amountFormatted)" + " " + carbGramAbbreviation - case .bolusEntry(let bolusAction): - let amountFormatted = Self.numberFormatter.string(from: bolusAction.amountInUnits as NSNumber) ?? "" - return "\(amountFormatted)" + " " + bolusUnitAbbreviation -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift -new file mode 100644 -index 0000000..77e9900 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift -@@ -0,0 +1,15 @@ -+// -+// CarbDeleteAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct CarbDeleteAction: Codable { -+ -+ public let syncIdentifier: String -+ -+ public init(syncIdentifier: String) { -+ self.syncIdentifier = syncIdentifier -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift -new file mode 100644 -index 0000000..0aea20b ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift -@@ -0,0 +1,23 @@ -+// -+// CarbEditAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct CarbEditAction: Codable { -+ -+ public let syncIdentifier: String -+ public let amountInGrams: Double -+ public let absorptionTime: TimeInterval -+ public let foodType: String? -+ public let startDate: Date -+ -+ public init(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String? = nil, startDate: Date) { -+ self.syncIdentifier = syncIdentifier -+ self.amountInGrams = amountInGrams -+ self.absorptionTime = absorptionTime -+ self.foodType = foodType -+ self.startDate = startDate -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 4457f80..aabb47f 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -21,12 +21,14 @@ class RemoteNotificationResponseManager { - let commandStatus: String - let commandType: String - let timestamp: TimeInterval -+ let syncIdentifier: String? - - enum CodingKeys: String, CodingKey { - case aps - case commandStatus = "command_status" - case commandType = "command_type" - case timestamp -+ case syncIdentifier = "sync_identifier" - } - } - -@@ -43,6 +45,8 @@ class RemoteNotificationResponseManager { - enum CommandType: String { - case bolus = "bolus" - case carbs = "carbs" -+ case carbsDelete = "carbs_delete" -+ case carbsEdit = "carbs_edit" - case override = "override" - case cancelOverride = "cancel_override" - case profileSwitch = "profile_switch" -@@ -52,7 +56,8 @@ class RemoteNotificationResponseManager { - to returnInfo: ReturnNotificationInfo?, - commandType: CommandType, - success: Bool, -- message: String -+ message: String, -+ syncIdentifier: String? = nil - ) async { - guard let returnInfo = returnInfo else { - os_log("No return notification info provided, skipping response", log: log, type: .info) -@@ -75,7 +80,8 @@ class RemoteNotificationResponseManager { - ), - commandStatus: success ? "success" : "failed", - commandType: commandType.rawValue, -- timestamp: Date().timeIntervalSince1970 -+ timestamp: Date().timeIntervalSince1970, -+ syncIdentifier: syncIdentifier - ) - - await sendPushNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift -new file mode 100644 -index 0000000..eda9134 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift -@@ -0,0 +1,44 @@ -+// -+// CarbDeleteRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+public struct CarbDeleteRemoteNotification: RemoteNotification, Codable { -+ -+ public let syncIdentifier: String -+ public let remoteAddress: String -+ public let expiration: Date? -+ public let sentAt: Date -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ enum CodingKeys: String, CodingKey { -+ case syncIdentifier = "carbs-delete" -+ case remoteAddress = "remote-address" -+ case expiration = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .carbsDelete(CarbDeleteAction(syncIdentifier: syncIdentifier)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ func expirationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification["carbs-delete"] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift -new file mode 100644 -index 0000000..f1ffb51 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift -@@ -0,0 +1,58 @@ -+// -+// CarbEditRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Replacement values use prefixed keys so an unpatched Loop never reads them as a new carb entry. -+public struct CarbEditRemoteNotification: RemoteNotification, Codable { -+ -+ public let syncIdentifier: String -+ public let amount: Double -+ public let absorptionInHours: Double -+ public let foodType: String? -+ public let startDate: Date -+ public let remoteAddress: String -+ public let expiration: Date? -+ public let sentAt: Date -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ enum CodingKeys: String, CodingKey { -+ case syncIdentifier = "carbs-edit" -+ case amount = "carbs-edit-entry" -+ case absorptionInHours = "carbs-edit-absorption-time" -+ case foodType = "carbs-edit-food-type" -+ case startDate = "carbs-edit-start-time" -+ case remoteAddress = "remote-address" -+ case expiration = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ public func absorptionTime() -> TimeInterval { -+ return TimeInterval(hours: absorptionInHours) -+ } -+ -+ func toRemoteAction() -> Action { -+ let action = CarbEditAction(syncIdentifier: syncIdentifier, amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate) -+ return .carbsEdit(action) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ func expirationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification["carbs-edit"] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index a0f58a0..1ea8b96 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -22,12 +22,22 @@ protocol RemoteNotification: Codable { - - func toRemoteAction() -> Action - func otpValidationRequired() -> Bool -+ func expirationRequired() -> Bool - func getReturnNotificationInfo() -> ReturnNotificationInfo? - - static func includedInNotification(_ notification: [String: Any]) -> Bool - } - -+/// Followers read this to know which remote commands this build accepts. -+enum RemoteNotificationCapabilities { -+ static let supported: [String] = ["carbs-delete", "carbs-edit"] -+} -+ - extension RemoteNotification { -+ -+ func expirationRequired() -> Bool { -+ return false -+ } - - var id: String { - //There is no unique identifier so we use the sent date -@@ -98,6 +108,10 @@ extension Dictionary { - func toRemoteNotification() throws -> RemoteNotification { - if BolusRemoteNotification.includedInNotification(self) { - return try BolusRemoteNotification(dictionary: self) -+ } else if CarbDeleteRemoteNotification.includedInNotification(self) { -+ return try CarbDeleteRemoteNotification(dictionary: self) -+ } else if CarbEditRemoteNotification.includedInNotification(self) { -+ return try CarbEditRemoteNotification(dictionary: self) - } else if CarbRemoteNotification.includedInNotification(self) { - return try CarbRemoteNotification(dictionary: self) - } else if OverrideRemoteNotification.includedInNotification(self) { -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -index 0912feb..bff69c8 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -@@ -46,6 +46,7 @@ class RemoteCommandSourceV1: RemoteCommandSource { - - var commandType: RemoteNotificationResponseManager.CommandType = .bolus - var successMessage = "" -+ var syncIdentifier: String? = nil - - do { - try await recentNotifications.trackReceivedRemoteNotification(remoteNotification, rawNotification: notification) -@@ -96,6 +97,32 @@ class RemoteCommandSourceV1: RemoteCommandSource { - status: .success(date: Date(), syncIdentifier: carbEntry.syncIdentifier ?? "", completionMessage: nil), - notificationJSON: notification - ) -+ case .carbsDelete(let deleteCommand): -+ commandType = .carbsDelete -+ syncIdentifier = deleteCommand.syncIdentifier -+ let deletedEntry = try await serviceDelegate.deleteRemoteCarbs(syncIdentifier: deleteCommand.syncIdentifier) -+ successMessage = "Carb entry deleted successfully" -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .success(date: Date(), syncIdentifier: deletedEntry.syncIdentifier ?? "", completionMessage: nil), -+ notificationJSON: notification -+ ) -+ case .carbsEdit(let editCommand): -+ commandType = .carbsEdit -+ syncIdentifier = editCommand.syncIdentifier -+ let replacementEntry = try await serviceDelegate.replaceRemoteCarbs( -+ syncIdentifier: editCommand.syncIdentifier, -+ amountInGrams: editCommand.amountInGrams, -+ absorptionTime: editCommand.absorptionTime, -+ foodType: editCommand.foodType, -+ startDate: editCommand.startDate -+ ) -+ successMessage = String(format: "Carb entry updated to %.1f g", editCommand.amountInGrams) -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .success(date: Date(), syncIdentifier: replacementEntry.syncIdentifier ?? "", completionMessage: nil), -+ notificationJSON: notification -+ ) - case .profileSwitch(let profileSwitchCommand): - commandType = .profileSwitch - guard let profileDelegate = serviceDelegate as? RemoteProfileActionDelegate else { -@@ -138,7 +165,8 @@ class RemoteCommandSourceV1: RemoteCommandSource { - to: returnInfo, - commandType: commandType, - success: true, -- message: successMessage -+ message: successMessage, -+ syncIdentifier: syncIdentifier - ) - } - } catch { -@@ -155,7 +183,8 @@ class RemoteCommandSourceV1: RemoteCommandSource { - to: returnInfo, - commandType: commandType, - success: false, -- message: "Command failed: \(error.localizedDescription)" -+ message: "Command failed: \(error.localizedDescription)", -+ syncIdentifier: syncIdentifier - ) - } - } -@@ -244,6 +273,14 @@ public class StoredRemoteNotification: NSObject, Codable { - self.init(notificationType: .carbs(carbNotification), notificationJSONData: notificationJSONData) - } - -+ convenience init(carbDeleteNotification: CarbDeleteRemoteNotification, notificationJSONData: Data) { -+ self.init(notificationType: .carbsDelete(carbDeleteNotification), notificationJSONData: notificationJSONData) -+ } -+ -+ convenience init(carbEditNotification: CarbEditRemoteNotification, notificationJSONData: Data) { -+ self.init(notificationType: .carbsEdit(carbEditNotification), notificationJSONData: notificationJSONData) -+ } -+ - convenience init(profileSwitchNotification: ProfileSwitchRemoteNotification, notificationJSONData: Data) { - self.init(notificationType: .profileSwitch(profileSwitchNotification), notificationJSONData: notificationJSONData) - } -@@ -331,6 +368,10 @@ public class StoredRemoteNotification: NSObject, Codable { - return bolusNotification - case let .carbs(carbNotification): - return carbNotification -+ case let .carbsDelete(carbDeleteNotification): -+ return carbDeleteNotification -+ case let .carbsEdit(carbEditNotification): -+ return carbEditNotification - case let .profileSwitch(profileSwitchNotification): - return profileSwitchNotification - case let .override(overrideNotification): -@@ -359,6 +400,8 @@ public class StoredRemoteNotification: NSObject, Codable { - public enum RemoteNotificationType: Codable { - case bolus(BolusRemoteNotification) - case carbs(CarbRemoteNotification) -+ case carbsDelete(CarbDeleteRemoteNotification) -+ case carbsEdit(CarbEditRemoteNotification) - case override(OverrideRemoteNotification) - case overrideCancel(OverrideCancelRemoteNotification) - case profileSwitch(ProfileSwitchRemoteNotification) -@@ -412,6 +455,12 @@ actor RecentNotifications { - } else if let carbNotification = remoteNotification as? CarbRemoteNotification { - let storedNotification = StoredRemoteNotification(carbNotification: carbNotification, notificationJSONData: data) - try storeNotification(storedNotification) -+ } else if let carbDeleteNotification = remoteNotification as? CarbDeleteRemoteNotification { -+ let storedNotification = StoredRemoteNotification(carbDeleteNotification: carbDeleteNotification, notificationJSONData: data) -+ try storeNotification(storedNotification) -+ } else if let carbEditNotification = remoteNotification as? CarbEditRemoteNotification { -+ let storedNotification = StoredRemoteNotification(carbEditNotification: carbEditNotification, notificationJSONData: data) -+ try storeNotification(storedNotification) - } else if let profileSwitchNotification = remoteNotification as? ProfileSwitchRemoteNotification { - let storedNotification = StoredRemoteNotification(profileSwitchNotification: profileSwitchNotification, notificationJSONData: data) - try storeNotification(storedNotification) -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -index fce4623..5be6e1c 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -@@ -16,6 +16,11 @@ struct RemoteCommandValidator { - } - - func validate(remoteNotification: RemoteNotification) throws { -+ if remoteNotification.expirationRequired() { -+ guard remoteNotification.expiration != nil else { -+ throw NotificationValidationError.missingExpiration -+ } -+ } - try validateExpirationDate(remoteNotification: remoteNotification) - if remoteNotification.otpValidationRequired() { - try validateOTP(remoteNotification: remoteNotification) -@@ -45,11 +50,14 @@ struct RemoteCommandValidator { - enum NotificationValidationError: LocalizedError { - case missingOTP - case expiredNotification(sentDate: Date, receivedDate: Date) -+ case missingExpiration - - var errorDescription: String? { - switch self { - case .missingOTP: - return LocalizedString("Missing OTP", comment: "Remote command error description: Missing OTP.") -+ case .missingExpiration: -+ return LocalizedString("Missing expiration", comment: "Remote command error description: missing expiration.") - case .expiredNotification(let sentDate, let receivedDate): - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "h:mm:ss a" -diff --git a/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..f03768c ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift -@@ -0,0 +1,61 @@ -+// -+// CarbDeleteRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+@testable import NightscoutServiceKit -+ -+final class CarbDeleteRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseCarbDeleteNotification_ValidPayload_Succeeds() throws { -+ let syncIdentifier = "3F2504E0-4F89-11D3-9A0C-0305E82C3301" -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-delete": syncIdentifier as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ let remoteNotification = try notification.toRemoteNotification() -+ -+ let deleteNotification = try XCTUnwrap(remoteNotification as? CarbDeleteRemoteNotification) -+ XCTAssertEqual(deleteNotification.syncIdentifier, syncIdentifier) -+ XCTAssertEqual(deleteNotification.otp, "12345") -+ XCTAssertTrue(deleteNotification.otpValidationRequired()) -+ XCTAssertTrue(deleteNotification.expirationRequired()) -+ guard case .carbsDelete(let action) = deleteNotification.toRemoteAction() else { -+ return XCTFail("Expected carbsDelete action") -+ } -+ XCTAssertEqual(action.syncIdentifier, syncIdentifier) -+ } -+ -+ func testValidate_MissingExpiration_Throws() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "carbs-delete": "abc" as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ let remoteNotification = try notification.toRemoteNotification() -+ let validator = RemoteCommandValidator(otpManager: OTPTestCoordinator().createOTPManager()) -+ -+ XCTAssertThrowsError(try validator.validate(remoteNotification: remoteNotification)) { error in -+ guard case RemoteCommandValidator.NotificationValidationError.missingExpiration = error else { -+ return XCTFail("Unexpected error \(error)") -+ } -+ } -+ } -+ -+ func testParseCarbDeleteNotification_MissingSentAt_Throws() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "expiration": "2099-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-delete": "abc" as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ XCTAssertThrowsError(try notification.toRemoteNotification()) -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..989f7c8 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift -@@ -0,0 +1,108 @@ -+// -+// CarbEditRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+@testable import NightscoutServiceKit -+ -+final class CarbEditRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseCarbEditNotification_ValidPayload_Succeeds() throws { -+ let syncIdentifier = "3F2504E0-4F89-11D3-9A0C-0305E82C3301" -+ let startDateString = "2023-02-25T20:46:35.778Z" -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-edit": syncIdentifier as AnyObject, -+ "carbs-edit-entry": 22.0 as AnyObject, -+ "carbs-edit-absorption-time": 3.0 as AnyObject, -+ "carbs-edit-food-type": "🍕" as AnyObject, -+ "carbs-edit-start-time": startDateString as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ let remoteNotification = try notification.toRemoteNotification() -+ -+ let editNotification = try XCTUnwrap(remoteNotification as? CarbEditRemoteNotification) -+ XCTAssertEqual(editNotification.syncIdentifier, syncIdentifier) -+ XCTAssertEqual(editNotification.amount, 22.0) -+ XCTAssertEqual(editNotification.absorptionTime(), TimeInterval(hours: 3.0)) -+ XCTAssertEqual(editNotification.foodType, "🍕") -+ XCTAssertEqual(editNotification.startDate, dateFormatter().date(from: startDateString)) -+ XCTAssertTrue(editNotification.otpValidationRequired()) -+ XCTAssertTrue(editNotification.expirationRequired()) -+ guard case .carbsEdit(let action) = editNotification.toRemoteAction() else { -+ return XCTFail("Expected carbsEdit action") -+ } -+ XCTAssertEqual(action.syncIdentifier, syncIdentifier) -+ XCTAssertEqual(action.amountInGrams, 22.0) -+ XCTAssertEqual(action.absorptionTime, TimeInterval(hours: 3.0)) -+ XCTAssertEqual(action.startDate, dateFormatter().date(from: startDateString)) -+ } -+ -+ func testParseCarbEditNotification_FoodTypeAbsent_Succeeds() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-edit": "abc" as AnyObject, -+ "carbs-edit-entry": 10.0 as AnyObject, -+ "carbs-edit-absorption-time": 2.0 as AnyObject, -+ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ let editNotification = try XCTUnwrap(try notification.toRemoteNotification() as? CarbEditRemoteNotification) -+ XCTAssertNil(editNotification.foodType) -+ } -+ -+ func testParseCarbEditNotification_MissingStartTime_Throws() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-edit": "abc" as AnyObject, -+ "carbs-edit-entry": 10.0 as AnyObject, -+ "carbs-edit-absorption-time": 2.0 as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ XCTAssertThrowsError(try notification.toRemoteNotification()) -+ } -+ -+ func testParseCarbEditNotification_MissingAbsorptionTime_Throws() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-edit": "abc" as AnyObject, -+ "carbs-edit-entry": 10.0 as AnyObject, -+ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ XCTAssertThrowsError(try notification.toRemoteNotification()) -+ } -+ -+ func testParseCarbEditNotification_DoesNotDecodeAsCarbEntry() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "carbs-edit": "abc" as AnyObject, -+ "carbs-edit-entry": 10.0 as AnyObject, -+ "carbs-edit-absorption-time": 2.0 as AnyObject, -+ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ XCTAssertFalse(CarbRemoteNotification.includedInNotification(notification)) -+ XCTAssertTrue(CarbEditRemoteNotification.includedInNotification(notification)) -+ } -+ -+ func dateFormatter() -> ISO8601DateFormatter { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift b/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift -new file mode 100644 -index 0000000..e284d3f ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift -@@ -0,0 +1,21 @@ -+// -+// RemoteNotificationCapabilitiesTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+@testable import NightscoutServiceKit -+ -+final class RemoteNotificationCapabilitiesTestCase: XCTestCase { -+ -+ func testSupportedCommands_MatchNotificationKeys() { -+ XCTAssertEqual(RemoteNotificationCapabilities.supported, ["carbs-delete", "carbs-edit"]) -+ XCTAssertTrue(CarbDeleteRemoteNotification.includedInNotification(["carbs-delete": "id"])) -+ XCTAssertTrue(CarbEditRemoteNotification.includedInNotification(["carbs-edit": "id"])) -+ for key in RemoteNotificationCapabilities.supported { -+ let notification: [String: Any] = [key: "id"] -+ XCTAssertTrue(CarbDeleteRemoteNotification.includedInNotification(notification) -+ || CarbEditRemoteNotification.includedInNotification(notification), key) -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -index 4ce6ab7..bb9423b 100644 ---- a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -+++ b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -@@ -54,6 +54,10 @@ extension StoredRemoteNotification { - return "Bolus" - case .carbs: - return "Carbs" -+ case .carbsDelete: -+ return "Delete Carbs" -+ case .carbsEdit: -+ return "Edit Carbs" - case .override: - return "Override" - case .profileSwitch: -@@ -76,6 +80,10 @@ extension StoredRemoteNotification { - return String(format: "%.2f U", notification.amount) - case .carbs(let notification): - return String(format: "%.0f g", notification.amount) -+ case .carbsDelete(let notification): -+ return String(notification.syncIdentifier.prefix(8)) -+ case .carbsEdit(let notification): -+ return String(format: "%.0f g", notification.amount) - case .override(let notification): - return notification.name - case .profileSwitch(let notification): -diff --git a/remote_carb_edit.loopcustomization b/remote_carb_edit.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/remote_carb_edit/nextdev_remote_carb_edit_profiles.patch b/remote_carb_edit/nextdev_remote_carb_edit_profiles.patch deleted file mode 100644 index ae1f810..0000000 --- a/remote_carb_edit/nextdev_remote_carb_edit_profiles.patch +++ /dev/null @@ -1,953 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift -index 28f07307..61cdb5c0 100644 ---- a/Loop/Loop/Managers/LoopDataManager.swift -+++ b/Loop/Loop/Managers/LoopDataManager.swift -@@ -1416,32 +1416,65 @@ extension LoopDataManager: ServicesManagerDelegate { - //Carb Entry - - func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { -- - let absorptionTime = absorptionTime ?? LoopCoreConstants.defaultCarbAbsorptionTimes.medium -+ let startDate = startDate ?? now -+ try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) -+ -+ let quantity = LoopQuantity(unit: .gram, doubleValue: amountInGrams) -+ let candidateCarbEntry = NewCarbEntry(syncIdentifier: UUID().uuidString, quantity: quantity, startDate: startDate, foodType: foodType, absorptionTime: absorptionTime) -+ -+ let _ = try await carbStore.addCarbEntry(candidateCarbEntry) -+ } -+ -+ /// Remote edits and deletes may only target entries this app created within the last 24 hours. -+ static let remoteCarbEditWindow: TimeInterval = .hours(24) -+ -+ func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry { -+ let entry = try await remoteEditableCarbEntry(syncIdentifier: syncIdentifier) -+ try await deleteCarbEntry(entry) -+ return entry -+ } -+ -+ func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry { -+ let existing = try await remoteEditableCarbEntry(syncIdentifier: syncIdentifier) -+ try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) -+ -+ let quantity = LoopQuantity(unit: .gram, doubleValue: amountInGrams) -+ let update = CarbEntryUpdate(quantity: quantity, startDate: startDate, foodType: foodType ?? existing.foodType, absorptionTime: absorptionTime) -+ -+ return try await replaceCarbEntry(existing, with: update) -+ } -+ -+ private func validateRemoteCarbParameters(amountInGrams: Double, absorptionTime: TimeInterval, startDate: Date) throws { - if absorptionTime < LoopConstants.minCarbAbsorptionTime || absorptionTime > LoopConstants.maxCarbAbsorptionTime { - throw CarbActionError.invalidAbsorptionTime(absorptionTime) - } -- -+ - guard amountInGrams > 0.0 else { - throw CarbActionError.invalidCarbs - } -- -+ - guard amountInGrams <= LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram) else { - throw CarbActionError.exceedsMaxCarbs - } -- -- if let startDate = startDate { -- let maxStartDate = now.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) -- let minStartDate = now.addingTimeInterval(LoopConstants.maxCarbEntryPastTime) -- guard startDate <= maxStartDate && startDate >= minStartDate else { -- throw CarbActionError.invalidStartDate(startDate) -- } -+ -+ let maxStartDate = now.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) -+ let minStartDate = now.addingTimeInterval(LoopConstants.maxCarbEntryPastTime) -+ guard startDate <= maxStartDate && startDate >= minStartDate else { -+ throw CarbActionError.invalidStartDate(startDate) - } -- -- let quantity = LoopQuantity(unit: .gram, doubleValue: amountInGrams) -- let candidateCarbEntry = NewCarbEntry(syncIdentifier: UUID().uuidString, quantity: quantity, startDate: startDate ?? now, foodType: foodType, absorptionTime: absorptionTime) -+ } - -- let _ = try await carbStore.addCarbEntry(candidateCarbEntry) -+ private func remoteEditableCarbEntry(syncIdentifier: String) async throws -> StoredCarbEntry { -+ let now = now -+ let entries = try await carbStore.getCarbEntries(start: now.addingTimeInterval(-Self.remoteCarbEditWindow), end: now.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime)) -+ guard let entry = entries.first(where: { $0.syncIdentifier == syncIdentifier }) else { -+ throw CarbActionError.entryNotFound -+ } -+ guard entry.createdByCurrentApp else { -+ throw CarbActionError.entryNotEditable -+ } -+ return entry - } - - enum CarbActionError: LocalizedError { -@@ -1450,6 +1483,8 @@ extension LoopDataManager: ServicesManagerDelegate { - case invalidStartDate(Date) - case exceedsMaxCarbs - case invalidCarbs -+ case entryNotFound -+ case entryNotEditable - - var errorDescription: String? { - switch self { -@@ -1457,6 +1492,10 @@ extension LoopDataManager: ServicesManagerDelegate { - return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Carb error description: carbs exceed maximum amount.") - case .invalidCarbs: - return NSLocalizedString("Invalid carb amount", comment: "Carb error description: invalid carb amount.") -+ case .entryNotFound: -+ return NSLocalizedString("Carb entry not found or older than 24 hours", comment: "Carb error description: remote edit target not found.") -+ case .entryNotEditable: -+ return NSLocalizedString("Carb entry was not created by Loop", comment: "Carb error description: remote edit target not created by this app.") - case .invalidAbsorptionTime(let absorptionTime): - let absorptionHoursFormatted = Self.numberFormatter.string(from: absorptionTime.hours) ?? "" - return String(format: NSLocalizedString("Invalid absorption time: %1$@ hours", comment: "Carb error description: invalid absorption time. (1: Input duration in hours)."), absorptionHoursFormatted) -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index c4d0f32e..1cf442dd 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -220,6 +220,34 @@ extension NotificationManager { - UNUserNotificationCenter.current().add(request) - } - -+ static func sendRemoteCarbChangeNotification(body: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [body, "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: LoopNotificationCategory.remoteCarbs.rawValue, -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteCarbChangeFailureNotification(body: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [body, error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: LoopNotificationCategory.remoteCarbsFailure.rawValue, -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ - static func sendMissedMealNotification(mealStart: Date, amountInGrams: Double, delay: TimeInterval? = nil) { - let notification = UNMutableNotificationContent() - /// Notifications should expire after the missed meal is no longer relevant -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index a7da8548..20d2f3d4 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -252,6 +252,8 @@ public protocol ServicesManagerDelegate: AnyObject { - func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws - func cancelCurrentOverride() async throws - func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws -+ func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry -+ func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry - } - - // MARK: - StatefulPluggableDelegate -@@ -337,6 +339,48 @@ extension ServicesManager: ServiceDelegate { - } - } - -+ func deleteRemoteCarbs(syncIdentifier: String) async throws { -+ do { -+ guard let servicesManagerDelegate else { -+ throw RemoteCarbCommandError.internalError -+ } -+ let deleted = try await servicesManagerDelegate.deleteCarbs(syncIdentifier: syncIdentifier) -+ let body = String(format: NSLocalizedString("Remote Carbs Delete: %d grams removed", comment: "The notification body for a successful remote carb delete. (1: Carb amount in grams)"), Int(deleted.quantity.doubleValue(for: .gram))) -+ NotificationManager.sendRemoteCarbChangeNotification(body: body) -+ await remoteDataServicesManager.performUpload(for: .carb) -+ } catch { -+ let body = NSLocalizedString("Remote Carbs Delete", comment: "The notification body prefix for a failed remote carb delete") -+ NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) -+ throw error -+ } -+ } -+ -+ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws { -+ let body = String(format: NSLocalizedString("Remote Carbs Edit: %d grams", comment: "The carb amount message for a remote carb edit notification. (1: Carb amount in grams)"), Int(amountInGrams)) -+ do { -+ guard let servicesManagerDelegate else { -+ throw RemoteCarbCommandError.internalError -+ } -+ _ = try await servicesManagerDelegate.replaceCarbs(syncIdentifier: syncIdentifier, amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) -+ NotificationManager.sendRemoteCarbChangeNotification(body: body) -+ await remoteDataServicesManager.performUpload(for: .carb) -+ } catch { -+ NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) -+ throw error -+ } -+ } -+ -+ enum RemoteCarbCommandError: LocalizedError { -+ case internalError -+ -+ var errorDescription: String? { -+ switch self { -+ case .internalError: -+ return NSLocalizedString("Internal error", comment: "Remote carb command error description: internal error.") -+ } -+ } -+ } -+ - func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?) async throws { - do { - -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index a7c9c653..5f1f37f3 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -19,4 +19,6 @@ public protocol RemoteActionDelegate: AnyObject { - func cancelRemoteOverride() async throws - func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws - func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?) async throws -+ func deleteRemoteCarbs(syncIdentifier: String) async throws -+ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws - } -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index cf6883e..eb8f03d 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -31,12 +31,17 @@ - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; - A941B09029BD4A9E00F91340 /* CarbRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */; }; -+ FB42DD0865235D441918DA5E /* CarbEditRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */; }; -+ A2C1A69F3A61FD4E207637A7 /* CarbDeleteRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */; }; - A941B09129BD4A9E00F91340 /* BolusRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */; }; - A941B09229BD4A9F00F91340 /* OverrideCancelRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */; }; - A941B09329BD4A9F00F91340 /* RemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */; }; - A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */; }; - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */; }; - A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */; }; -+ AFFFFDFBBBA4F7DF3997E63E /* CarbEditRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */; }; -+ 73056A2FC515352D51EFFECF /* CarbDeleteRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */; }; -+ 5C1D2E3F4A5B6C7D8E9F0A1B /* RemoteNotificationCapabilitiesTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */; }; - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */; }; - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */; }; - A94DA7642353CF930082E26D /* StoredGlucoseSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */; }; -@@ -50,6 +55,8 @@ - A9AA6E5B29EB072E008FFA78 /* OverrideAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */; }; - A9AA6E6329EB07D7008FFA78 /* Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5D29EB07D6008FFA78 /* Action.swift */; }; - A9AA6E6429EB07D7008FFA78 /* CarbAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */; }; -+ 843DF14B73EC57EB6F0033DA /* CarbEditAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04073CA740189C4259808331 /* CarbEditAction.swift */; }; -+ 7E7294EAE3456F0C6DC10F37 /* CarbDeleteAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */; }; - A9AA6E6529EB07D7008FFA78 /* OverrideCancelAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */; }; - A9AA6E6629EB07D7008FFA78 /* BolusAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */; }; - A9B007B322BD6FF7000131DE /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B007B222BD6FF7000131DE /* LocalizedString.swift */; }; -@@ -168,12 +175,17 @@ - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; - A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbRemoteNotification.swift; sourceTree = ""; }; -+ 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditRemoteNotification.swift; sourceTree = ""; }; -+ A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteRemoteNotification.swift; sourceTree = ""; }; - A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRemoteNotification.swift; sourceTree = ""; }; - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotification.swift; sourceTree = ""; }; - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotification.swift; sourceTree = ""; }; - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideRemoteNotification.swift; sourceTree = ""; }; - A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRemoteNotificationTestCase.swift; sourceTree = ""; }; - A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbRemoteNotificationTestCase.swift; sourceTree = ""; }; -+ E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditRemoteNotificationTestCase.swift; sourceTree = ""; }; -+ 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteRemoteNotificationTestCase.swift; sourceTree = ""; }; -+ 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotificationCapabilitiesTestCase.swift; sourceTree = ""; }; - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotificationTestCase.swift; sourceTree = ""; }; - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideRemoteNotificationTestCase.swift; sourceTree = ""; }; - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredGlucoseSample.swift; sourceTree = ""; }; -@@ -189,6 +201,8 @@ - A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideAction.swift; sourceTree = ""; }; - A9AA6E5D29EB07D6008FFA78 /* Action.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbAction.swift; sourceTree = ""; }; -+ 04073CA740189C4259808331 /* CarbEditAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditAction.swift; sourceTree = ""; }; -+ 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteAction.swift; sourceTree = ""; }; - A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelAction.swift; sourceTree = ""; }; - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusAction.swift; sourceTree = ""; }; - A9B007B222BD6FF7000131DE /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; -@@ -327,6 +341,9 @@ - A91BAC2B22BC691A00ABF1BB /* Info.plist */, - A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */, - A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */, -+ E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */, -+ 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */, -+ 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */, - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -@@ -368,6 +385,8 @@ - children = ( - A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */, - A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */, -+ 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */, -+ A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */, - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -@@ -424,6 +443,8 @@ - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -+ 04073CA740189C4259808331 /* CarbEditAction.swift */, -+ 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */, - A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */, - A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */, - ); -@@ -725,12 +746,16 @@ - A993990322BC7095005E39D3 /* NightscoutService.swift in Sources */, - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */, - A9AA6E6429EB07D7008FFA78 /* CarbAction.swift in Sources */, -+ 843DF14B73EC57EB6F0033DA /* CarbEditAction.swift in Sources */, -+ 7E7294EAE3456F0C6DC10F37 /* CarbDeleteAction.swift in Sources */, - A90E39A922BC7AD10016DFE8 /* Bundle.swift in Sources */, - A90E39A322BC782C0016DFE8 /* SyncCarbObject.swift in Sources */, - A941B09329BD4A9F00F91340 /* RemoteNotification.swift in Sources */, - A9AA6E6629EB07D7008FFA78 /* BolusAction.swift in Sources */, - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */, - A941B09029BD4A9E00F91340 /* CarbRemoteNotification.swift in Sources */, -+ FB42DD0865235D441918DA5E /* CarbEditRemoteNotification.swift in Sources */, -+ A2C1A69F3A61FD4E207637A7 /* CarbDeleteRemoteNotification.swift in Sources */, - A94DA7642353CF930082E26D /* StoredGlucoseSample.swift in Sources */, - A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */, - C1E703642506BE9400DAB534 /* ObjectIdCache.swift in Sources */, -@@ -765,6 +790,9 @@ - buildActionMask = 2147483647; - files = ( - A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */, -+ AFFFFDFBBBA4F7DF3997E63E /* CarbEditRemoteNotificationTestCase.swift in Sources */, -+ 73056A2FC515352D51EFFECF /* CarbDeleteRemoteNotificationTestCase.swift in Sources */, -+ 5C1D2E3F4A5B6C7D8E9F0A1B /* RemoteNotificationCapabilitiesTestCase.swift in Sources */, - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index 2dec600..1b43f76 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -180,7 +180,7 @@ extension StoredDosingDecision { - enacted: automaticDoseDecision?.loopStatusEnacted, - failureReason: automaticDoseDecision?.loopStatusFailureReason, - failureCode: automaticDoseDecision?.errors.first?.id, -- testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]], -+ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]], - algorithmExperiments: (automaticDoseDecision?.algorithmExperiments ?? algorithmExperiments)?.map { $0.jsonObject }), - overrideStatus: overrideStatus) - } -diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index 73db786..3a513fb 100644 ---- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift -+++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -23,7 +23,7 @@ public final class NightscoutService: Service { - - public static let localizedTitle = LocalizedString("Nightscout", comment: "The title of the Nightscout service") - -- public let objectIdCacheKeepTime = TimeInterval(24 * 60 * 60) -+ public let objectIdCacheKeepTime = TimeInterval(48 * 60 * 60) - - public weak var serviceDelegate: ServiceDelegate? - -@@ -446,6 +446,7 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - var commandType: RemoteNotificationResponseManager.CommandType = .bolus // Default, will be set in switch - var success = false - var message = "" -+ var syncIdentifier: String? = nil - - do { - switch action { -@@ -499,6 +500,26 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - ) - success = true - message = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) -+ -+ case .carbsDelete(let deleteCommand): -+ commandType = .carbsDelete -+ syncIdentifier = deleteCommand.syncIdentifier -+ try await self.serviceDelegate?.deleteRemoteCarbs(syncIdentifier: deleteCommand.syncIdentifier) -+ success = true -+ message = "Carb entry deleted successfully" -+ -+ case .carbsEdit(let editCommand): -+ commandType = .carbsEdit -+ syncIdentifier = editCommand.syncIdentifier -+ try await self.serviceDelegate?.replaceRemoteCarbs( -+ syncIdentifier: editCommand.syncIdentifier, -+ amountInGrams: editCommand.amountInGrams, -+ absorptionTime: editCommand.absorptionTime, -+ foodType: editCommand.foodType, -+ startDate: editCommand.startDate -+ ) -+ success = true -+ message = String(format: "Carb entry updated to %.1f g", editCommand.amountInGrams) - } - } catch { - message = "Command failed: \(error.localizedDescription)" -@@ -508,7 +529,8 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - to: returnInfo, - commandType: commandType, - success: false, -- message: message -+ message: message, -+ syncIdentifier: syncIdentifier - ) - } - throw error -@@ -520,7 +542,8 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - to: returnInfo, - commandType: commandType, - success: success, -- message: message -+ message: message, -+ syncIdentifier: syncIdentifier - ) - } - } -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index 29313b6..29d3563 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -14,6 +14,8 @@ public enum Action: Codable { - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) - case carbsEntry(CarbAction) -+ case carbsDelete(CarbDeleteAction) -+ case carbsEdit(CarbEditAction) - - public var description: String { - return "\(actionName) \(actionParameterDescription)" -@@ -23,6 +25,10 @@ public enum Action: Codable { - switch self { - case .carbsEntry: - return LocalizedString("Carb Entry", comment: "The remote action name for Carb Entry") -+ case .carbsDelete: -+ return LocalizedString("Delete Carbs", comment: "The remote action name for deleting a carb entry") -+ case .carbsEdit: -+ return LocalizedString("Edit Carbs", comment: "The remote action name for editing a carb entry") - case .bolusEntry: - return LocalizedString("Bolus Entry", comment: "The remote action name for Bolus Entry") - case .cancelTemporaryOverride: -@@ -39,6 +45,11 @@ public enum Action: Codable { - case .carbsEntry(let carbAction): - let amountFormatted = Self.numberFormatter.string(from: carbAction.amountInGrams as NSNumber) ?? "" - return "\(amountFormatted)" + " " + carbGramAbbreviation -+ case .carbsDelete(let deleteAction): -+ return String(deleteAction.syncIdentifier.prefix(8)) -+ case .carbsEdit(let editAction): -+ let amountFormatted = Self.numberFormatter.string(from: editAction.amountInGrams as NSNumber) ?? "" -+ return "\(amountFormatted)" + " " + carbGramAbbreviation - case .bolusEntry(let bolusAction): - let amountFormatted = Self.numberFormatter.string(from: bolusAction.amountInUnits as NSNumber) ?? "" - return "\(amountFormatted)" + " " + bolusUnitAbbreviation -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift -new file mode 100644 -index 0000000..77e9900 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift -@@ -0,0 +1,15 @@ -+// -+// CarbDeleteAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct CarbDeleteAction: Codable { -+ -+ public let syncIdentifier: String -+ -+ public init(syncIdentifier: String) { -+ self.syncIdentifier = syncIdentifier -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift -new file mode 100644 -index 0000000..0aea20b ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift -@@ -0,0 +1,23 @@ -+// -+// CarbEditAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct CarbEditAction: Codable { -+ -+ public let syncIdentifier: String -+ public let amountInGrams: Double -+ public let absorptionTime: TimeInterval -+ public let foodType: String? -+ public let startDate: Date -+ -+ public init(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String? = nil, startDate: Date) { -+ self.syncIdentifier = syncIdentifier -+ self.amountInGrams = amountInGrams -+ self.absorptionTime = absorptionTime -+ self.foodType = foodType -+ self.startDate = startDate -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 4457f80..aabb47f 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -21,12 +21,14 @@ class RemoteNotificationResponseManager { - let commandStatus: String - let commandType: String - let timestamp: TimeInterval -+ let syncIdentifier: String? - - enum CodingKeys: String, CodingKey { - case aps - case commandStatus = "command_status" - case commandType = "command_type" - case timestamp -+ case syncIdentifier = "sync_identifier" - } - } - -@@ -43,6 +45,8 @@ class RemoteNotificationResponseManager { - enum CommandType: String { - case bolus = "bolus" - case carbs = "carbs" -+ case carbsDelete = "carbs_delete" -+ case carbsEdit = "carbs_edit" - case override = "override" - case cancelOverride = "cancel_override" - case profileSwitch = "profile_switch" -@@ -52,7 +56,8 @@ class RemoteNotificationResponseManager { - to returnInfo: ReturnNotificationInfo?, - commandType: CommandType, - success: Bool, -- message: String -+ message: String, -+ syncIdentifier: String? = nil - ) async { - guard let returnInfo = returnInfo else { - os_log("No return notification info provided, skipping response", log: log, type: .info) -@@ -75,7 +80,8 @@ class RemoteNotificationResponseManager { - ), - commandStatus: success ? "success" : "failed", - commandType: commandType.rawValue, -- timestamp: Date().timeIntervalSince1970 -+ timestamp: Date().timeIntervalSince1970, -+ syncIdentifier: syncIdentifier - ) - - await sendPushNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift -new file mode 100644 -index 0000000..1593a74 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift -@@ -0,0 +1,44 @@ -+// -+// CarbDeleteRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+public struct CarbDeleteRemoteNotification: RemoteNotification, Codable { -+ -+ public let syncIdentifier: String -+ public let remoteAddress: String -+ public let expiration: Date? -+ public let sentAt: Date? -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ enum CodingKeys: String, CodingKey { -+ case syncIdentifier = "carbs-delete" -+ case remoteAddress = "remote-address" -+ case expiration = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .carbsDelete(CarbDeleteAction(syncIdentifier: syncIdentifier)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ func expirationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification["carbs-delete"] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift -new file mode 100644 -index 0000000..fde3e4c ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift -@@ -0,0 +1,58 @@ -+// -+// CarbEditRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Replacement values use prefixed keys so an unpatched Loop never reads them as a new carb entry. -+public struct CarbEditRemoteNotification: RemoteNotification, Codable { -+ -+ public let syncIdentifier: String -+ public let amount: Double -+ public let absorptionInHours: Double -+ public let foodType: String? -+ public let startDate: Date -+ public let remoteAddress: String -+ public let expiration: Date? -+ public let sentAt: Date? -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ enum CodingKeys: String, CodingKey { -+ case syncIdentifier = "carbs-edit" -+ case amount = "carbs-edit-entry" -+ case absorptionInHours = "carbs-edit-absorption-time" -+ case foodType = "carbs-edit-food-type" -+ case startDate = "carbs-edit-start-time" -+ case remoteAddress = "remote-address" -+ case expiration = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ public func absorptionTime() -> TimeInterval { -+ return TimeInterval(hours: absorptionInHours) -+ } -+ -+ func toRemoteAction() -> Action { -+ let action = CarbEditAction(syncIdentifier: syncIdentifier, amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate) -+ return .carbsEdit(action) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ func expirationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification["carbs-edit"] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index 3362bf7..87f1e92 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -22,12 +22,22 @@ protocol RemoteNotification: Codable { - - func toRemoteAction() -> Action - func otpValidationRequired() -> Bool -+ func expirationRequired() -> Bool - func getReturnNotificationInfo() -> ReturnNotificationInfo? - - static func includedInNotification(_ notification: [String: Any]) -> Bool - } - -+/// Followers read this to know which remote commands this build accepts. -+enum RemoteNotificationCapabilities { -+ static let supported: [String] = ["carbs-delete", "carbs-edit"] -+} -+ - extension RemoteNotification { -+ -+ func expirationRequired() -> Bool { -+ return false -+ } - - var id: String { - //There is no unique identifier so we use the sent date when available -@@ -102,6 +112,10 @@ extension Dictionary { - func toRemoteNotification() throws -> RemoteNotification { - if BolusRemoteNotification.includedInNotification(self) { - return try BolusRemoteNotification(dictionary: self) -+ } else if CarbDeleteRemoteNotification.includedInNotification(self) { -+ return try CarbDeleteRemoteNotification(dictionary: self) -+ } else if CarbEditRemoteNotification.includedInNotification(self) { -+ return try CarbEditRemoteNotification(dictionary: self) - } else if CarbRemoteNotification.includedInNotification(self) { - return try CarbRemoteNotification(dictionary: self) - } else if OverrideRemoteNotification.includedInNotification(self) { -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -index 634eb4e..21d0e0a 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -@@ -16,6 +16,14 @@ struct RemoteCommandValidator { - } - - func validate(remoteNotification: RemoteNotification) throws { -+ if remoteNotification.expirationRequired() { -+ guard remoteNotification.expiration != nil else { -+ throw NotificationValidationError.missingExpiration -+ } -+ guard remoteNotification.sentAt != nil else { -+ throw NotificationValidationError.missingSentAt -+ } -+ } - try validateExpirationDate(remoteNotification: remoteNotification) - if remoteNotification.otpValidationRequired() { - try validateOTP(remoteNotification: remoteNotification) -@@ -45,11 +53,17 @@ struct RemoteCommandValidator { - enum NotificationValidationError: LocalizedError { - case missingOTP - case expiredNotification -+ case missingExpiration -+ case missingSentAt - - var errorDescription: String? { - switch self { - case .missingOTP: - return LocalizedString("Missing OTP", comment: "Remote command error description: Missing OTP.") -+ case .missingExpiration: -+ return LocalizedString("Missing expiration", comment: "Remote command error description: missing expiration.") -+ case .missingSentAt: -+ return LocalizedString("Missing sent-at time", comment: "Remote command error description: missing sent-at.") - case .expiredNotification: - return LocalizedString("Expired", comment: "Remote command error description: expired.") - } -diff --git a/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..6a3aba6 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift -@@ -0,0 +1,67 @@ -+// -+// CarbDeleteRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+@testable import NightscoutServiceKit -+ -+final class CarbDeleteRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseCarbDeleteNotification_ValidPayload_Succeeds() throws { -+ let syncIdentifier = "3F2504E0-4F89-11D3-9A0C-0305E82C3301" -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-delete": syncIdentifier as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ let remoteNotification = try notification.toRemoteNotification() -+ -+ let deleteNotification = try XCTUnwrap(remoteNotification as? CarbDeleteRemoteNotification) -+ XCTAssertEqual(deleteNotification.syncIdentifier, syncIdentifier) -+ XCTAssertEqual(deleteNotification.otp, "12345") -+ XCTAssertTrue(deleteNotification.otpValidationRequired()) -+ XCTAssertTrue(deleteNotification.expirationRequired()) -+ guard case .carbsDelete(let action) = deleteNotification.toRemoteAction() else { -+ return XCTFail("Expected carbsDelete action") -+ } -+ XCTAssertEqual(action.syncIdentifier, syncIdentifier) -+ } -+ -+ func testValidate_MissingExpiration_Throws() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "carbs-delete": "abc" as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ let remoteNotification = try notification.toRemoteNotification() -+ let validator = RemoteCommandValidator(otpManager: OTPTestCoordinator().createOTPManager()) -+ -+ XCTAssertThrowsError(try validator.validate(remoteNotification: remoteNotification)) { error in -+ guard case RemoteCommandValidator.NotificationValidationError.missingExpiration = error else { -+ return XCTFail("Unexpected error \(error)") -+ } -+ } -+ } -+ -+ func testValidate_MissingSentAt_Throws() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "expiration": "2099-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-delete": "abc" as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ let remoteNotification = try notification.toRemoteNotification() -+ let validator = RemoteCommandValidator(otpManager: OTPTestCoordinator().createOTPManager()) -+ -+ XCTAssertThrowsError(try validator.validate(remoteNotification: remoteNotification)) { error in -+ guard case RemoteCommandValidator.NotificationValidationError.missingSentAt = error else { -+ return XCTFail("Unexpected error \(error)") -+ } -+ } -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..989f7c8 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift -@@ -0,0 +1,108 @@ -+// -+// CarbEditRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+@testable import NightscoutServiceKit -+ -+final class CarbEditRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseCarbEditNotification_ValidPayload_Succeeds() throws { -+ let syncIdentifier = "3F2504E0-4F89-11D3-9A0C-0305E82C3301" -+ let startDateString = "2023-02-25T20:46:35.778Z" -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-edit": syncIdentifier as AnyObject, -+ "carbs-edit-entry": 22.0 as AnyObject, -+ "carbs-edit-absorption-time": 3.0 as AnyObject, -+ "carbs-edit-food-type": "🍕" as AnyObject, -+ "carbs-edit-start-time": startDateString as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ let remoteNotification = try notification.toRemoteNotification() -+ -+ let editNotification = try XCTUnwrap(remoteNotification as? CarbEditRemoteNotification) -+ XCTAssertEqual(editNotification.syncIdentifier, syncIdentifier) -+ XCTAssertEqual(editNotification.amount, 22.0) -+ XCTAssertEqual(editNotification.absorptionTime(), TimeInterval(hours: 3.0)) -+ XCTAssertEqual(editNotification.foodType, "🍕") -+ XCTAssertEqual(editNotification.startDate, dateFormatter().date(from: startDateString)) -+ XCTAssertTrue(editNotification.otpValidationRequired()) -+ XCTAssertTrue(editNotification.expirationRequired()) -+ guard case .carbsEdit(let action) = editNotification.toRemoteAction() else { -+ return XCTFail("Expected carbsEdit action") -+ } -+ XCTAssertEqual(action.syncIdentifier, syncIdentifier) -+ XCTAssertEqual(action.amountInGrams, 22.0) -+ XCTAssertEqual(action.absorptionTime, TimeInterval(hours: 3.0)) -+ XCTAssertEqual(action.startDate, dateFormatter().date(from: startDateString)) -+ } -+ -+ func testParseCarbEditNotification_FoodTypeAbsent_Succeeds() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-edit": "abc" as AnyObject, -+ "carbs-edit-entry": 10.0 as AnyObject, -+ "carbs-edit-absorption-time": 2.0 as AnyObject, -+ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ let editNotification = try XCTUnwrap(try notification.toRemoteNotification() as? CarbEditRemoteNotification) -+ XCTAssertNil(editNotification.foodType) -+ } -+ -+ func testParseCarbEditNotification_MissingStartTime_Throws() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-edit": "abc" as AnyObject, -+ "carbs-edit-entry": 10.0 as AnyObject, -+ "carbs-edit-absorption-time": 2.0 as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ XCTAssertThrowsError(try notification.toRemoteNotification()) -+ } -+ -+ func testParseCarbEditNotification_MissingAbsorptionTime_Throws() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-edit": "abc" as AnyObject, -+ "carbs-edit-entry": 10.0 as AnyObject, -+ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ XCTAssertThrowsError(try notification.toRemoteNotification()) -+ } -+ -+ func testParseCarbEditNotification_DoesNotDecodeAsCarbEntry() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "carbs-edit": "abc" as AnyObject, -+ "carbs-edit-entry": 10.0 as AnyObject, -+ "carbs-edit-absorption-time": 2.0 as AnyObject, -+ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ XCTAssertFalse(CarbRemoteNotification.includedInNotification(notification)) -+ XCTAssertTrue(CarbEditRemoteNotification.includedInNotification(notification)) -+ } -+ -+ func dateFormatter() -> ISO8601DateFormatter { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift b/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift -new file mode 100644 -index 0000000..e284d3f ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift -@@ -0,0 +1,21 @@ -+// -+// RemoteNotificationCapabilitiesTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+@testable import NightscoutServiceKit -+ -+final class RemoteNotificationCapabilitiesTestCase: XCTestCase { -+ -+ func testSupportedCommands_MatchNotificationKeys() { -+ XCTAssertEqual(RemoteNotificationCapabilities.supported, ["carbs-delete", "carbs-edit"]) -+ XCTAssertTrue(CarbDeleteRemoteNotification.includedInNotification(["carbs-delete": "id"])) -+ XCTAssertTrue(CarbEditRemoteNotification.includedInNotification(["carbs-edit": "id"])) -+ for key in RemoteNotificationCapabilities.supported { -+ let notification: [String: Any] = [key: "id"] -+ XCTAssertTrue(CarbDeleteRemoteNotification.includedInNotification(notification) -+ || CarbEditRemoteNotification.includedInNotification(notification), key) -+ } -+ } -+} -diff --git a/remote_carb_edit.loopcustomization b/remote_carb_edit.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/remote_carb_edit/nextdev_remote_carb_edit_profiles_remote_window.patch b/remote_carb_edit/nextdev_remote_carb_edit_profiles_remote_window.patch deleted file mode 100644 index c60494a..0000000 --- a/remote_carb_edit/nextdev_remote_carb_edit_profiles_remote_window.patch +++ /dev/null @@ -1,956 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift -index 1966dcde..feb53338 100644 ---- a/Loop/Loop/Managers/LoopDataManager.swift -+++ b/Loop/Loop/Managers/LoopDataManager.swift -@@ -1416,28 +1416,10 @@ extension LoopDataManager: ServicesManagerDelegate { - //Carb Entry - - func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws { -- - let absorptionTime = absorptionTime ?? LoopCoreConstants.defaultCarbAbsorptionTimes.medium -- if absorptionTime < LoopConstants.minCarbAbsorptionTime || absorptionTime > LoopConstants.maxCarbAbsorptionTime { -- throw CarbActionError.invalidAbsorptionTime(absorptionTime) -- } -- -- guard amountInGrams > 0.0 else { -- throw CarbActionError.invalidCarbs -- } -- -- guard amountInGrams <= LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram) else { -- throw CarbActionError.exceedsMaxCarbs -- } -- -- if let startDate = startDate { -- let maxStartDate = now.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) -- let minStartDate = now.addingTimeInterval(LoopConstants.maxCarbEntryPastTime) -- guard startDate <= maxStartDate && startDate >= minStartDate else { -- throw CarbActionError.invalidStartDate(startDate) -- } -- } -- -+ let startDate = startDate ?? now -+ try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) -+ - // Guard B (conflicting-treatment protection): reject remote carbs if any carb entry was - // created within minTreatmentIntervalInMinutes of the command's userCreatedDate. Look back - // a little further for safety (e.g. upload delays); zero tolerance for carbs. -@@ -1455,10 +1437,61 @@ extension LoopDataManager: ServicesManagerDelegate { - } - - let quantity = LoopQuantity(unit: .gram, doubleValue: amountInGrams) -- let candidateCarbEntry = NewCarbEntry(syncIdentifier: UUID().uuidString, quantity: quantity, startDate: startDate ?? now, foodType: foodType, absorptionTime: absorptionTime) -+ let candidateCarbEntry = NewCarbEntry(syncIdentifier: UUID().uuidString, quantity: quantity, startDate: startDate, foodType: foodType, absorptionTime: absorptionTime) - - let _ = try await carbStore.addCarbEntry(candidateCarbEntry) - } -+ -+ /// Remote edits and deletes may only target entries this app created within the last 24 hours. -+ static let remoteCarbEditWindow: TimeInterval = .hours(24) -+ -+ func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry { -+ let entry = try await remoteEditableCarbEntry(syncIdentifier: syncIdentifier) -+ try await deleteCarbEntry(entry) -+ return entry -+ } -+ -+ func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry { -+ let existing = try await remoteEditableCarbEntry(syncIdentifier: syncIdentifier) -+ try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) -+ -+ let quantity = LoopQuantity(unit: .gram, doubleValue: amountInGrams) -+ let update = CarbEntryUpdate(quantity: quantity, startDate: startDate, foodType: foodType ?? existing.foodType, absorptionTime: absorptionTime) -+ -+ return try await replaceCarbEntry(existing, with: update) -+ } -+ -+ private func validateRemoteCarbParameters(amountInGrams: Double, absorptionTime: TimeInterval, startDate: Date) throws { -+ if absorptionTime < LoopConstants.minCarbAbsorptionTime || absorptionTime > LoopConstants.maxCarbAbsorptionTime { -+ throw CarbActionError.invalidAbsorptionTime(absorptionTime) -+ } -+ -+ guard amountInGrams > 0.0 else { -+ throw CarbActionError.invalidCarbs -+ } -+ -+ guard amountInGrams <= LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram) else { -+ throw CarbActionError.exceedsMaxCarbs -+ } -+ -+ let maxStartDate = now.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) -+ let minStartDate = now.addingTimeInterval(LoopConstants.maxCarbEntryPastTime) -+ guard startDate <= maxStartDate && startDate >= minStartDate else { -+ throw CarbActionError.invalidStartDate(startDate) -+ } -+ } -+ -+ private func remoteEditableCarbEntry(syncIdentifier: String) async throws -> StoredCarbEntry { -+ let now = now -+ let entries = try await carbStore.getCarbEntries(start: now.addingTimeInterval(-Self.remoteCarbEditWindow), end: now.addingTimeInterval(LoopConstants.maxCarbEntryFutureTime)) -+ guard let entry = entries.first(where: { $0.syncIdentifier == syncIdentifier }) else { -+ throw CarbActionError.entryNotFound -+ } -+ guard entry.createdByCurrentApp else { -+ throw CarbActionError.entryNotEditable -+ } -+ return entry -+ } - - enum CarbActionError: LocalizedError { - -@@ -1466,6 +1499,8 @@ extension LoopDataManager: ServicesManagerDelegate { - case invalidStartDate(Date) - case exceedsMaxCarbs - case invalidCarbs -+ case entryNotFound -+ case entryNotEditable - case conflictingTreatments(_ conflictingAmountInGrams: Double, _ minutesRequiredBetweenTreatments: TimeInterval) - - var errorDescription: String? { -@@ -1478,6 +1513,10 @@ extension LoopDataManager: ServicesManagerDelegate { - return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Carb error description: carbs exceed maximum amount.") - case .invalidCarbs: - return NSLocalizedString("Invalid carb amount", comment: "Carb error description: invalid carb amount.") -+ case .entryNotFound: -+ return NSLocalizedString("Carb entry not found or older than 24 hours", comment: "Carb error description: remote edit target not found.") -+ case .entryNotEditable: -+ return NSLocalizedString("Carb entry was not created by Loop", comment: "Carb error description: remote edit target not created by this app.") - case .invalidAbsorptionTime(let absorptionTime): - let absorptionHoursFormatted = Self.numberFormatter.string(from: absorptionTime.hours) ?? "" - return String(format: NSLocalizedString("Invalid absorption time: %1$@ hours", comment: "Carb error description: invalid absorption time. (1: Input duration in hours)."), absorptionHoursFormatted) -diff --git a/Loop/Loop/Managers/NotificationManager.swift b/Loop/Loop/Managers/NotificationManager.swift -index c4d0f32e..1cf442dd 100644 ---- a/Loop/Loop/Managers/NotificationManager.swift -+++ b/Loop/Loop/Managers/NotificationManager.swift -@@ -220,6 +220,34 @@ extension NotificationManager { - UNUserNotificationCenter.current().add(request) - } - -+ static func sendRemoteCarbChangeNotification(body: String) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [body, "Success!"].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: LoopNotificationCategory.remoteCarbs.rawValue, -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ -+ static func sendRemoteCarbChangeFailureNotification(body: String, error: Error) { -+ let notification = UNMutableNotificationContent() -+ notification.body = [body, error.localizedDescription].joined(separator: "\n") -+ notification.sound = .default -+ -+ let request = UNNotificationRequest( -+ identifier: LoopNotificationCategory.remoteCarbsFailure.rawValue, -+ content: notification, -+ trigger: nil -+ ) -+ -+ UNUserNotificationCenter.current().add(request) -+ } -+ - static func sendMissedMealNotification(mealStart: Date, amountInGrams: Double, delay: TimeInterval? = nil) { - let notification = UNMutableNotificationContent() - /// Notifications should expire after the missed meal is no longer relevant -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 462e815b..f710fb3e 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -252,6 +252,8 @@ public protocol ServicesManagerDelegate: AnyObject { - func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws - func cancelCurrentOverride() async throws - func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -+ func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry -+ func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry - } - - // MARK: - StatefulPluggableDelegate -@@ -338,6 +340,48 @@ extension ServicesManager: ServiceDelegate { - } - } - -+ func deleteRemoteCarbs(syncIdentifier: String) async throws { -+ do { -+ guard let servicesManagerDelegate else { -+ throw RemoteCarbCommandError.internalError -+ } -+ let deleted = try await servicesManagerDelegate.deleteCarbs(syncIdentifier: syncIdentifier) -+ let body = String(format: NSLocalizedString("Remote Carbs Delete: %d grams removed", comment: "The notification body for a successful remote carb delete. (1: Carb amount in grams)"), Int(deleted.quantity.doubleValue(for: .gram))) -+ NotificationManager.sendRemoteCarbChangeNotification(body: body) -+ await remoteDataServicesManager.performUpload(for: .carb) -+ } catch { -+ let body = NSLocalizedString("Remote Carbs Delete", comment: "The notification body prefix for a failed remote carb delete") -+ NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) -+ throw error -+ } -+ } -+ -+ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws { -+ let body = String(format: NSLocalizedString("Remote Carbs Edit: %d grams", comment: "The carb amount message for a remote carb edit notification. (1: Carb amount in grams)"), Int(amountInGrams)) -+ do { -+ guard let servicesManagerDelegate else { -+ throw RemoteCarbCommandError.internalError -+ } -+ _ = try await servicesManagerDelegate.replaceCarbs(syncIdentifier: syncIdentifier, amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) -+ NotificationManager.sendRemoteCarbChangeNotification(body: body) -+ await remoteDataServicesManager.performUpload(for: .carb) -+ } catch { -+ NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) -+ throw error -+ } -+ } -+ -+ enum RemoteCarbCommandError: LocalizedError { -+ case internalError -+ -+ var errorDescription: String? { -+ switch self { -+ case .internalError: -+ return NSLocalizedString("Internal error", comment: "Remote carb command error description: internal error.") -+ } -+ } -+ } -+ - func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?, userCreatedDate: Date) async throws -> Double { - do { - -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index b82f71ed..39c0eee9 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -19,4 +19,6 @@ public protocol RemoteActionDelegate: AnyObject { - func cancelRemoteOverride() async throws - func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws - func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?, userCreatedDate: Date) async throws -> Double -+ func deleteRemoteCarbs(syncIdentifier: String) async throws -+ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws - } -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -index cf6883e..eb8f03d 100644 ---- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -+++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj -@@ -31,12 +31,17 @@ - A941B08729BCB87500F91340 /* RemoteCommandSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08629BCB87500F91340 /* RemoteCommandSource.swift */; }; - A941B08929BCB99C00F91340 /* RemoteCommandValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */; }; - A941B09029BD4A9E00F91340 /* CarbRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */; }; -+ FB42DD0865235D441918DA5E /* CarbEditRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */; }; -+ A2C1A69F3A61FD4E207637A7 /* CarbDeleteRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */; }; - A941B09129BD4A9E00F91340 /* BolusRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */; }; - A941B09229BD4A9F00F91340 /* OverrideCancelRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */; }; - A941B09329BD4A9F00F91340 /* RemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */; }; - A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */; }; - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */; }; - A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */; }; -+ AFFFFDFBBBA4F7DF3997E63E /* CarbEditRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */; }; -+ 73056A2FC515352D51EFFECF /* CarbDeleteRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */; }; -+ 5C1D2E3F4A5B6C7D8E9F0A1B /* RemoteNotificationCapabilitiesTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */; }; - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */; }; - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */; }; - A94DA7642353CF930082E26D /* StoredGlucoseSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */; }; -@@ -50,6 +55,8 @@ - A9AA6E5B29EB072E008FFA78 /* OverrideAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */; }; - A9AA6E6329EB07D7008FFA78 /* Action.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5D29EB07D6008FFA78 /* Action.swift */; }; - A9AA6E6429EB07D7008FFA78 /* CarbAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */; }; -+ 843DF14B73EC57EB6F0033DA /* CarbEditAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04073CA740189C4259808331 /* CarbEditAction.swift */; }; -+ 7E7294EAE3456F0C6DC10F37 /* CarbDeleteAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */; }; - A9AA6E6529EB07D7008FFA78 /* OverrideCancelAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */; }; - A9AA6E6629EB07D7008FFA78 /* BolusAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */; }; - A9B007B322BD6FF7000131DE /* LocalizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9B007B222BD6FF7000131DE /* LocalizedString.swift */; }; -@@ -168,12 +175,17 @@ - A941B08629BCB87500F91340 /* RemoteCommandSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandSource.swift; sourceTree = ""; }; - A941B08829BCB99C00F91340 /* RemoteCommandValidator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteCommandValidator.swift; sourceTree = ""; }; - A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbRemoteNotification.swift; sourceTree = ""; }; -+ 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditRemoteNotification.swift; sourceTree = ""; }; -+ A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteRemoteNotification.swift; sourceTree = ""; }; - A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRemoteNotification.swift; sourceTree = ""; }; - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotification.swift; sourceTree = ""; }; - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotification.swift; sourceTree = ""; }; - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideRemoteNotification.swift; sourceTree = ""; }; - A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRemoteNotificationTestCase.swift; sourceTree = ""; }; - A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbRemoteNotificationTestCase.swift; sourceTree = ""; }; -+ E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditRemoteNotificationTestCase.swift; sourceTree = ""; }; -+ 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteRemoteNotificationTestCase.swift; sourceTree = ""; }; -+ 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteNotificationCapabilitiesTestCase.swift; sourceTree = ""; }; - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelRemoteNotificationTestCase.swift; sourceTree = ""; }; - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideRemoteNotificationTestCase.swift; sourceTree = ""; }; - A94DA7632353CF930082E26D /* StoredGlucoseSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoredGlucoseSample.swift; sourceTree = ""; }; -@@ -189,6 +201,8 @@ - A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideAction.swift; sourceTree = ""; }; - A9AA6E5D29EB07D6008FFA78 /* Action.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Action.swift; sourceTree = ""; }; - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbAction.swift; sourceTree = ""; }; -+ 04073CA740189C4259808331 /* CarbEditAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbEditAction.swift; sourceTree = ""; }; -+ 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbDeleteAction.swift; sourceTree = ""; }; - A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideCancelAction.swift; sourceTree = ""; }; - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusAction.swift; sourceTree = ""; }; - A9B007B222BD6FF7000131DE /* LocalizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedString.swift; sourceTree = ""; }; -@@ -327,6 +341,9 @@ - A91BAC2B22BC691A00ABF1BB /* Info.plist */, - A941B09529BD4AC900F91340 /* BolusRemoteNotificationTestCase.swift */, - A941B09629BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift */, -+ E0998B6C05EEDD7283F87513 /* CarbEditRemoteNotificationTestCase.swift */, -+ 976E1716A3DFA298F24F1566 /* CarbDeleteRemoteNotificationTestCase.swift */, -+ 6D2E3F4A5B6C7D8E9F0A1B2C /* RemoteNotificationCapabilitiesTestCase.swift */, - A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, - A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, - A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, -@@ -368,6 +385,8 @@ - children = ( - A941B08C29BD4A9D00F91340 /* BolusRemoteNotification.swift */, - A941B08B29BD4A9D00F91340 /* CarbRemoteNotification.swift */, -+ 72499960CC6A5E6390155A7B /* CarbEditRemoteNotification.swift */, -+ A900F6EB84789F6CE34171C5 /* CarbDeleteRemoteNotification.swift */, - A941B08D29BD4A9E00F91340 /* OverrideCancelRemoteNotification.swift */, - A941B08F29BD4A9E00F91340 /* OverrideRemoteNotification.swift */, - A941B08E29BD4A9E00F91340 /* RemoteNotification.swift */, -@@ -424,6 +443,8 @@ - A9AA6E5D29EB07D6008FFA78 /* Action.swift */, - A9AA6E6029EB07D6008FFA78 /* BolusAction.swift */, - A9AA6E5E29EB07D6008FFA78 /* CarbAction.swift */, -+ 04073CA740189C4259808331 /* CarbEditAction.swift */, -+ 28CA9258C6438134DC9E758F /* CarbDeleteAction.swift */, - A9AA6E5A29EB072E008FFA78 /* OverrideAction.swift */, - A9AA6E5F29EB07D6008FFA78 /* OverrideCancelAction.swift */, - ); -@@ -725,12 +746,16 @@ - A993990322BC7095005E39D3 /* NightscoutService.swift in Sources */, - A934B6162358F42A00949C8B /* StoredDosingDecision.swift in Sources */, - A9AA6E6429EB07D7008FFA78 /* CarbAction.swift in Sources */, -+ 843DF14B73EC57EB6F0033DA /* CarbEditAction.swift in Sources */, -+ 7E7294EAE3456F0C6DC10F37 /* CarbDeleteAction.swift in Sources */, - A90E39A922BC7AD10016DFE8 /* Bundle.swift in Sources */, - A90E39A322BC782C0016DFE8 /* SyncCarbObject.swift in Sources */, - A941B09329BD4A9F00F91340 /* RemoteNotification.swift in Sources */, - A9AA6E6629EB07D7008FFA78 /* BolusAction.swift in Sources */, - A934B61A2358F5FC00949C8B /* TemporaryScheduleOverride.swift in Sources */, - A941B09029BD4A9E00F91340 /* CarbRemoteNotification.swift in Sources */, -+ FB42DD0865235D441918DA5E /* CarbEditRemoteNotification.swift in Sources */, -+ A2C1A69F3A61FD4E207637A7 /* CarbDeleteRemoteNotification.swift in Sources */, - A94DA7642353CF930082E26D /* StoredGlucoseSample.swift in Sources */, - A941B09429BD4A9F00F91340 /* OverrideRemoteNotification.swift in Sources */, - C1E703642506BE9400DAB534 /* ObjectIdCache.swift in Sources */, -@@ -765,6 +790,9 @@ - buildActionMask = 2147483647; - files = ( - A941B09A29BD4ACA00F91340 /* CarbRemoteNotificationTestCase.swift in Sources */, -+ AFFFFDFBBBA4F7DF3997E63E /* CarbEditRemoteNotificationTestCase.swift in Sources */, -+ 73056A2FC515352D51EFFECF /* CarbDeleteRemoteNotificationTestCase.swift in Sources */, -+ 5C1D2E3F4A5B6C7D8E9F0A1B /* RemoteNotificationCapabilitiesTestCase.swift in Sources */, - A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, - A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, - A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift in Sources */, -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -index 2dec600..1b43f76 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/StoredDosingDecision.swift -@@ -180,7 +180,7 @@ extension StoredDosingDecision { - enacted: automaticDoseDecision?.loopStatusEnacted, - failureReason: automaticDoseDecision?.loopStatusFailureReason, - failureCode: automaticDoseDecision?.errors.first?.id, -- testingDetails: ["remoteCommands": [ProfileSwitchRemoteNotification.commandKey]], -+ testingDetails: ["remoteCommands": RemoteNotificationCapabilities.supported + [ProfileSwitchRemoteNotification.commandKey]], - algorithmExperiments: (automaticDoseDecision?.algorithmExperiments ?? algorithmExperiments)?.map { $0.jsonObject }), - overrideStatus: overrideStatus) - } -diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index ec997ef..64a668c 100644 ---- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift -+++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -24,7 +24,7 @@ public final class NightscoutService: Service { - - public static let localizedTitle = LocalizedString("Nightscout", comment: "The title of the Nightscout service") - -- public let objectIdCacheKeepTime = TimeInterval(24 * 60 * 60) -+ public let objectIdCacheKeepTime = TimeInterval(48 * 60 * 60) - - public weak var serviceDelegate: ServiceDelegate? - -@@ -457,6 +457,7 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - var commandType: RemoteNotificationResponseManager.CommandType = .bolus // Default, will be set in switch - var success = false - var message = "" -+ var syncIdentifier: String? = nil - - do { - guard let serviceDelegate = self.serviceDelegate else { -@@ -518,6 +519,26 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - ) - success = true - message = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) -+ -+ case .carbsDelete(let deleteCommand): -+ commandType = .carbsDelete -+ syncIdentifier = deleteCommand.syncIdentifier -+ try await serviceDelegate.deleteRemoteCarbs(syncIdentifier: deleteCommand.syncIdentifier) -+ success = true -+ message = "Carb entry deleted successfully" -+ -+ case .carbsEdit(let editCommand): -+ commandType = .carbsEdit -+ syncIdentifier = editCommand.syncIdentifier -+ try await serviceDelegate.replaceRemoteCarbs( -+ syncIdentifier: editCommand.syncIdentifier, -+ amountInGrams: editCommand.amountInGrams, -+ absorptionTime: editCommand.absorptionTime, -+ foodType: editCommand.foodType, -+ startDate: editCommand.startDate -+ ) -+ success = true -+ message = String(format: "Carb entry updated to %.1f g", editCommand.amountInGrams) - } - } catch { - message = "Command failed: \(error.localizedDescription)" -@@ -527,7 +548,8 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - to: returnInfo, - commandType: commandType, - success: false, -- message: message -+ message: message, -+ syncIdentifier: syncIdentifier - ) - } - throw error -@@ -539,7 +561,8 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - to: returnInfo, - commandType: commandType, - success: success, -- message: message -+ message: message, -+ syncIdentifier: syncIdentifier - ) - } - } -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -index 29313b6..29d3563 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/Action.swift -@@ -14,6 +14,8 @@ public enum Action: Codable { - case cancelTemporaryOverride(OverrideCancelAction) - case bolusEntry(BolusAction) - case carbsEntry(CarbAction) -+ case carbsDelete(CarbDeleteAction) -+ case carbsEdit(CarbEditAction) - - public var description: String { - return "\(actionName) \(actionParameterDescription)" -@@ -23,6 +25,10 @@ public enum Action: Codable { - switch self { - case .carbsEntry: - return LocalizedString("Carb Entry", comment: "The remote action name for Carb Entry") -+ case .carbsDelete: -+ return LocalizedString("Delete Carbs", comment: "The remote action name for deleting a carb entry") -+ case .carbsEdit: -+ return LocalizedString("Edit Carbs", comment: "The remote action name for editing a carb entry") - case .bolusEntry: - return LocalizedString("Bolus Entry", comment: "The remote action name for Bolus Entry") - case .cancelTemporaryOverride: -@@ -39,6 +45,11 @@ public enum Action: Codable { - case .carbsEntry(let carbAction): - let amountFormatted = Self.numberFormatter.string(from: carbAction.amountInGrams as NSNumber) ?? "" - return "\(amountFormatted)" + " " + carbGramAbbreviation -+ case .carbsDelete(let deleteAction): -+ return String(deleteAction.syncIdentifier.prefix(8)) -+ case .carbsEdit(let editAction): -+ let amountFormatted = Self.numberFormatter.string(from: editAction.amountInGrams as NSNumber) ?? "" -+ return "\(amountFormatted)" + " " + carbGramAbbreviation - case .bolusEntry(let bolusAction): - let amountFormatted = Self.numberFormatter.string(from: bolusAction.amountInUnits as NSNumber) ?? "" - return "\(amountFormatted)" + " " + bolusUnitAbbreviation -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift -new file mode 100644 -index 0000000..77e9900 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbDeleteAction.swift -@@ -0,0 +1,15 @@ -+// -+// CarbDeleteAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct CarbDeleteAction: Codable { -+ -+ public let syncIdentifier: String -+ -+ public init(syncIdentifier: String) { -+ self.syncIdentifier = syncIdentifier -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift -new file mode 100644 -index 0000000..0aea20b ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbEditAction.swift -@@ -0,0 +1,23 @@ -+// -+// CarbEditAction.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+ -+public struct CarbEditAction: Codable { -+ -+ public let syncIdentifier: String -+ public let amountInGrams: Double -+ public let absorptionTime: TimeInterval -+ public let foodType: String? -+ public let startDate: Date -+ -+ public init(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String? = nil, startDate: Date) { -+ self.syncIdentifier = syncIdentifier -+ self.amountInGrams = amountInGrams -+ self.absorptionTime = absorptionTime -+ self.foodType = foodType -+ self.startDate = startDate -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -index 4457f80..aabb47f 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteNotificationResponseManager.swift -@@ -21,12 +21,14 @@ class RemoteNotificationResponseManager { - let commandStatus: String - let commandType: String - let timestamp: TimeInterval -+ let syncIdentifier: String? - - enum CodingKeys: String, CodingKey { - case aps - case commandStatus = "command_status" - case commandType = "command_type" - case timestamp -+ case syncIdentifier = "sync_identifier" - } - } - -@@ -43,6 +45,8 @@ class RemoteNotificationResponseManager { - enum CommandType: String { - case bolus = "bolus" - case carbs = "carbs" -+ case carbsDelete = "carbs_delete" -+ case carbsEdit = "carbs_edit" - case override = "override" - case cancelOverride = "cancel_override" - case profileSwitch = "profile_switch" -@@ -52,7 +56,8 @@ class RemoteNotificationResponseManager { - to returnInfo: ReturnNotificationInfo?, - commandType: CommandType, - success: Bool, -- message: String -+ message: String, -+ syncIdentifier: String? = nil - ) async { - guard let returnInfo = returnInfo else { - os_log("No return notification info provided, skipping response", log: log, type: .info) -@@ -75,7 +80,8 @@ class RemoteNotificationResponseManager { - ), - commandStatus: success ? "success" : "failed", - commandType: commandType.rawValue, -- timestamp: Date().timeIntervalSince1970 -+ timestamp: Date().timeIntervalSince1970, -+ syncIdentifier: syncIdentifier - ) - - await sendPushNotification( -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift -new file mode 100644 -index 0000000..eda9134 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift -@@ -0,0 +1,44 @@ -+// -+// CarbDeleteRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+public struct CarbDeleteRemoteNotification: RemoteNotification, Codable { -+ -+ public let syncIdentifier: String -+ public let remoteAddress: String -+ public let expiration: Date? -+ public let sentAt: Date -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ enum CodingKeys: String, CodingKey { -+ case syncIdentifier = "carbs-delete" -+ case remoteAddress = "remote-address" -+ case expiration = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ func toRemoteAction() -> Action { -+ return .carbsDelete(CarbDeleteAction(syncIdentifier: syncIdentifier)) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ func expirationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification["carbs-delete"] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift -new file mode 100644 -index 0000000..f1ffb51 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift -@@ -0,0 +1,58 @@ -+// -+// CarbEditRemoteNotification.swift -+// NightscoutServiceKit -+// -+ -+import Foundation -+import LoopKit -+ -+/// Replacement values use prefixed keys so an unpatched Loop never reads them as a new carb entry. -+public struct CarbEditRemoteNotification: RemoteNotification, Codable { -+ -+ public let syncIdentifier: String -+ public let amount: Double -+ public let absorptionInHours: Double -+ public let foodType: String? -+ public let startDate: Date -+ public let remoteAddress: String -+ public let expiration: Date? -+ public let sentAt: Date -+ public let otp: String? -+ public let enteredBy: String? -+ public let encryptedReturnNotification: String? -+ -+ enum CodingKeys: String, CodingKey { -+ case syncIdentifier = "carbs-edit" -+ case amount = "carbs-edit-entry" -+ case absorptionInHours = "carbs-edit-absorption-time" -+ case foodType = "carbs-edit-food-type" -+ case startDate = "carbs-edit-start-time" -+ case remoteAddress = "remote-address" -+ case expiration = "expiration" -+ case sentAt = "sent-at" -+ case otp = "otp" -+ case enteredBy = "entered-by" -+ case encryptedReturnNotification = "encrypted_return_notification" -+ } -+ -+ public func absorptionTime() -> TimeInterval { -+ return TimeInterval(hours: absorptionInHours) -+ } -+ -+ func toRemoteAction() -> Action { -+ let action = CarbEditAction(syncIdentifier: syncIdentifier, amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate) -+ return .carbsEdit(action) -+ } -+ -+ func otpValidationRequired() -> Bool { -+ return true -+ } -+ -+ func expirationRequired() -> Bool { -+ return true -+ } -+ -+ public static func includedInNotification(_ notification: [String: Any]) -> Bool { -+ return notification["carbs-edit"] != nil -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index a0f58a0..1ea8b96 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -22,12 +22,22 @@ protocol RemoteNotification: Codable { - - func toRemoteAction() -> Action - func otpValidationRequired() -> Bool -+ func expirationRequired() -> Bool - func getReturnNotificationInfo() -> ReturnNotificationInfo? - - static func includedInNotification(_ notification: [String: Any]) -> Bool - } - -+/// Followers read this to know which remote commands this build accepts. -+enum RemoteNotificationCapabilities { -+ static let supported: [String] = ["carbs-delete", "carbs-edit"] -+} -+ - extension RemoteNotification { -+ -+ func expirationRequired() -> Bool { -+ return false -+ } - - var id: String { - //There is no unique identifier so we use the sent date -@@ -98,6 +108,10 @@ extension Dictionary { - func toRemoteNotification() throws -> RemoteNotification { - if BolusRemoteNotification.includedInNotification(self) { - return try BolusRemoteNotification(dictionary: self) -+ } else if CarbDeleteRemoteNotification.includedInNotification(self) { -+ return try CarbDeleteRemoteNotification(dictionary: self) -+ } else if CarbEditRemoteNotification.includedInNotification(self) { -+ return try CarbEditRemoteNotification(dictionary: self) - } else if CarbRemoteNotification.includedInNotification(self) { - return try CarbRemoteNotification(dictionary: self) - } else if OverrideRemoteNotification.includedInNotification(self) { -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -index fce4623..5be6e1c 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -@@ -16,6 +16,11 @@ struct RemoteCommandValidator { - } - - func validate(remoteNotification: RemoteNotification) throws { -+ if remoteNotification.expirationRequired() { -+ guard remoteNotification.expiration != nil else { -+ throw NotificationValidationError.missingExpiration -+ } -+ } - try validateExpirationDate(remoteNotification: remoteNotification) - if remoteNotification.otpValidationRequired() { - try validateOTP(remoteNotification: remoteNotification) -@@ -45,11 +50,14 @@ struct RemoteCommandValidator { - enum NotificationValidationError: LocalizedError { - case missingOTP - case expiredNotification(sentDate: Date, receivedDate: Date) -+ case missingExpiration - - var errorDescription: String? { - switch self { - case .missingOTP: - return LocalizedString("Missing OTP", comment: "Remote command error description: Missing OTP.") -+ case .missingExpiration: -+ return LocalizedString("Missing expiration", comment: "Remote command error description: missing expiration.") - case .expiredNotification(let sentDate, let receivedDate): - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "h:mm:ss a" -diff --git a/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..f03768c ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift -@@ -0,0 +1,61 @@ -+// -+// CarbDeleteRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+@testable import NightscoutServiceKit -+ -+final class CarbDeleteRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseCarbDeleteNotification_ValidPayload_Succeeds() throws { -+ let syncIdentifier = "3F2504E0-4F89-11D3-9A0C-0305E82C3301" -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-delete": syncIdentifier as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ let remoteNotification = try notification.toRemoteNotification() -+ -+ let deleteNotification = try XCTUnwrap(remoteNotification as? CarbDeleteRemoteNotification) -+ XCTAssertEqual(deleteNotification.syncIdentifier, syncIdentifier) -+ XCTAssertEqual(deleteNotification.otp, "12345") -+ XCTAssertTrue(deleteNotification.otpValidationRequired()) -+ XCTAssertTrue(deleteNotification.expirationRequired()) -+ guard case .carbsDelete(let action) = deleteNotification.toRemoteAction() else { -+ return XCTFail("Expected carbsDelete action") -+ } -+ XCTAssertEqual(action.syncIdentifier, syncIdentifier) -+ } -+ -+ func testValidate_MissingExpiration_Throws() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "carbs-delete": "abc" as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ let remoteNotification = try notification.toRemoteNotification() -+ let validator = RemoteCommandValidator(otpManager: OTPTestCoordinator().createOTPManager()) -+ -+ XCTAssertThrowsError(try validator.validate(remoteNotification: remoteNotification)) { error in -+ guard case RemoteCommandValidator.NotificationValidationError.missingExpiration = error else { -+ return XCTFail("Unexpected error \(error)") -+ } -+ } -+ } -+ -+ func testParseCarbDeleteNotification_MissingSentAt_Throws() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "expiration": "2099-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-delete": "abc" as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ XCTAssertThrowsError(try notification.toRemoteNotification()) -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift -new file mode 100644 -index 0000000..989f7c8 ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/CarbEditRemoteNotificationTestCase.swift -@@ -0,0 +1,108 @@ -+// -+// CarbEditRemoteNotificationTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+@testable import NightscoutServiceKit -+ -+final class CarbEditRemoteNotificationTestCase: XCTestCase { -+ -+ func testParseCarbEditNotification_ValidPayload_Succeeds() throws { -+ let syncIdentifier = "3F2504E0-4F89-11D3-9A0C-0305E82C3301" -+ let startDateString = "2023-02-25T20:46:35.778Z" -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-edit": syncIdentifier as AnyObject, -+ "carbs-edit-entry": 22.0 as AnyObject, -+ "carbs-edit-absorption-time": 3.0 as AnyObject, -+ "carbs-edit-food-type": "🍕" as AnyObject, -+ "carbs-edit-start-time": startDateString as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ let remoteNotification = try notification.toRemoteNotification() -+ -+ let editNotification = try XCTUnwrap(remoteNotification as? CarbEditRemoteNotification) -+ XCTAssertEqual(editNotification.syncIdentifier, syncIdentifier) -+ XCTAssertEqual(editNotification.amount, 22.0) -+ XCTAssertEqual(editNotification.absorptionTime(), TimeInterval(hours: 3.0)) -+ XCTAssertEqual(editNotification.foodType, "🍕") -+ XCTAssertEqual(editNotification.startDate, dateFormatter().date(from: startDateString)) -+ XCTAssertTrue(editNotification.otpValidationRequired()) -+ XCTAssertTrue(editNotification.expirationRequired()) -+ guard case .carbsEdit(let action) = editNotification.toRemoteAction() else { -+ return XCTFail("Expected carbsEdit action") -+ } -+ XCTAssertEqual(action.syncIdentifier, syncIdentifier) -+ XCTAssertEqual(action.amountInGrams, 22.0) -+ XCTAssertEqual(action.absorptionTime, TimeInterval(hours: 3.0)) -+ XCTAssertEqual(action.startDate, dateFormatter().date(from: startDateString)) -+ } -+ -+ func testParseCarbEditNotification_FoodTypeAbsent_Succeeds() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-edit": "abc" as AnyObject, -+ "carbs-edit-entry": 10.0 as AnyObject, -+ "carbs-edit-absorption-time": 2.0 as AnyObject, -+ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ let editNotification = try XCTUnwrap(try notification.toRemoteNotification() as? CarbEditRemoteNotification) -+ XCTAssertNil(editNotification.foodType) -+ } -+ -+ func testParseCarbEditNotification_MissingStartTime_Throws() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-edit": "abc" as AnyObject, -+ "carbs-edit-entry": 10.0 as AnyObject, -+ "carbs-edit-absorption-time": 2.0 as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ XCTAssertThrowsError(try notification.toRemoteNotification()) -+ } -+ -+ func testParseCarbEditNotification_MissingAbsorptionTime_Throws() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "sent-at": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "expiration": "2023-02-25T20:51:35.778Z" as AnyObject, -+ "carbs-edit": "abc" as AnyObject, -+ "carbs-edit-entry": 10.0 as AnyObject, -+ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ XCTAssertThrowsError(try notification.toRemoteNotification()) -+ } -+ -+ func testParseCarbEditNotification_DoesNotDecodeAsCarbEntry() throws { -+ let notification: [String: AnyObject] = [ -+ "remote-address": "::ffff:11.2.44.155" as AnyObject, -+ "carbs-edit": "abc" as AnyObject, -+ "carbs-edit-entry": 10.0 as AnyObject, -+ "carbs-edit-absorption-time": 2.0 as AnyObject, -+ "carbs-edit-start-time": "2023-02-25T20:46:35.778Z" as AnyObject, -+ "otp": "12345" as AnyObject -+ ] -+ -+ XCTAssertFalse(CarbRemoteNotification.includedInNotification(notification)) -+ XCTAssertTrue(CarbEditRemoteNotification.includedInNotification(notification)) -+ } -+ -+ func dateFormatter() -> ISO8601DateFormatter { -+ let formatter = ISO8601DateFormatter() -+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] -+ return formatter -+ } -+} -diff --git a/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift b/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift -new file mode 100644 -index 0000000..e284d3f ---- /dev/null -+++ b/NightscoutService/NightscoutServiceKitTests/RemoteNotificationCapabilitiesTestCase.swift -@@ -0,0 +1,21 @@ -+// -+// RemoteNotificationCapabilitiesTestCase.swift -+// NightscoutServiceKitTests -+// -+ -+import XCTest -+@testable import NightscoutServiceKit -+ -+final class RemoteNotificationCapabilitiesTestCase: XCTestCase { -+ -+ func testSupportedCommands_MatchNotificationKeys() { -+ XCTAssertEqual(RemoteNotificationCapabilities.supported, ["carbs-delete", "carbs-edit"]) -+ XCTAssertTrue(CarbDeleteRemoteNotification.includedInNotification(["carbs-delete": "id"])) -+ XCTAssertTrue(CarbEditRemoteNotification.includedInNotification(["carbs-edit": "id"])) -+ for key in RemoteNotificationCapabilities.supported { -+ let notification: [String: Any] = [key: "id"] -+ XCTAssertTrue(CarbDeleteRemoteNotification.includedInNotification(notification) -+ || CarbEditRemoteNotification.includedInNotification(notification), key) -+ } -+ } -+} -diff --git a/remote_carb_edit.loopcustomization b/remote_carb_edit.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/remote_window/dev_remote_window_profiles.patch b/remote_window/dev_remote_window_profiles.patch deleted file mode 100644 index 2adb70e..0000000 --- a/remote_window/dev_remote_window_profiles.patch +++ /dev/null @@ -1,1782 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index 3b5f1de2..c253e88e 100644 ---- a/Loop/Loop/Managers/DeviceDataManager.swift -+++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -838,13 +838,14 @@ extension DeviceDataManager { - - // MARK: - Client API - extension DeviceDataManager { -- func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void = { _ in }) { -+ func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ result: Swift.Result) -> Void = { _ in }) { - guard let pumpManager = pumpManager else { -- completion(LoopError.configurationError(.pumpManager)) -+ completion(.failure(LoopError.configurationError(.pumpManager))) - return - } - -- self.loopManager.addRequestedBolus(DoseEntry(type: .bolus, startDate: Date(), value: units, unit: .units, isMutable: true)) { -+ let doseEntry = DoseEntry(type: .bolus, startDate: Date(), value: units, unit: .units, isMutable: true) -+ self.loopManager.addRequestedBolus(doseEntry) { - pumpManager.enactBolus(units: units, activationType: activationType) { (error) in - if let error = error { - self.log.error("%{public}@", String(describing: error)) -@@ -860,11 +861,23 @@ extension DeviceDataManager { - } - - self.loopManager.bolusRequestFailed(error) { -- completion(error) -+ completion(.failure(error)) - } - } else { - self.loopManager.bolusConfirmed() { -- completion(nil) -+ // The DoseEntry from the store is returned as it has the syncIdentifier set. -+ self.doseStore.getDoses(start: doseEntry.startDate, includeMutable: true) { result in -+ switch result { -+ case .success(let doses): -+ if let storedEntry = doses.filter({ $0.programmedUnits == doseEntry.programmedUnits }).first { -+ completion(.success(storedEntry)) -+ } else { -+ completion(.failure(EnactBolusError.storageFailure)) -+ } -+ case .failure: -+ completion(.failure(EnactBolusError.storageFailure)) -+ } -+ } - } - } - } -@@ -873,17 +886,17 @@ extension DeviceDataManager { - } - } - -- func enactBolus(units: Double, activationType: BolusActivationType) async throws { -+ func enactBolus(units: Double, activationType: BolusActivationType) async throws -> DoseEntry { - return try await withCheckedThrowingContinuation { continuation in -- enactBolus(units: units, activationType: activationType) { error in -- if let error = error { -- continuation.resume(throwing: error) -- return -- } -- continuation.resume() -+ enactBolus(units: units, activationType: activationType) { result in -+ continuation.resume(with: result) - } - } - } -+ -+ enum EnactBolusError: Error { -+ case storageFailure -+ } - - var pumpManagerStatus: PumpManagerStatus? { - return pumpManager?.status -@@ -1476,10 +1489,49 @@ extension Notification.Name { - - extension DeviceDataManager: ServicesManagerDosingDelegate { - -- func deliverBolus(amountInUnits: Double) async throws { -- try await enactBolus(units: amountInUnits, activationType: .manualNoRecommendation) -+ func deliverBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry { -+ let minTreatmentIntervalInMinutes = 10.0 -+ let boluses = try await doseStore.getBoluses(start: Date().addingTimeInterval(-.days(1))) -+ // Look back further for safety in case caregiver didn't know about other treatments before they send this (i.e. Upload delays) -+ let conflictStartDate = userCreatedDate.addingTimeInterval(-.minutes(minTreatmentIntervalInMinutes)) -+ let conflictingEntries = boluses.filter({$0.startDate >= conflictStartDate}) -+ let conflictingBolusAmount = conflictingEntries.map({$0.programmedUnits}).reduce(0, {$0 + $1}) -+ let adjustedDoseAmount = amountInUnits - conflictingBolusAmount -+ guard adjustedDoseAmount <= amountInUnits else { // Added safety check for the bolus math -+ throw BolusActionError.invalidCalculation -+ } -+ guard adjustedDoseAmount > 0.0 else { -+ throw BolusActionError.bolusAmountAdjustedToZero(amountInUnits, conflictStartDate) -+ } -+ return try await enactBolus(units: adjustedDoseAmount, activationType: .manualNoRecommendation) - } - -+ enum BolusActionError: LocalizedError { -+ case bolusAmountAdjustedToZero( _ requestedAmountInUnits: Double, _ conflictStartDate: Date) -+ case invalidCalculation -+ -+ var errorDescription: String? { -+ switch self { -+ case .bolusAmountAdjustedToZero(let amountInUnits, let conflictStartDate): -+ let treatmentAmountFormatted = Self.numberFormatter.string(from: HKQuantity(unit: .internationalUnit(), doubleValue: amountInUnits), unit: .internationalUnit()) ?? "" -+ return String(format: NSLocalizedString("All boluses delivered after %1$@ were subtracted from the requested bolus amount for safety. Those amount(s) exceed the requested amount of %2$@ so the remote bolus was not delivered.", comment: "Bolus error description: no remote bolus delivered."), conflictStartDate.formatted(date: .omitted, time: .complete), treatmentAmountFormatted) -+ case .invalidCalculation: -+ return String(format: NSLocalizedString("An invalid bolus calculation occurred.", comment: "Bolus error description: invalid calculation.")) -+ } -+ } -+ -+ static var numberFormatter: NumberFormatter = { -+ let formatter = NumberFormatter() -+ formatter.numberStyle = .decimal -+ return formatter -+ }() -+ -+ static var dateFormatter: DateFormatter = { -+ let formatter = DateFormatter() -+ formatter.timeStyle = .medium -+ return formatter -+ }() -+ } - } - - // MARK: - Critical Event Log Export -diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift -index d0d2b312..0a5c29eb 100644 ---- a/Loop/Loop/Managers/LoopDataManager.swift -+++ b/Loop/Loop/Managers/LoopDataManager.swift -@@ -2501,7 +2501,7 @@ extension LoopDataManager: ServicesManagerDelegate { - - //Overrides - -- func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws { -+ func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws -> TemporaryScheduleOverride { - - guard let preset = settings.overridePresets.first(where: { $0.name == name }) else { - throw EnactOverrideError.unknownPreset(name) -@@ -2514,11 +2514,27 @@ extension LoopDataManager: ServicesManagerDelegate { - } - - await enactOverride(remoteOverride) -+ return remoteOverride - } - - -- func cancelCurrentOverride() async throws { -+ func cancelCurrentOverride() async throws -> TemporaryScheduleOverride { -+ guard let currentOverride = settings.scheduleOverride else { -+ throw OverrideCancelError.overrideNotActive -+ } - await enactOverride(nil) -+ return currentOverride -+ } -+ -+ enum OverrideCancelError: LocalizedError { -+ case overrideNotActive -+ -+ var errorDescription: String? { -+ switch self { -+ case .overrideNotActive: -+ return String(format: NSLocalizedString("No overrides active for cancellation", comment: "Cancel Override error description: override not active for cancellation.")) -+ } -+ } - } - - func enactOverride(_ override: TemporaryScheduleOverride?) async { -@@ -2539,7 +2555,7 @@ extension LoopDataManager: ServicesManagerDelegate { - - //Carb Entry - -- func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { -+ func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry { - - let absorptionTime = absorptionTime ?? carbStore.defaultAbsorptionTimes.medium - if absorptionTime < LoopConstants.minCarbAbsorptionTime || absorptionTime > LoopConstants.maxCarbAbsorptionTime { -@@ -2554,6 +2570,19 @@ extension LoopDataManager: ServicesManagerDelegate { - throw CarbActionError.exceedsMaxCarbs - } - -+ let maxAllowedConflictingAmountInGrams = 0.0 // Not allowing any carbs -+ let minTreatmentIntervalInMinutes = 10.0 -+ let entries = try await carbStore.getCarbEntries(start: Date().addingTimeInterval(-.days(1)), end: Date().addingTimeInterval(.days(1))) -+ let conflictStartDate = userCreatedDate.addingTimeInterval(-.minutes(minTreatmentIntervalInMinutes)) // Look back a little further for added safety (i.e. Network delays) -+ let conflictingEntries = entries.filter({ entry in -+ let createdDate = entry.userCreatedDate ?? entry.startDate -+ return createdDate >= conflictStartDate -+ }) -+ let conflictingCarbAmount = conflictingEntries.map({$0.quantity.doubleValue(for: .gram())}).reduce(0, {$0 + $1}) -+ guard conflictingCarbAmount <= maxAllowedConflictingAmountInGrams else { -+ throw CarbActionError.conflictingTreatments(conflictingCarbAmount, minTreatmentIntervalInMinutes) -+ } -+ - if let startDate = startDate { - let maxStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryFutureTime) - let minStartDate = Date().addingTimeInterval(LoopConstants.maxCarbEntryPastTime) -@@ -2565,7 +2594,7 @@ extension LoopDataManager: ServicesManagerDelegate { - let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) - let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate ?? Date(), foodType: foodType, absorptionTime: absorptionTime) - -- let _ = try await devliverCarbEntry(candidateCarbEntry) -+ return try await deliverCarbEntry(candidateCarbEntry) - } - - enum CarbActionError: LocalizedError { -@@ -2574,9 +2603,10 @@ extension LoopDataManager: ServicesManagerDelegate { - case invalidStartDate(Date) - case exceedsMaxCarbs - case invalidCarbs -+ case conflictingTreatments(_ conflictingAmountInGrams: Double, _ minutesRequiredBetweenTreatments: TimeInterval) - - var errorDescription: String? { -- switch self { -+ switch self { - case .exceedsMaxCarbs: - return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Carb error description: carbs exceed maximum amount.") - case .invalidCarbs: -@@ -2587,6 +2617,10 @@ extension LoopDataManager: ServicesManagerDelegate { - case .invalidStartDate(let startDate): - let startDateFormatted = Self.dateFormatter.string(from: startDate) - return String(format: NSLocalizedString("Start time is out of range: %@", comment: "Carb error description: invalid start time is out of range."), startDateFormatted) -+ case .conflictingTreatments(let conflictingAmountInGrams, let minutesRequiredBetweenTreatments): -+ let treatmentAmountFormatted = Self.numberFormatter.string(from: HKQuantity(unit: .gram(), doubleValue: conflictingAmountInGrams), unit: .gram()) ?? "" -+ let minutesRequiredBetweenTreatmentsFormatted = Self.numberFormatter.string(from: minutesRequiredBetweenTreatments) ?? "" -+ return String(format: NSLocalizedString("Conflicting carb treatments (%1$@) have occurred. At least %2$@ minutes must pass between carb entries.", comment: "Carb error description: conflicting treatments occurred."), treatmentAmountFormatted, minutesRequiredBetweenTreatmentsFormatted) - } - } - -@@ -2604,7 +2638,7 @@ extension LoopDataManager: ServicesManagerDelegate { - } - - //Can't add this concurrency wrapper method to LoopKit due to the minimum iOS version -- func devliverCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry { -+ func deliverCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry { - return try await withCheckedThrowingContinuation { continuation in - carbStore.addCarbEntry(carbEntry) { result in - switch result { -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index c61eda65..a9d9177d 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -233,13 +233,13 @@ class ServicesManager { - } - - public protocol ServicesManagerDosingDelegate: AnyObject { -- func deliverBolus(amountInUnits: Double) async throws -+ func deliverBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry - } - - public protocol ServicesManagerDelegate: AnyObject { -- func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws -- func cancelCurrentOverride() async throws -- func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws -+ func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws -> TemporaryScheduleOverride -+ func cancelCurrentOverride() async throws -> TemporaryScheduleOverride -+ func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry - } - - // MARK: - StatefulPluggableDelegate -@@ -274,7 +274,10 @@ extension ServicesManager: ServiceDelegate { - return semanticVersion - } - -- func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws { -+ func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride { -+ guard let servicesManagerDelegate else { -+ throw OverrideActionError.internalError -+ } - - var duration: TemporaryScheduleOverride.Duration? = nil - if let durationTime = durationTime { -@@ -294,14 +297,16 @@ extension ServicesManager: ServiceDelegate { - } - } - -- try await servicesManagerDelegate?.enactOverride(name: name, duration: duration, remoteAddress: remoteAddress) -+ let override = try await servicesManagerDelegate.enactOverride(name: name, duration: duration, remoteAddress: remoteAddress) - await remoteDataServicesManager.triggerUpload(for: .overrides) -+ return override - } - - enum OverrideActionError: LocalizedError { - - case durationExceedsMax(TimeInterval) - case negativeDuration -+ case internalError - - var errorDescription: String? { - switch self { -@@ -309,29 +314,55 @@ extension ServicesManager: ServiceDelegate { - return String(format: NSLocalizedString("Duration exceeds: %1$.1f hours", comment: "Override error description: duration exceed max (1: max duration in hours)."), maxDurationTime.hours) - case .negativeDuration: - return String(format: NSLocalizedString("Negative duration not allowed", comment: "Override error description: negative duration error.")) -+ case .internalError: -+ return String(format: NSLocalizedString("Internal error", comment: "Override error description: Internal error.")) - } - } - } - -- func cancelRemoteOverride() async throws { -- try await servicesManagerDelegate?.cancelCurrentOverride() -+ func cancelRemoteOverride() async throws -> TemporaryScheduleOverride { -+ guard let servicesManagerDelegate else { -+ throw CarbActionError.internalError -+ } -+ -+ let cancelledOverride = try await servicesManagerDelegate.cancelCurrentOverride() - await remoteDataServicesManager.triggerUpload(for: .overrides) -+ return cancelledOverride - } - -- func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { -+ func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry { - do { -- try await servicesManagerDelegate?.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) -+ guard let servicesManagerDelegate else { -+ throw CarbActionError.internalError -+ } -+ -+ let carbEntry = try await servicesManagerDelegate.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate, userCreatedDate: userCreatedDate) - await NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: amountInGrams) - await remoteDataServicesManager.triggerUpload(for: .carb) - analyticsServicesManager.didAddCarbs(source: "Remote") -+ return carbEntry - } catch { - await NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: amountInGrams) - throw error - } - } - -- func deliverRemoteBolus(amountInUnits: Double) async throws { -+ enum CarbActionError: LocalizedError { -+ case internalError -+ -+ var errorDescription: String? { -+ switch self { -+ case .internalError: -+ return NSLocalizedString("Internal error", comment: "Carb error description: internal error.") -+ } -+ } -+ } -+ -+ func deliverRemoteBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry { - do { -+ guard let servicesManagerDosingDelegate else { -+ throw BolusActionError.internalError -+ } - - guard amountInUnits > 0 else { - throw BolusActionError.invalidBolus -@@ -345,10 +376,11 @@ extension ServicesManager: ServiceDelegate { - throw BolusActionError.exceedsMaxBolus - } - -- try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits) -- await NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) -+ let doseEntry = try await servicesManagerDosingDelegate.deliverBolus(amountInUnits: amountInUnits, userCreatedDate: userCreatedDate) -+ await NotificationManager.sendRemoteBolusNotification(amount: doseEntry.programmedUnits) - await remoteDataServicesManager.triggerUpload(for: .dose) - analyticsServicesManager.didBolus(source: "Remote") -+ return doseEntry - } catch { - await NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) - throw error -@@ -360,6 +392,7 @@ extension ServicesManager: ServiceDelegate { - case invalidBolus - case missingMaxBolus - case exceedsMaxBolus -+ case internalError - - var errorDescription: String? { - switch self { -@@ -369,6 +402,8 @@ extension ServicesManager: ServiceDelegate { - return NSLocalizedString("Missing maximum allowed bolus in settings", comment: "Bolus error description: missing maximum bolus in settings.") - case .exceedsMaxBolus: - return NSLocalizedString("Exceeds maximum allowed bolus in settings", comment: "Bolus error description: bolus exceeds maximum bolus in settings.") -+ case .internalError: -+ return NSLocalizedString("Internal error", comment: "Bolus error description: internal error.") - } - } - } -diff --git a/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift -index a7ffef2e..41221127 100644 ---- a/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift -+++ b/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift -@@ -51,6 +51,8 @@ protocol CarbStoreProtocol: AnyObject { - func getTotalCarbs(since start: Date, completion: @escaping (_ result: CarbStoreResult) -> Void) - - func deleteCarbEntry(_ entry: StoredCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) -+ -+ func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] - } - - extension CarbStore: CarbStoreProtocol { } -diff --git a/Loop/Loop/View Models/BolusEntryViewModel.swift b/Loop/Loop/View Models/BolusEntryViewModel.swift -index 20735e7f..4f2691a4 100644 ---- a/Loop/Loop/View Models/BolusEntryViewModel.swift -+++ b/Loop/Loop/View Models/BolusEntryViewModel.swift -@@ -29,7 +29,7 @@ protocol BolusEntryViewModelDelegate: AnyObject { - - func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) - -- func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void) -+ func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ result: Swift.Result) -> Void) - - func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (_ samples: Swift.Result<[StoredGlucoseSample], Error>) -> Void) - -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit/InsulinKit/DoseStore.swift b/LoopKit/LoopKit/InsulinKit/DoseStore.swift -index 712f9048..be7c17dd 100644 ---- a/LoopKit/LoopKit/InsulinKit/DoseStore.swift -+++ b/LoopKit/LoopKit/InsulinKit/DoseStore.swift -@@ -1274,8 +1274,18 @@ extension DoseStore { - public func getDoses(start: Date? = nil, end: Date? = nil) async throws -> [DoseEntry] { - return try await insulinDeliveryStore.getDoses(start: start, end: end) - } -- -- -+ -+ /// Retrieves doses overlapping supplied range -+ /// -+ /// - Parameters: -+ /// - start: The earliest date of dose entries to retrieve. -+ /// - end: The latest date of dose entries to retrieve, if provided. -+ /// - includeMutable: Whether to include mutable dose entries or not. -+ /// - completion: A closure called once the dose entries have been retrieved. -+ /// - result: An array of dose entries, in chronological order by startDate, or error. -+ public func getDoses(start: Date, end: Date? = nil, includeMutable: Bool, completion: @escaping (_ result: Swift.Result<[DoseEntry], Error>) -> Void) { -+ insulinDeliveryStore.getDoseEntries(start: start, end: end, includeMutable: includeMutable, completion: completion) -+ } - - /// Retrieves the maximum insulin on-board value from the two timeline values nearest to the specified date - /// -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 00fe25ab..6f691598 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -15,8 +15,8 @@ public protocol RemoteProfileActionDelegate: AnyObject { - } - - public protocol RemoteActionDelegate: AnyObject { -- func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -- func cancelRemoteOverride() async throws -- func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws -- func deliverRemoteBolus(amountInUnits: Double) async throws -+ func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride -+ func cancelRemoteOverride() async throws -> TemporaryScheduleOverride -+ func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry -+ func deliverRemoteBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry - } -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/NightscoutUploader.swift b/NightscoutService/NightscoutServiceKit/Extensions/NightscoutUploader.swift -index 4a85888..fa35651 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/NightscoutUploader.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/NightscoutUploader.swift -@@ -117,25 +117,27 @@ extension NightscoutClient { - - extension NightscoutClient { - -- func createDoses(_ data: [DoseEntry], usingObjectIdCache objectIdCache: ObjectIdCache, completion: @escaping (Result<[String], Error>) -> Void) { -- guard !data.isEmpty else { -- completion(.success([])) -- return -- } -- -- let source = "loop://\(UIDevice.current.name)" -- -+ func createDoses( -+ _ data: [DoseEntry], -+ sourceMessage: (DoseEntry) -> String, -+ usingObjectIdCache objectIdCache: ObjectIdCache, -+ completion: @escaping (Result<[String], Error>) -> Void -+ ) { - let treatments = data.compactMap { (dose) -> NightscoutTreatment? in - var objectId: String? = nil -- -+ - if let syncIdentifier = dose.syncIdentifier { - objectId = objectIdCache.findObjectIdBySyncIdentifier(syncIdentifier) - } -- -- return dose.treatment(enteredBy: source, withObjectId: objectId) -+ -+ return dose.treatment(enteredBy: sourceMessage(dose), withObjectId: objectId) - } -- -- -+ -+ guard !treatments.isEmpty else { -+ completion(.success([])) -+ return -+ } -+ - self.upload(treatments) { (result) in - switch result { - case .failure(let error): -diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index ef69236..7adb37d 100644 ---- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift -+++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -65,10 +65,12 @@ public final class NightscoutService: Service { - - private let log = OSLog(category: "NightscoutService") - -+ private let notificationExpirationInMinutes = 10.0 -+ - public init() { - self.isOnboarded = false - self.lockedObjectIdCache = Locked(ObjectIdCache()) -- self.otpManager = OTPManager(secretStore: KeychainManager()) -+ self.otpManager = OTPManager(secretStore: KeychainManager(), maxMinutesValid: notificationExpirationInMinutes) - self.commandSourceV1 = RemoteCommandSourceV1(otpManager: otpManager) - self.commandSourceV1.delegate = self - } -@@ -83,11 +85,11 @@ public final class NightscoutService: Service { - } else { - self.lockedObjectIdCache = Locked(ObjectIdCache()) - } -- -- self.otpManager = OTPManager(secretStore: KeychainManager()) -+ -+ self.otpManager = OTPManager(secretStore: KeychainManager(), maxMinutesValid: notificationExpirationInMinutes) - self.commandSourceV1 = RemoteCommandSourceV1(otpManager: otpManager) - self.commandSourceV1.delegate = self -- -+ - restoreCredentials() - } - -@@ -238,12 +240,35 @@ extension NightscoutService: RemoteDataService { - public var doseDataLimit: Int? { return 1000 } - - public func uploadDoseData(created: [DoseEntry], deleted: [DoseEntry], completion: @escaping (_ result: Result) -> Void) { -+ Task { -+ let notificationHistory = await notificationHistory() -+ uploadDoseData(created: created, deleted: deleted, notificationHistory: notificationHistory, completion: completion) -+ -+ // Upload pending stored notifications -+ await uploadPendingNotifications() -+ } -+ } -+ -+ func uploadPendingNotifications() async { -+ await commandSourceV1.uploadPendingNotifications() -+ } -+ -+ public func uploadDoseData(created: [DoseEntry], deleted: [DoseEntry], notificationHistory: [StoredRemoteNotification], completion: @escaping (_ result: Result) -> Void) { - guard hasConfiguration, let uploader = uploader else { - completion(.success(true)) - return - } - -- uploader.createDoses(created, usingObjectIdCache: self.objectIdCache) { (result) in -+ let deviceName = UIDevice.current.name -+ let sourceMessage: (DoseEntry) -> String = { (dose) -> String in -+ if notificationHistory.contains(where: { $0.containsDose(dose) }) { -+ return "Loop (via remote command)" -+ } else { -+ return "loop://\(deviceName)" -+ } -+ } -+ -+ uploader.createDoses(created, sourceMessage: sourceMessage, usingObjectIdCache: objectIdCache) { (result) in - switch (result) { - case .failure(let error): - completion(.failure(error)) -@@ -399,10 +424,34 @@ extension NightscoutService: RemoteDataService { - - - public func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async throws { -+ guard let serviceDelegate else { -+ return -+ } -+ -+ // Set the expiration to longer duration. Ideally this would be done in Nightscout instead. -+ var notification = notification -+ let dateFormatter = DateFormatter.iso8601DateDecoder -+ if let sentDateString = notification["sent-at"] as? String, let sentDate = dateFormatter.date(from: sentDateString) { -+ let expirationDate = sentDate.addingTimeInterval(60 * notificationExpirationInMinutes) -+ notification["expiration"] = dateFormatter.string(from: expirationDate) as AnyObject -+ } -+ - let commandSource = try commandSource(notification: notification) -- await commandSource.remoteNotificationWasReceived(notification) -+ await commandSource.remoteNotificationWasReceived(notification, serviceDelegate: serviceDelegate) - } -- -+ -+ public func notificationHistory() async -> [StoredRemoteNotification] { -+ return await commandSourceV1.notificationHistory() -+ } -+ -+ public func notificationPublisher() async -> AsyncStream<[StoredRemoteNotification]> { -+ return await commandSourceV1.notificationPublisher() -+ } -+ -+ public func deleteNotificationHistory() { -+ commandSourceV1.deleteNotificationHistory() -+ } -+ - private func commandSource(notification: [String: AnyObject]) throws -> RemoteCommandSource { - return commandSourceV1 - } -@@ -410,121 +459,29 @@ extension NightscoutService: RemoteDataService { - } - - extension NightscoutService: RemoteCommandSourceV1Delegate { -- -- func commandSourceV1(_: RemoteCommandSourceV1, handleAction action: Action, remoteNotification: RemoteNotification) async throws { -- -- let returnInfo = remoteNotification.getReturnNotificationInfo() -- if returnInfo == nil { -- os_log("No return notification info available, response will not be sent", log: .default, type: .info) -- } else { -- os_log("Return notification info available, will send response after command processing", log: .default, type: .info) -- } -- -- var commandType: RemoteNotificationResponseManager.CommandType = .bolus // Default, will be set in switch -- var success = false -- var message = "" -- -- do { -- switch action { -- case .temporaryScheduleOverride(let overrideCommand): -- commandType = .override -- try await self.serviceDelegate?.enactRemoteOverride( -- name: overrideCommand.name, -- durationTime: overrideCommand.durationTime, -- remoteAddress: overrideCommand.remoteAddress -- ) -- success = true -- message = "Override '\(overrideCommand.name)' enacted successfully" -- -- case .profileSwitch(let profileSwitchCommand): -- commandType = .profileSwitch -- guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { -- throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -- } -- do { -- try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -- let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -- guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -- throw RemoteProfileSwitchError.profileChanged -- } -- } -- } catch { -- throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -- } -- success = true -- message = "Profile '\(profileSwitchCommand.name)' activated" -- -- case .cancelTemporaryOverride: -- commandType = .cancelOverride -- try await self.serviceDelegate?.cancelRemoteOverride() -- success = true -- message = "Override cancelled successfully" -- -- case .bolusEntry(let bolusCommand): -- commandType = .bolus -- try await self.serviceDelegate?.deliverRemoteBolus(amountInUnits: bolusCommand.amountInUnits) -- success = true -- message = String(format: "Bolus of %.2f units delivered successfully", bolusCommand.amountInUnits) -- -- case .carbsEntry(let carbCommand): -- commandType = .carbs -- try await self.serviceDelegate?.deliverRemoteCarbs( -- amountInGrams: carbCommand.amountInGrams, -- absorptionTime: carbCommand.absorptionTime, -- foodType: carbCommand.foodType, -- startDate: carbCommand.startDate -- ) -- success = true -- message = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) -- } -- } catch { -- message = "Command failed: \(error.localizedDescription)" -- // Send failure response before rethrowing -- if let returnInfo = returnInfo { -- await RemoteNotificationResponseManager.shared.sendResponseNotification( -- to: returnInfo, -- commandType: commandType, -- success: false, -- message: message -- ) -- } -- throw error -- } -- -- // Send success response -- if let returnInfo = returnInfo { -- await RemoteNotificationResponseManager.shared.sendResponseNotification( -- to: returnInfo, -- commandType: commandType, -- success: success, -- message: message -- ) -- } -- } -- -- func commandSourceV1(_: RemoteCommandSourceV1, uploadError error: Error, notification: [String: AnyObject]) async throws { -- -+ func commandSourceV1(_: RemoteCommandSourceV1, uploadError errorMessage: String, receivedDate: Date, notification: [String: AnyObject]) async throws { -+ - guard let uploader = self.uploader else {throw NightscoutServiceError.missingCredentials} - var commandDescription = "Loop Remote Action Error" - if let remoteNotification = try? notification.toRemoteNotification() { - commandDescription = remoteNotification.toRemoteAction().description - } -- -+ - let notificationJSON = try JSONSerialization.data(withJSONObject: notification) - let notificationJSONString = String(data: notificationJSON, encoding: .utf8) ?? "" -- -+ - let noteBody = """ -- \(error.localizedDescription) -+ \(errorMessage) - \(notificationJSONString) - """ - - let treatment = NightscoutTreatment( -- timestamp: Date(), -+ timestamp: receivedDate, - enteredBy: commandDescription, - notes: noteBody, - eventType: .note - ) -- -+ - return try await withCheckedThrowingContinuation { continuation in - uploader.upload([treatment], completionHandler: { result in - switch result { -diff --git a/NightscoutService/NightscoutServiceKit/OTPManager.swift b/NightscoutService/NightscoutServiceKit/OTPManager.swift -index 3d51b72..7a572d3 100644 ---- a/NightscoutService/NightscoutServiceKit/OTPManager.swift -+++ b/NightscoutService/NightscoutServiceKit/OTPManager.swift -@@ -77,8 +77,19 @@ public class OTPManager { - let maxOTPsToAccept: Int - - public static var defaultTokenPeriod: TimeInterval = 30 -- public static var defaultMaxOTPsToAccept = 2 -- -+ public static var defaultMaxOTPsToAccept = 30 -+ -+ public init(secretStore: OTPSecretStore = KeychainManager(), nowDateSource: @escaping () -> Date = {Date()}, maxMinutesValid: Double) { -+ self.secretStore = secretStore -+ self.nowDateSource = nowDateSource -+ self.tokenPeriod = OTPManager.defaultTokenPeriod -+ let secondsValid = 60.0 * maxMinutesValid -+ self.maxOTPsToAccept = Int(secondsValid / OTPManager.defaultTokenPeriod) -+ if secretStore.tokenSecretKey() == nil || secretStore.tokenSecretKeyName() == nil { -+ resetSecretKey() -+ } -+ } -+ - public init(secretStore: OTPSecretStore = KeychainManager(), nowDateSource: @escaping () -> Date = {Date()}, tokenPeriod: TimeInterval = OTPManager.defaultTokenPeriod, maxOTPsToAccept: Int = OTPManager.defaultMaxOTPsToAccept) { - self.secretStore = secretStore - self.nowDateSource = nowDateSource -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift -index 3a56d81..6db615c 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift -@@ -9,10 +9,12 @@ - import Foundation - - public struct BolusAction: Codable { -- -+ - public let amountInUnits: Double -- -- public init(amountInUnits: Double) { -+ public let userCreatedDate: Date -+ -+ public init(amountInUnits: Double, userCreatedDate: Date) { - self.amountInUnits = amountInUnits -+ self.userCreatedDate = userCreatedDate - } - } -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift -index 3865989..b315b16 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift -@@ -14,11 +14,13 @@ public struct CarbAction: Codable{ - public let absorptionTime: TimeInterval? - public let foodType: String? - public let startDate: Date? -- -- public init(amountInGrams: Double, absorptionTime: TimeInterval? = nil, foodType: String? = nil, startDate: Date? = nil) { -+ public let userCreatedDate: Date -+ -+ public init(amountInGrams: Double, absorptionTime: TimeInterval? = nil, foodType: String? = nil, startDate: Date? = nil, userCreatedDate: Date) { - self.amountInGrams = amountInGrams - self.absorptionTime = absorptionTime - self.foodType = foodType - self.startDate = startDate -+ self.userCreatedDate = userCreatedDate - } - } -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteCommandSource.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteCommandSource.swift -index e3cea7f..eb57fac 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteCommandSource.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteCommandSource.swift -@@ -9,5 +9,6 @@ - import LoopKit - - protocol RemoteCommandSource { -- func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async -+ func remoteNotificationWasReceived(_ notification: [String: AnyObject], serviceDelegate: ServiceDelegate) async -+ func notificationHistory() async -> [StoredRemoteNotification] - } -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift -index 1c14cdf..869abc2 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift -@@ -14,11 +14,11 @@ public struct BolusRemoteNotification: RemoteNotification, Codable { - public let amount: Double - public let remoteAddress: String - public let expiration: Date? -- public let sentAt: Date? -+ public let sentAt: Date - public let otp: String? - public let enteredBy: String? - public let encryptedReturnNotification: String? -- -+ - enum CodingKeys: String, CodingKey { - case remoteAddress = "remote-address" - case amount = "bolus-entry" -@@ -28,9 +28,13 @@ public struct BolusRemoteNotification: RemoteNotification, Codable { - case enteredBy = "entered-by" - case encryptedReturnNotification = "encrypted_return_notification" - } -- -+ - func toRemoteAction() -> Action { -- return .bolusEntry(BolusAction(amountInUnits: amount)) -+ return .bolusEntry(toBolusAction()) -+ } -+ -+ func toBolusAction() -> BolusAction { -+ return BolusAction(amountInUnits: amount, userCreatedDate: sentAt) - } - - func otpValidationRequired() -> Bool { -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift -index 6c238ed..c069c34 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift -@@ -17,7 +17,7 @@ public struct CarbRemoteNotification: RemoteNotification, Codable { - public let startDate: Date? - public let remoteAddress: String - public let expiration: Date? -- public let sentAt: Date? -+ public let sentAt: Date - public let otp: String? - public let enteredBy: String? - public let encryptedReturnNotification: String? -@@ -43,7 +43,7 @@ public struct CarbRemoteNotification: RemoteNotification, Codable { - } - - func toRemoteAction() -> Action { -- let action = CarbAction(amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate) -+ let action = CarbAction(amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate, userCreatedDate: sentAt) - return .carbsEntry(action) - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift -index fdd6d6b..babb71f 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift -@@ -13,7 +13,7 @@ public struct OverrideCancelRemoteNotification: RemoteNotification, Codable { - - public let remoteAddress: String - public let expiration: Date? -- public let sentAt: Date? -+ public let sentAt: Date - public let cancelOverride: String - public let enteredBy: String? - public let otp: String? -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift -index 2060a74..426ff89 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift -@@ -15,7 +15,7 @@ public struct OverrideRemoteNotification: RemoteNotification, Codable { - public let durationInMinutes: Double? - public let remoteAddress: String - public let expiration: Date? -- public let sentAt: Date? -+ public let sentAt: Date - public let enteredBy: String? - public let otp: String? - public let encryptedReturnNotification: String? -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -index 487c620..ebcd6ef 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -16,7 +16,7 @@ public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { - public let fingerprint: String - public let remoteAddress: String - public let expirationDate: Date -- public let sentAt: Date? -+ public let sentAt: Date - public let otp: String? - public let enteredBy: String? - public let encryptedReturnNotification: String? -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index 3362bf7..a0f58a0 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -14,7 +14,7 @@ protocol RemoteNotification: Codable { - - var id: String {get} - var expiration: Date? {get} -- var sentAt: Date? {get} -+ var sentAt: Date {get} - var otp: String? {get} - var remoteAddress: String {get} - var enteredBy: String? {get} -@@ -30,12 +30,8 @@ protocol RemoteNotification: Codable { - extension RemoteNotification { - - var id: String { -- //There is no unique identifier so we use the sent date when available -- if let sentAt = sentAt { -- return "\(sentAt.timeIntervalSince1970)" -- } else { -- return UUID().uuidString -- } -+ //There is no unique identifier so we use the sent date -+ return "\(sentAt.timeIntervalSince1970)" - } - - func getReturnNotificationInfo() -> ReturnNotificationInfo? { -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -index 0165004..0912feb 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -@@ -7,69 +7,509 @@ - // - - import Foundation -+import LoopKit - import OSLog - - class RemoteCommandSourceV1: RemoteCommandSource { -- -+ - weak var delegate: RemoteCommandSourceV1Delegate? - private let otpManager: OTPManager - private let log = OSLog(category: "Remote Command Source V1") - private var commandValidator: RemoteCommandValidator - private var recentNotifications = RecentNotifications() -- -+ - init(otpManager: OTPManager) { - self.otpManager = otpManager - self.commandValidator = RemoteCommandValidator(otpManager: otpManager) - } -- -+ - //MARK: RemoteCommandSource -- -- func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async { -- -- if let encryptedReturnNotification = notification["encrypted_return_notification"] { -- log.info("Found encrypted_return_notification in notification: %{public}@", String(describing: encryptedReturnNotification)) -- } else { -- log.info("No encrypted_return_notification found in notification. Available keys: %{public}@", Array(notification.keys).joined(separator: ", ")) -+ -+ func remoteNotificationWasReceived(_ notification: [String: AnyObject], serviceDelegate: ServiceDelegate) async { -+ -+ guard let remoteNotification = try? notification.toRemoteNotification() else { -+ log.error("Remote Notification: Malformed notification payload") -+ return -+ } -+ -+ guard await !recentNotifications.contains(pushIdentifier: remoteNotification.id) else { -+ // Duplicate notifications are expected after app is force killed -+ // https://github.com/LoopKit/Loop/issues/2174 -+ return -+ } -+ -+ // Extract APNS return info for sending responses back to the caregiver -+ let returnInfo = remoteNotification.getReturnNotificationInfo() -+ if returnInfo != nil { -+ log.info("Return notification info available, will send APNS response after command processing") - } -- -+ -+ var commandType: RemoteNotificationResponseManager.CommandType = .bolus -+ var successMessage = "" -+ - do { -- guard let delegate = delegate else {return} -- let remoteNotification = try notification.toRemoteNotification() -- -- // Log after parsing to see if the field was preserved -- if let encryptedReturnNotification = remoteNotification.encryptedReturnNotification { -- log.info("Parsed encrypted_return_notification successfully, length: %d", encryptedReturnNotification.count) -- } else { -- log.info("encrypted_return_notification is nil after parsing") -+ try await recentNotifications.trackReceivedRemoteNotification(remoteNotification, rawNotification: notification) -+ try commandValidator.validate(remoteNotification: remoteNotification) -+ -+ switch remoteNotification.toRemoteAction() { -+ case .bolusEntry(let bolusCommand): -+ commandType = .bolus -+ let doseEntry = try await serviceDelegate.deliverRemoteBolus( -+ amountInUnits: bolusCommand.amountInUnits, -+ userCreatedDate: bolusCommand.userCreatedDate -+ ) -+ var adjustmentMessage: String? = nil -+ if bolusCommand.amountInUnits > doseEntry.programmedUnits { -+ let quantityFormatter = QuantityFormatter(for: .internationalUnit()) -+ if let bolusAmountDescription = quantityFormatter.numberFormatter.string(from: bolusCommand.amountInUnits as NSNumber), -+ let doseAmountDescription = quantityFormatter.numberFormatter.string(from: doseEntry.programmedUnits as NSNumber){ -+ adjustmentMessage = "Bolus amount was reduced from \(bolusAmountDescription) U to \(doseAmountDescription) U due to other recent treatments." -+ } -+ } -+ successMessage = String(format: "Bolus of %.2f units delivered successfully", doseEntry.programmedUnits) -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .success(date: Date(), syncIdentifier: doseEntry.syncIdentifier ?? "", completionMessage: adjustmentMessage), -+ notificationJSON: notification -+ ) -+ case .cancelTemporaryOverride: -+ commandType = .cancelOverride -+ let cancelledOverride = try await serviceDelegate.cancelRemoteOverride() -+ successMessage = "Override cancelled successfully" -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .success(date: Date(), syncIdentifier: cancelledOverride.syncIdentifier.uuidString, completionMessage: nil), -+ notificationJSON: notification -+ ) -+ case .carbsEntry(let carbCommand): -+ commandType = .carbs -+ let carbEntry = try await serviceDelegate.deliverRemoteCarbs( -+ amountInGrams: carbCommand.amountInGrams, -+ absorptionTime: carbCommand.absorptionTime, -+ foodType: carbCommand.foodType, -+ startDate: carbCommand.startDate, -+ userCreatedDate: carbCommand.userCreatedDate -+ ) -+ successMessage = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .success(date: Date(), syncIdentifier: carbEntry.syncIdentifier ?? "", completionMessage: nil), -+ notificationJSON: notification -+ ) -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ successMessage = "Profile '\(profileSwitchCommand.name)' activated" -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .success(date: Date(), syncIdentifier: "", completionMessage: nil), -+ notificationJSON: notification -+ ) -+ case .temporaryScheduleOverride(let overrideCommand): -+ commandType = .override -+ let override = try await serviceDelegate.enactRemoteOverride( -+ name: overrideCommand.name, -+ durationTime: overrideCommand.durationTime, -+ remoteAddress: overrideCommand.remoteAddress -+ ) -+ successMessage = "Override '\(overrideCommand.name)' enacted successfully" -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .success(date: Date(), syncIdentifier: override.syncIdentifier.uuidString, completionMessage: nil), -+ notificationJSON: notification -+ ) - } -- -- guard await !recentNotifications.isDuplicate(remoteNotification) else { -- // Duplicate notifications are expected after app is force killed -- // https://github.com/LoopKit/Loop/issues/2174 -- return -+ -+ // Send APNS success response back to caregiver -+ if let returnInfo = returnInfo { -+ await RemoteNotificationResponseManager.shared.sendResponseNotification( -+ to: returnInfo, -+ commandType: commandType, -+ success: true, -+ message: successMessage -+ ) - } -- try commandValidator.validate(remoteNotification: remoteNotification) -- try await delegate.commandSourceV1(self, handleAction: remoteNotification.toRemoteAction(), remoteNotification: remoteNotification) - } catch { - log.error("Remote Notification: %{public}@. Error: %{public}@", String(describing: notification), String(describing: error)) -- try? await self.delegate?.commandSourceV1(self, uploadError: error, notification: notification) -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .failure(date: Date(), errorMessage: error.localizedDescription), -+ notificationJSON: notification -+ ) -+ -+ // Send APNS failure response back to caregiver -+ if let returnInfo = returnInfo { -+ await RemoteNotificationResponseManager.shared.sendResponseNotification( -+ to: returnInfo, -+ commandType: commandType, -+ success: false, -+ message: "Command failed: \(error.localizedDescription)" -+ ) -+ } -+ } -+ } -+ -+ func handleEnactmentCompletion( -+ remoteNotification: RemoteNotification, -+ status: RemoteNotificationStatus, -+ notificationJSON: [String: AnyObject] -+ ) async { -+ do { -+ let storedNotification = try await recentNotifications.updateStatus(status, for: remoteNotification.id) -+ await uploadStoredNotification(storedNotification) -+ } catch { -+ log.error("Remote Notification: %{public}@. Error: %{public}@", String(describing: notificationJSON), String(describing: error)) -+ } -+ } -+ -+ func uploadStoredNotification(_ storedNotification: StoredRemoteNotification) async { -+ do { -+ switch storedNotification.status { -+ case .success(_, _, let completionMessage): -+ if let completionMessage { -+ // Store adjustments as an error note to Nightscout -+ // try await self.delegate?.commandSourceV1(self, uploadError: completionMessage, receivedDate: storedNotification.receivedDate, notification: storedNotification.notificationJSON()) -+ } -+ case .failure(_, let errorMessage): -+ try await self.delegate?.commandSourceV1(self, uploadError: errorMessage, receivedDate: storedNotification.receivedDate, notification: storedNotification.notificationJSON()) -+ case .none: -+ return -+ } -+ try await recentNotifications.updateUploadStatus(true, for: storedNotification.pushIdentifier) -+ } catch { -+ log.error("Remote Notification: %{public}@. Error: %{public}@", String(describing: storedNotification), String(describing: error)) -+ } -+ } -+ -+ func notificationHistory() async -> [StoredRemoteNotification] { -+ return await recentNotifications.notifications -+ } -+ -+ /// Uploads pending notifications. Limited to a few at a time to avoid long background delays. -+ func uploadPendingNotifications() async { -+ guard let mostRecentPendingNotification = await notificationHistory().filter({$0.isPendingUpload}).sorted(by: {$0.receivedDate > $1.receivedDate}).last else { -+ return -+ } -+ await uploadStoredNotification(mostRecentPendingNotification) -+ } -+ -+ func notificationPublisher() async -> AsyncStream<[StoredRemoteNotification]> { -+ return await recentNotifications.notificationPublisher() -+ } -+ -+ func deleteNotificationHistory() { -+ Task { -+ await recentNotifications.deleteNotificationHistory() - } - } - } - - protocol RemoteCommandSourceV1Delegate: AnyObject { -- func commandSourceV1(_: RemoteCommandSourceV1, handleAction action: Action, remoteNotification: RemoteNotification) async throws -- func commandSourceV1(_: RemoteCommandSourceV1, uploadError error: Error, notification: [String: AnyObject]) async throws -+ func commandSourceV1(_: RemoteCommandSourceV1, uploadError errorMessage: String, receivedDate: Date, notification: [String: AnyObject]) async throws - } - --private actor RecentNotifications { -- private var recentNotifications = [RemoteNotification]() -- -- func isDuplicate(_ remoteNotification: RemoteNotification) -> Bool { -- if recentNotifications.contains(where: {remoteNotification.id == $0.id}) { -+// MARK: Notification history -+ -+public class StoredRemoteNotification: NSObject, Codable { -+ public let remoteNotificationType: RemoteNotificationType -+ public let notificationJSONData: Data -+ public var status: RemoteNotificationStatus? = nil -+ public var receivedDate: Date -+ public var uploaded: Bool = false -+ -+ init(notificationType: RemoteNotificationType, notificationJSONData: Data) { -+ self.remoteNotificationType = notificationType -+ self.notificationJSONData = notificationJSONData -+ self.receivedDate = Date() -+ self.uploaded = false -+ } -+ -+ convenience init(bolusNotification: BolusRemoteNotification, notificationJSONData: Data) { -+ self.init(notificationType: .bolus(bolusNotification), notificationJSONData: notificationJSONData) -+ } -+ -+ convenience init(carbNotification: CarbRemoteNotification, notificationJSONData: Data) { -+ self.init(notificationType: .carbs(carbNotification), notificationJSONData: notificationJSONData) -+ } -+ -+ convenience init(profileSwitchNotification: ProfileSwitchRemoteNotification, notificationJSONData: Data) { -+ self.init(notificationType: .profileSwitch(profileSwitchNotification), notificationJSONData: notificationJSONData) -+ } -+ -+ convenience init(overrideNotification: OverrideRemoteNotification, notificationJSONData: Data) { -+ self.init(notificationType: .override(overrideNotification), notificationJSONData: notificationJSONData) -+ } -+ -+ convenience init(overrideCancelNotification: OverrideCancelRemoteNotification, notificationJSONData: Data) { -+ self.init(notificationType: .overrideCancel(overrideCancelNotification), notificationJSONData: notificationJSONData) -+ } -+ -+ public var pushIdentifier: String { -+ return remoteNotification().id -+ } -+ -+ var isPendingUpload: Bool { -+ guard !uploaded else { -+ return false -+ } -+ switch status { -+ case .success, .failure: - return true -+ case nil: -+ return false -+ } -+ } -+ -+ func containsDose(_ dose: DoseEntry) -> Bool { -+ guard case let .bolus(bolusNotification) = remoteNotificationType else { -+ return false -+ } -+ -+ if case let .success(_, syncIdentifier: syncIdentifier, _) = status { -+ return dose.syncIdentifier == syncIdentifier -+ } -+ -+ // If sync identifier not set yet, that could mean either a failure occurred -+ // or we are in the middle of processing this notification. -+ // Doses start uploading during remote bolus action, -+ // before syncIdentifier is set to StoredRemoteAction. -+ // Heuristics are used to match the dose. -+ guard isDateWithinEnactmentPeriod(dose.startDate) else { -+ return false - } -- recentNotifications.append(remoteNotification) -- return false -+ -+ return dose.programmedUnits <= bolusNotification.amount -+ } -+ -+ func isDateWithinEnactmentPeriod(_ date: Date) -> Bool { -+ guard date.isAfterOrEqual(otherDate: receivedDate) else { -+ return false -+ } -+ -+ guard let completionDate else { -+ // Either enactment is in progress or there was an app crash during enactment -+ // For the corner case of an app crash, we want only want to match if the -+ // received date is within a few minutes. It should be within seconds really -+ // but minutes help when paused in the Xcode debugger. -+ return date.timeIntervalSince(receivedDate) < 60 * 5 -+ } -+ -+ guard date.isBeforeOrEqual(otherDate: completionDate) else { -+ return false -+ } -+ -+ return true -+ } -+ -+ var completionDate: Date? { -+ guard let status else { -+ return nil -+ } -+ switch status { -+ case .success(let completionDate, _, _): -+ return completionDate -+ case .failure(let completionDate, _): -+ return completionDate -+ } -+ } -+ -+ func remoteNotification() -> RemoteNotification { -+ switch remoteNotificationType { -+ case let .bolus(bolusNotification): -+ return bolusNotification -+ case let .carbs(carbNotification): -+ return carbNotification -+ case let .profileSwitch(profileSwitchNotification): -+ return profileSwitchNotification -+ case let .override(overrideNotification): -+ return overrideNotification -+ case let .overrideCancel(overrideCancelNotification): -+ return overrideCancelNotification -+ } -+ } -+ -+ public func remoteAction() -> Action { -+ return remoteNotification().toRemoteAction() -+ } -+ -+ func notificationJSON() throws -> [String: AnyObject] { -+ let jsonObject = try JSONSerialization.jsonObject(with: notificationJSONData, options: []) -+ guard let notificationJSON = jsonObject as? [String: AnyObject] else { -+ throw StoredRemoteNotificationError.notificationJSONTypeIncorrect -+ } -+ return notificationJSON -+ } -+ -+ enum StoredRemoteNotificationError: Error { -+ case notificationJSONTypeIncorrect -+ } -+ -+ public enum RemoteNotificationType: Codable { -+ case bolus(BolusRemoteNotification) -+ case carbs(CarbRemoteNotification) -+ case override(OverrideRemoteNotification) -+ case overrideCancel(OverrideCancelRemoteNotification) -+ case profileSwitch(ProfileSwitchRemoteNotification) -+ } -+} -+ -+public enum RemoteNotificationStatus: Codable, Equatable { -+ case success(date: Date, syncIdentifier: String, completionMessage: String?) -+ case failure(date: Date, errorMessage: String) -+} -+ -+actor RecentNotifications { -+ var notifications: [StoredRemoteNotification] = [] -+ private var continuation: AsyncStream<[StoredRemoteNotification]>.Continuation? -+ -+ init() { -+ Task { -+ await loadNotifications() -+ } -+ } -+ -+ // Publish -+ -+ func notificationPublisher() -> AsyncStream<[StoredRemoteNotification]> { -+ return AsyncStream { continuation in -+ self.continuation = continuation -+ continuation.yield(notifications) -+ } -+ } -+ -+ private func publish(notifications: [StoredRemoteNotification]) { -+ self.notifications = notifications -+ continuation?.yield(notifications) -+ } -+ -+ // Misc -+ -+ func contains(pushIdentifier: String) -> Bool { -+ return storedNotification(for: pushIdentifier) != nil -+ } -+ -+ func storedNotification(for id: String) -> StoredRemoteNotification? { -+ return notifications.first(where: {$0.pushIdentifier == id}) -+ } -+ -+ func trackReceivedRemoteNotification(_ remoteNotification: RemoteNotification, rawNotification: [String : AnyObject]) throws { -+ let data = try JSONSerialization.data(withJSONObject: rawNotification, options: []) -+ if let bolusNotification = remoteNotification as? BolusRemoteNotification { -+ let storedNotification = StoredRemoteNotification(bolusNotification: bolusNotification, notificationJSONData: data) -+ try storeNotification(storedNotification) -+ } else if let carbNotification = remoteNotification as? CarbRemoteNotification { -+ let storedNotification = StoredRemoteNotification(carbNotification: carbNotification, notificationJSONData: data) -+ try storeNotification(storedNotification) -+ } else if let profileSwitchNotification = remoteNotification as? ProfileSwitchRemoteNotification { -+ let storedNotification = StoredRemoteNotification(profileSwitchNotification: profileSwitchNotification, notificationJSONData: data) -+ try storeNotification(storedNotification) -+ } else if let overrideNotification = remoteNotification as? OverrideRemoteNotification { -+ let storedNotification = StoredRemoteNotification(overrideNotification: overrideNotification, notificationJSONData: data) -+ try storeNotification(storedNotification) -+ } else if let overrideCancelNotification = remoteNotification as? OverrideCancelRemoteNotification { -+ let storedNotification = StoredRemoteNotification(overrideCancelNotification: overrideCancelNotification, notificationJSONData: data) -+ try storeNotification(storedNotification) -+ } else { -+ fatalError() -+ } -+ } -+ -+ func updateStatus(_ status: RemoteNotificationStatus, for pushIdentifier: String) throws -> StoredRemoteNotification { -+ guard let storedNotification = storedNotification(for: pushIdentifier) else { -+ throw RemoteNotificationError.notificationNotFound(pushIdentifier) -+ } -+ storedNotification.status = status -+ try storeNotification(storedNotification) -+ return storedNotification -+ } -+ -+ func updateUploadStatus(_ uploaded: Bool, for pushIdentifier: String) throws { -+ guard let storedNotification = storedNotification(for: pushIdentifier) else { -+ throw RemoteNotificationError.notificationNotFound(pushIdentifier) -+ } -+ storedNotification.uploaded = uploaded -+ try storeNotification(storedNotification) -+ } -+ -+ // Disk Storage -+ -+ static let maxStoredNotifications = 50 -+ -+ static var storageURL: URL { -+ let appSupportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! -+ return appSupportDir.appendingPathComponent("remote_notifications.json") -+ } -+ -+ private func storeNotification(_ notification: StoredRemoteNotification) throws { -+ if let existingIndex = notifications.firstIndex(where: {$0.pushIdentifier == notification.pushIdentifier}) { -+ notifications[existingIndex] = notification -+ } else { -+ notifications.append(notification) -+ } -+ -+ if notifications.count > Self.maxStoredNotifications { -+ notifications = Array(notifications.suffix(Self.maxStoredNotifications)) -+ } -+ -+ try saveNotifications() -+ publish(notifications: notifications) -+ } -+ -+ private func saveNotifications() throws { -+ let encoder = JSONEncoder() -+ encoder.dateEncodingStrategy = .iso8601 -+ let data = try encoder.encode(notifications) -+ try data.write(to: Self.storageURL) -+ } -+ -+ private func loadNotifications() { -+ let decoder = JSONDecoder() -+ decoder.dateDecodingStrategy = .iso8601 -+ guard let data = try? Data(contentsOf: Self.storageURL) else { -+ return -+ } -+ guard let loaded = try? decoder.decode([StoredRemoteNotification].self, from: data) else { -+ return -+ } -+ self.notifications = loaded -+ } -+ -+ func deleteNotificationHistory() { -+ notifications = [] -+ try? saveNotifications() -+ publish(notifications: notifications) -+ } -+} -+ -+enum RemoteNotificationError: LocalizedError { -+ case notificationNotFound(String) -+ -+ var errorDescription: String? { -+ switch self { -+ case .notificationNotFound(let id): -+ return "Notification not found: \(id)" -+ } -+ } -+} -+ -+extension Date { -+ func isBeforeOrEqual(otherDate: Date) -> Bool { -+ return self <= otherDate -+ } -+ -+ func isAfterOrEqual(otherDate: Date) -> Bool { -+ return self >= otherDate - } - } -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -index 634eb4e..fce4623 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -@@ -23,13 +23,13 @@ struct RemoteCommandValidator { - } - - private func validateExpirationDate(remoteNotification: RemoteNotification) throws { -- -+ - guard let expirationDate = remoteNotification.expiration else { - return //Skip validation if no date included - } -- -+ - if nowDateSource() > expirationDate { -- throw NotificationValidationError.expiredNotification -+ throw NotificationValidationError.expiredNotification(sentDate: remoteNotification.sentAt, receivedDate: nowDateSource()) - } - } - -@@ -44,14 +44,16 @@ struct RemoteCommandValidator { - - enum NotificationValidationError: LocalizedError { - case missingOTP -- case expiredNotification -- -+ case expiredNotification(sentDate: Date, receivedDate: Date) -+ - var errorDescription: String? { - switch self { - case .missingOTP: - return LocalizedString("Missing OTP", comment: "Remote command error description: Missing OTP.") -- case .expiredNotification: -- return LocalizedString("Expired", comment: "Remote command error description: expired.") -+ case .expiredNotification(let sentDate, let receivedDate): -+ let dateFormatter = DateFormatter() -+ dateFormatter.dateFormat = "h:mm:ss a" -+ return String(format: LocalizedString("Expired. Sent: %@. Received: %@", comment: "Remote command error description: expired."), dateFormatter.string(from: sentDate), dateFormatter.string(from: receivedDate)) - } - } - } -diff --git a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -index 3d54080..4ce6ab7 100644 ---- a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -+++ b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -@@ -14,6 +14,9 @@ import LoopKit - protocol ServiceStatusViewModelDelegate { - func verifyConfiguration(completion: @escaping (Error?) -> Void) - var siteURL: URL? { get } -+ func notificationHistory() async -> [StoredRemoteNotification] -+ func notificationPublisher() async -> AsyncStream<[StoredRemoteNotification]> -+ func deleteNotificationHistory() - } - - enum ServiceStatus { -@@ -35,19 +38,92 @@ extension ServiceStatus: CustomStringConvertible { - } - } - -+struct RemoteCommand: Identifiable { -+ let id: String -+ let actionName: String -+ let createdDate: String -+ let details: String -+ let statusMessage: String -+ let isError: Bool -+} -+ -+extension StoredRemoteNotification { -+ var actionName: String { -+ switch remoteNotificationType { -+ case .bolus: -+ return "Bolus" -+ case .carbs: -+ return "Carbs" -+ case .override: -+ return "Override" -+ case .profileSwitch: -+ return "Profile Switch" -+ case .overrideCancel: -+ return "Cancel Override" -+ } -+ } -+ -+ var createdDateDescription: String { -+ let formatter = DateFormatter() -+ formatter.dateStyle = .short -+ formatter.timeStyle = .medium -+ return formatter.string(from: receivedDate) -+ } -+ -+ var details: String { -+ switch remoteNotificationType { -+ case .bolus(let notification): -+ return String(format: "%.2f U", notification.amount) -+ case .carbs(let notification): -+ return String(format: "%.0f g", notification.amount) -+ case .override(let notification): -+ return notification.name -+ case .profileSwitch(let notification): -+ return notification.profileName -+ case .overrideCancel: -+ return "" -+ } -+ } -+ -+ var statusMessage: String { -+ guard let status else { -+ return "Pending..." -+ } -+ switch status { -+ case .success(_, _, let completionMessage): -+ return completionMessage ?? "Success" -+ case .failure(_, let errorMessage): -+ return errorMessage -+ } -+ } -+ -+ var isError: Bool { -+ guard let status else { -+ return false -+ } -+ switch status { -+ case .success: -+ return false -+ case .failure: -+ return true -+ } -+ } -+} -+ - class ServiceStatusViewModel: ObservableObject { - @Published var status: ServiceStatus = .checking -- -+ @Published var remoteCommands: [RemoteCommand] = [] -+ - let delegate: ServiceStatusViewModelDelegate - var didLogout: (() -> Void)? -- -+ - var urlString: String { - return delegate.siteURL?.absoluteString ?? LocalizedString("Not Available", comment: "Error when nightscout service url is not set") - } - - init(delegate: ServiceStatusViewModelDelegate) { - self.delegate = delegate -- -+ - delegate.verifyConfiguration { (error) in - DispatchQueue.main.async { - if let error = error { -@@ -57,5 +133,24 @@ class ServiceStatusViewModel: ObservableObject { - } - } - } -+ -+ Task { -+ let stream = await delegate.notificationPublisher() -+ for await notifications in stream { -+ let commands = notifications.reversed().map { notification in -+ RemoteCommand( -+ id: notification.pushIdentifier, -+ actionName: notification.actionName, -+ createdDate: notification.createdDateDescription, -+ details: notification.details, -+ statusMessage: notification.statusMessage, -+ isError: notification.isError -+ ) -+ } -+ await MainActor.run { -+ self.remoteCommands = commands -+ } -+ } -+ } - } - } -diff --git a/NightscoutService/NightscoutServiceKitUI/Views/ServiceStatusView.swift b/NightscoutService/NightscoutServiceKitUI/Views/ServiceStatusView.swift -index 4ecbdde..f213ccb 100644 ---- a/NightscoutService/NightscoutServiceKitUI/Views/ServiceStatusView.swift -+++ b/NightscoutService/NightscoutServiceKitUI/Views/ServiceStatusView.swift -@@ -17,56 +17,86 @@ struct ServiceStatusView: View, HorizontalSizeClassOverride { - @ObservedObject var otpViewModel: OTPViewModel - @State private var selectedItem: String? - var body: some View { -- VStack { -- Text("Nightscout") -- .font(.largeTitle) -- .fontWeight(.semibold) -- Image(frameworkImage: "nightscout", decorative: true) -- .resizable() -- .aspectRatio(contentMode: .fill) -- .frame(width: 150, height: 150) -- -+ List { -+ Section { -+ VStack { -+ Text("Nightscout") -+ .font(.largeTitle) -+ .fontWeight(.semibold) -+ Image(frameworkImage: "nightscout", decorative: true) -+ .resizable() -+ .aspectRatio(contentMode: .fill) -+ .frame(width: 150, height: 150) -+ } -+ .frame(maxWidth: .infinity) -+ .listRowBackground(Color.clear) -+ } - -- VStack(spacing: 0) { -+ Section { - HStack { - Text("URL") - Spacer() - Text(viewModel.urlString) - } -- .padding() -- Divider() - HStack { - Text("Status") - Spacer() - Text(String(describing: viewModel.status)) - } -- .padding() -- Divider() - NavigationLink(destination: OTPSelectionView(otpViewModel: otpViewModel), tag: "otp-view", selection: $selectedItem) { - HStack { - Text("One-Time Password") - Spacer() - Text(otpViewModel.otpCode) -- Image(systemName: "chevron.right") -- .font(.caption) - } -- }.foregroundColor(Color.primary) -- .padding() -+ } - } -- .background(Color(UIColor.secondarySystemBackground)) -- .cornerRadius(10) -- -- Button(action: { -- viewModel.didLogout?() -- } ) { -- Text("Logout").padding(.top, 20) -+ -+ Section(header: Text("Remote Commands")) { -+ if viewModel.remoteCommands.isEmpty { -+ Text("No remote commands received") -+ .foregroundColor(.secondary) -+ } else { -+ ForEach(viewModel.remoteCommands) { command in -+ VStack(alignment: .leading, spacing: 4) { -+ HStack { -+ Text(command.actionName) -+ .fontWeight(.medium) -+ if !command.details.isEmpty { -+ Text(command.details) -+ .foregroundColor(.secondary) -+ } -+ Spacer() -+ Text(command.createdDate) -+ .font(.caption) -+ .foregroundColor(.secondary) -+ } -+ Text(command.statusMessage) -+ .font(.caption) -+ .foregroundColor(command.isError ? .red : .secondary) -+ } -+ } -+ } -+ Button(role: .destructive) { -+ viewModel.delegate.deleteNotificationHistory() -+ } label: { -+ Text("Remove History") -+ } -+ } -+ -+ Section { -+ Button(action: { -+ viewModel.didLogout?() -+ } ) { -+ Text("Logout") -+ .foregroundColor(.red) -+ } - } - } -- .padding([.leading, .trailing]) - .navigationBarTitle("") - .navigationBarItems(trailing: dismissButton) - } -- -+ - private var dismissButton: some View { - Button(action: dismiss) { - Text("Done").bold() -diff --git a/remote_window.loopcustomization b/remote_window.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/remote_window/dev_remote_window_profiles_remote_carb_edit.patch b/remote_window/dev_remote_window_profiles_remote_carb_edit.patch deleted file mode 100644 index 6e6af92..0000000 --- a/remote_window/dev_remote_window_profiles_remote_carb_edit.patch +++ /dev/null @@ -1,1989 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/DeviceDataManager.swift b/Loop/Loop/Managers/DeviceDataManager.swift -index 3b5f1de2..c253e88e 100644 ---- a/Loop/Loop/Managers/DeviceDataManager.swift -+++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -838,13 +838,14 @@ extension DeviceDataManager { - - // MARK: - Client API - extension DeviceDataManager { -- func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void = { _ in }) { -+ func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ result: Swift.Result) -> Void = { _ in }) { - guard let pumpManager = pumpManager else { -- completion(LoopError.configurationError(.pumpManager)) -+ completion(.failure(LoopError.configurationError(.pumpManager))) - return - } - -- self.loopManager.addRequestedBolus(DoseEntry(type: .bolus, startDate: Date(), value: units, unit: .units, isMutable: true)) { -+ let doseEntry = DoseEntry(type: .bolus, startDate: Date(), value: units, unit: .units, isMutable: true) -+ self.loopManager.addRequestedBolus(doseEntry) { - pumpManager.enactBolus(units: units, activationType: activationType) { (error) in - if let error = error { - self.log.error("%{public}@", String(describing: error)) -@@ -860,11 +861,23 @@ extension DeviceDataManager { - } - - self.loopManager.bolusRequestFailed(error) { -- completion(error) -+ completion(.failure(error)) - } - } else { - self.loopManager.bolusConfirmed() { -- completion(nil) -+ // The DoseEntry from the store is returned as it has the syncIdentifier set. -+ self.doseStore.getDoses(start: doseEntry.startDate, includeMutable: true) { result in -+ switch result { -+ case .success(let doses): -+ if let storedEntry = doses.filter({ $0.programmedUnits == doseEntry.programmedUnits }).first { -+ completion(.success(storedEntry)) -+ } else { -+ completion(.failure(EnactBolusError.storageFailure)) -+ } -+ case .failure: -+ completion(.failure(EnactBolusError.storageFailure)) -+ } -+ } - } - } - } -@@ -873,17 +886,17 @@ extension DeviceDataManager { - } - } - -- func enactBolus(units: Double, activationType: BolusActivationType) async throws { -+ func enactBolus(units: Double, activationType: BolusActivationType) async throws -> DoseEntry { - return try await withCheckedThrowingContinuation { continuation in -- enactBolus(units: units, activationType: activationType) { error in -- if let error = error { -- continuation.resume(throwing: error) -- return -- } -- continuation.resume() -+ enactBolus(units: units, activationType: activationType) { result in -+ continuation.resume(with: result) - } - } - } -+ -+ enum EnactBolusError: Error { -+ case storageFailure -+ } - - var pumpManagerStatus: PumpManagerStatus? { - return pumpManager?.status -@@ -1476,10 +1489,49 @@ extension Notification.Name { - - extension DeviceDataManager: ServicesManagerDosingDelegate { - -- func deliverBolus(amountInUnits: Double) async throws { -- try await enactBolus(units: amountInUnits, activationType: .manualNoRecommendation) -+ func deliverBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry { -+ let minTreatmentIntervalInMinutes = 10.0 -+ let boluses = try await doseStore.getBoluses(start: Date().addingTimeInterval(-.days(1))) -+ // Look back further for safety in case caregiver didn't know about other treatments before they send this (i.e. Upload delays) -+ let conflictStartDate = userCreatedDate.addingTimeInterval(-.minutes(minTreatmentIntervalInMinutes)) -+ let conflictingEntries = boluses.filter({$0.startDate >= conflictStartDate}) -+ let conflictingBolusAmount = conflictingEntries.map({$0.programmedUnits}).reduce(0, {$0 + $1}) -+ let adjustedDoseAmount = amountInUnits - conflictingBolusAmount -+ guard adjustedDoseAmount <= amountInUnits else { // Added safety check for the bolus math -+ throw BolusActionError.invalidCalculation -+ } -+ guard adjustedDoseAmount > 0.0 else { -+ throw BolusActionError.bolusAmountAdjustedToZero(amountInUnits, conflictStartDate) -+ } -+ return try await enactBolus(units: adjustedDoseAmount, activationType: .manualNoRecommendation) - } - -+ enum BolusActionError: LocalizedError { -+ case bolusAmountAdjustedToZero( _ requestedAmountInUnits: Double, _ conflictStartDate: Date) -+ case invalidCalculation -+ -+ var errorDescription: String? { -+ switch self { -+ case .bolusAmountAdjustedToZero(let amountInUnits, let conflictStartDate): -+ let treatmentAmountFormatted = Self.numberFormatter.string(from: HKQuantity(unit: .internationalUnit(), doubleValue: amountInUnits), unit: .internationalUnit()) ?? "" -+ return String(format: NSLocalizedString("All boluses delivered after %1$@ were subtracted from the requested bolus amount for safety. Those amount(s) exceed the requested amount of %2$@ so the remote bolus was not delivered.", comment: "Bolus error description: no remote bolus delivered."), conflictStartDate.formatted(date: .omitted, time: .complete), treatmentAmountFormatted) -+ case .invalidCalculation: -+ return String(format: NSLocalizedString("An invalid bolus calculation occurred.", comment: "Bolus error description: invalid calculation.")) -+ } -+ } -+ -+ static var numberFormatter: NumberFormatter = { -+ let formatter = NumberFormatter() -+ formatter.numberStyle = .decimal -+ return formatter -+ }() -+ -+ static var dateFormatter: DateFormatter = { -+ let formatter = DateFormatter() -+ formatter.timeStyle = .medium -+ return formatter -+ }() -+ } - } - - // MARK: - Critical Event Log Export -diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift -index 02359815..03ffe6c7 100644 ---- a/Loop/Loop/Managers/LoopDataManager.swift -+++ b/Loop/Loop/Managers/LoopDataManager.swift -@@ -2501,7 +2501,7 @@ extension LoopDataManager: ServicesManagerDelegate { - - //Overrides - -- func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws { -+ func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws -> TemporaryScheduleOverride { - - guard let preset = settings.overridePresets.first(where: { $0.name == name }) else { - throw EnactOverrideError.unknownPreset(name) -@@ -2514,11 +2514,27 @@ extension LoopDataManager: ServicesManagerDelegate { - } - - await enactOverride(remoteOverride) -+ return remoteOverride - } - - -- func cancelCurrentOverride() async throws { -+ func cancelCurrentOverride() async throws -> TemporaryScheduleOverride { -+ guard let currentOverride = settings.scheduleOverride else { -+ throw OverrideCancelError.overrideNotActive -+ } - await enactOverride(nil) -+ return currentOverride -+ } -+ -+ enum OverrideCancelError: LocalizedError { -+ case overrideNotActive -+ -+ var errorDescription: String? { -+ switch self { -+ case .overrideNotActive: -+ return String(format: NSLocalizedString("No overrides active for cancellation", comment: "Cancel Override error description: override not active for cancellation.")) -+ } -+ } - } - - func enactOverride(_ override: TemporaryScheduleOverride?) async { -@@ -2539,15 +2555,28 @@ extension LoopDataManager: ServicesManagerDelegate { - - //Carb Entry - -- func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { -+ func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry { - let absorptionTime = absorptionTime ?? carbStore.defaultAbsorptionTimes.medium - let startDate = startDate ?? Date() - try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) -- -+ -+ let maxAllowedConflictingAmountInGrams = 0.0 // Not allowing any carbs -+ let minTreatmentIntervalInMinutes = 10.0 -+ let entries = try await carbStore.getCarbEntries(start: Date().addingTimeInterval(-.days(1)), end: Date().addingTimeInterval(.days(1))) -+ let conflictStartDate = userCreatedDate.addingTimeInterval(-.minutes(minTreatmentIntervalInMinutes)) // Look back a little further for added safety (i.e. Network delays) -+ let conflictingEntries = entries.filter({ entry in -+ let createdDate = entry.userCreatedDate ?? entry.startDate -+ return createdDate >= conflictStartDate -+ }) -+ let conflictingCarbAmount = conflictingEntries.map({$0.quantity.doubleValue(for: .gram())}).reduce(0, {$0 + $1}) -+ guard conflictingCarbAmount <= maxAllowedConflictingAmountInGrams else { -+ throw CarbActionError.conflictingTreatments(conflictingCarbAmount, minTreatmentIntervalInMinutes) -+ } -+ - let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) - let candidateCarbEntry = NewCarbEntry(quantity: quantity, startDate: startDate, foodType: foodType, absorptionTime: absorptionTime) - -- let _ = try await devliverCarbEntry(candidateCarbEntry) -+ return try await deliverCarbEntry(candidateCarbEntry) - } - - /// Remote edits and deletes may only target entries this app created within the last 24 hours. -@@ -2625,11 +2654,12 @@ extension LoopDataManager: ServicesManagerDelegate { - case invalidStartDate(Date) - case exceedsMaxCarbs - case invalidCarbs -+ case conflictingTreatments(_ conflictingAmountInGrams: Double, _ minutesRequiredBetweenTreatments: TimeInterval) - case entryNotFound - case entryNotEditable - - var errorDescription: String? { -- switch self { -+ switch self { - case .exceedsMaxCarbs: - return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Carb error description: carbs exceed maximum amount.") - case .invalidCarbs: -@@ -2644,6 +2674,10 @@ extension LoopDataManager: ServicesManagerDelegate { - case .invalidStartDate(let startDate): - let startDateFormatted = Self.dateFormatter.string(from: startDate) - return String(format: NSLocalizedString("Start time is out of range: %@", comment: "Carb error description: invalid start time is out of range."), startDateFormatted) -+ case .conflictingTreatments(let conflictingAmountInGrams, let minutesRequiredBetweenTreatments): -+ let treatmentAmountFormatted = Self.numberFormatter.string(from: HKQuantity(unit: .gram(), doubleValue: conflictingAmountInGrams), unit: .gram()) ?? "" -+ let minutesRequiredBetweenTreatmentsFormatted = Self.numberFormatter.string(from: minutesRequiredBetweenTreatments) ?? "" -+ return String(format: NSLocalizedString("Conflicting carb treatments (%1$@) have occurred. At least %2$@ minutes must pass between carb entries.", comment: "Carb error description: conflicting treatments occurred."), treatmentAmountFormatted, minutesRequiredBetweenTreatmentsFormatted) - } - } - -@@ -2661,7 +2695,7 @@ extension LoopDataManager: ServicesManagerDelegate { - } - - //Can't add this concurrency wrapper method to LoopKit due to the minimum iOS version -- func devliverCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry { -+ func deliverCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry { - return try await withCheckedThrowingContinuation { continuation in - carbStore.addCarbEntry(carbEntry) { result in - switch result { -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 7a8dc92f..d804456f 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -233,13 +233,13 @@ class ServicesManager { - } - - public protocol ServicesManagerDosingDelegate: AnyObject { -- func deliverBolus(amountInUnits: Double) async throws -+ func deliverBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry - } - - public protocol ServicesManagerDelegate: AnyObject { -- func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws -- func cancelCurrentOverride() async throws -- func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws -+ func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws -> TemporaryScheduleOverride -+ func cancelCurrentOverride() async throws -> TemporaryScheduleOverride -+ func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry - func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry - func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry - } -@@ -276,7 +276,10 @@ extension ServicesManager: ServiceDelegate { - return semanticVersion - } - -- func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws { -+ func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride { -+ guard let servicesManagerDelegate else { -+ throw OverrideActionError.internalError -+ } - - var duration: TemporaryScheduleOverride.Duration? = nil - if let durationTime = durationTime { -@@ -296,14 +299,16 @@ extension ServicesManager: ServiceDelegate { - } - } - -- try await servicesManagerDelegate?.enactOverride(name: name, duration: duration, remoteAddress: remoteAddress) -+ let override = try await servicesManagerDelegate.enactOverride(name: name, duration: duration, remoteAddress: remoteAddress) - await remoteDataServicesManager.triggerUpload(for: .overrides) -+ return override - } - - enum OverrideActionError: LocalizedError { - - case durationExceedsMax(TimeInterval) - case negativeDuration -+ case internalError - - var errorDescription: String? { - switch self { -@@ -311,36 +316,60 @@ extension ServicesManager: ServiceDelegate { - return String(format: NSLocalizedString("Duration exceeds: %1$.1f hours", comment: "Override error description: duration exceed max (1: max duration in hours)."), maxDurationTime.hours) - case .negativeDuration: - return String(format: NSLocalizedString("Negative duration not allowed", comment: "Override error description: negative duration error.")) -+ case .internalError: -+ return String(format: NSLocalizedString("Internal error", comment: "Override error description: Internal error.")) - } - } - } - -- func cancelRemoteOverride() async throws { -- try await servicesManagerDelegate?.cancelCurrentOverride() -+ func cancelRemoteOverride() async throws -> TemporaryScheduleOverride { -+ guard let servicesManagerDelegate else { -+ throw CarbActionError.internalError -+ } -+ -+ let cancelledOverride = try await servicesManagerDelegate.cancelCurrentOverride() - await remoteDataServicesManager.triggerUpload(for: .overrides) -+ return cancelledOverride - } - -- func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { -+ func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry { - do { -- try await servicesManagerDelegate?.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) -+ guard let servicesManagerDelegate else { -+ throw CarbActionError.internalError -+ } -+ -+ let carbEntry = try await servicesManagerDelegate.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate, userCreatedDate: userCreatedDate) - await NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: amountInGrams) - await remoteDataServicesManager.triggerUpload(for: .carb) - analyticsServicesManager.didAddCarbs(source: "Remote") -+ return carbEntry - } catch { - await NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: amountInGrams) - throw error - } - } - -- func deleteRemoteCarbs(syncIdentifier: String) async throws { -+ enum CarbActionError: LocalizedError { -+ case internalError -+ -+ var errorDescription: String? { -+ switch self { -+ case .internalError: -+ return NSLocalizedString("Internal error", comment: "Carb error description: internal error.") -+ } -+ } -+ } -+ -+ func deleteRemoteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry { - do { - guard let servicesManagerDelegate else { -- throw RemoteCarbCommandError.internalError -+ throw CarbActionError.internalError - } - let deleted = try await servicesManagerDelegate.deleteCarbs(syncIdentifier: syncIdentifier) - let body = String(format: NSLocalizedString("Remote Carbs Delete: %d grams removed", comment: "The notification body for a successful remote carb delete. (1: Carb amount in grams)"), Int(deleted.quantity.doubleValue(for: .gram()))) - await NotificationManager.sendRemoteCarbChangeNotification(body: body) - await remoteDataServicesManager.triggerUpload(for: .carb) -+ return deleted - } catch { - let body = NSLocalizedString("Remote Carbs Delete", comment: "The notification body prefix for a failed remote carb delete") - await NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) -@@ -348,34 +377,27 @@ extension ServicesManager: ServiceDelegate { - } - } - -- func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws { -+ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry { - let body = String(format: NSLocalizedString("Remote Carbs Edit: %d grams", comment: "The carb amount message for a remote carb edit notification. (1: Carb amount in grams)"), Int(amountInGrams)) - do { - guard let servicesManagerDelegate else { -- throw RemoteCarbCommandError.internalError -+ throw CarbActionError.internalError - } -- _ = try await servicesManagerDelegate.replaceCarbs(syncIdentifier: syncIdentifier, amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) -+ let replacement = try await servicesManagerDelegate.replaceCarbs(syncIdentifier: syncIdentifier, amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) - await NotificationManager.sendRemoteCarbChangeNotification(body: body) - await remoteDataServicesManager.triggerUpload(for: .carb) -+ return replacement - } catch { - await NotificationManager.sendRemoteCarbChangeFailureNotification(body: body, error: error) - throw error - } - } - -- enum RemoteCarbCommandError: LocalizedError { -- case internalError -- -- var errorDescription: String? { -- switch self { -- case .internalError: -- return NSLocalizedString("Internal error", comment: "Remote carb command error description: internal error.") -- } -- } -- } -- -- func deliverRemoteBolus(amountInUnits: Double) async throws { -+ func deliverRemoteBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry { - do { -+ guard let servicesManagerDosingDelegate else { -+ throw BolusActionError.internalError -+ } - - guard amountInUnits > 0 else { - throw BolusActionError.invalidBolus -@@ -389,10 +411,11 @@ extension ServicesManager: ServiceDelegate { - throw BolusActionError.exceedsMaxBolus - } - -- try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits) -- await NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) -+ let doseEntry = try await servicesManagerDosingDelegate.deliverBolus(amountInUnits: amountInUnits, userCreatedDate: userCreatedDate) -+ await NotificationManager.sendRemoteBolusNotification(amount: doseEntry.programmedUnits) - await remoteDataServicesManager.triggerUpload(for: .dose) - analyticsServicesManager.didBolus(source: "Remote") -+ return doseEntry - } catch { - await NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) - throw error -@@ -404,6 +427,7 @@ extension ServicesManager: ServiceDelegate { - case invalidBolus - case missingMaxBolus - case exceedsMaxBolus -+ case internalError - - var errorDescription: String? { - switch self { -@@ -413,6 +437,8 @@ extension ServicesManager: ServiceDelegate { - return NSLocalizedString("Missing maximum allowed bolus in settings", comment: "Bolus error description: missing maximum bolus in settings.") - case .exceedsMaxBolus: - return NSLocalizedString("Exceeds maximum allowed bolus in settings", comment: "Bolus error description: bolus exceeds maximum bolus in settings.") -+ case .internalError: -+ return NSLocalizedString("Internal error", comment: "Bolus error description: internal error.") - } - } - } -diff --git a/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift b/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift -index 692a4ec2..41221127 100644 ---- a/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift -+++ b/Loop/Loop/Managers/Store Protocols/CarbStoreProtocol.swift -@@ -34,8 +34,6 @@ protocol CarbStoreProtocol: AnyObject { - func replaceCarbEntry(_ oldEntry: StoredCarbEntry, withEntry newEntry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) - - func addCarbEntry(_ entry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) -- -- func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] - - func getCarbStatus(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (_ result: CarbStoreResult<[CarbStatus]>) -> Void) - -@@ -53,6 +51,8 @@ protocol CarbStoreProtocol: AnyObject { - func getTotalCarbs(since start: Date, completion: @escaping (_ result: CarbStoreResult) -> Void) - - func deleteCarbEntry(_ entry: StoredCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) -+ -+ func getCarbEntries(start: Date?, end: Date?) async throws -> [StoredCarbEntry] - } - - extension CarbStore: CarbStoreProtocol { } -diff --git a/Loop/Loop/View Models/BolusEntryViewModel.swift b/Loop/Loop/View Models/BolusEntryViewModel.swift -index 20735e7f..4f2691a4 100644 ---- a/Loop/Loop/View Models/BolusEntryViewModel.swift -+++ b/Loop/Loop/View Models/BolusEntryViewModel.swift -@@ -29,7 +29,7 @@ protocol BolusEntryViewModelDelegate: AnyObject { - - func storeManualBolusDosingDecision(_ bolusDosingDecision: BolusDosingDecision, withDate date: Date) - -- func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ error: Error?) -> Void) -+ func enactBolus(units: Double, activationType: BolusActivationType, completion: @escaping (_ result: Swift.Result) -> Void) - - func getGlucoseSamples(start: Date?, end: Date?, completion: @escaping (_ samples: Swift.Result<[StoredGlucoseSample], Error>) -> Void) - -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit/InsulinKit/DoseStore.swift b/LoopKit/LoopKit/InsulinKit/DoseStore.swift -index 712f9048..be7c17dd 100644 ---- a/LoopKit/LoopKit/InsulinKit/DoseStore.swift -+++ b/LoopKit/LoopKit/InsulinKit/DoseStore.swift -@@ -1274,8 +1274,18 @@ extension DoseStore { - public func getDoses(start: Date? = nil, end: Date? = nil) async throws -> [DoseEntry] { - return try await insulinDeliveryStore.getDoses(start: start, end: end) - } -- -- -+ -+ /// Retrieves doses overlapping supplied range -+ /// -+ /// - Parameters: -+ /// - start: The earliest date of dose entries to retrieve. -+ /// - end: The latest date of dose entries to retrieve, if provided. -+ /// - includeMutable: Whether to include mutable dose entries or not. -+ /// - completion: A closure called once the dose entries have been retrieved. -+ /// - result: An array of dose entries, in chronological order by startDate, or error. -+ public func getDoses(start: Date, end: Date? = nil, includeMutable: Bool, completion: @escaping (_ result: Swift.Result<[DoseEntry], Error>) -> Void) { -+ insulinDeliveryStore.getDoseEntries(start: start, end: end, includeMutable: includeMutable, completion: completion) -+ } - - /// Retrieves the maximum insulin on-board value from the two timeline values nearest to the specified date - /// -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 56742c96..858a5621 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -15,10 +15,10 @@ public protocol RemoteProfileActionDelegate: AnyObject { - } - - public protocol RemoteActionDelegate: AnyObject { -- func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -- func cancelRemoteOverride() async throws -- func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws -- func deliverRemoteBolus(amountInUnits: Double) async throws -- func deleteRemoteCarbs(syncIdentifier: String) async throws -- func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -+ func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws -> TemporaryScheduleOverride -+ func cancelRemoteOverride() async throws -> TemporaryScheduleOverride -+ func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -> StoredCarbEntry -+ func deliverRemoteBolus(amountInUnits: Double, userCreatedDate: Date) async throws -> DoseEntry -+ func deleteRemoteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry -+ func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry - } -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutServiceKit/Extensions/NightscoutUploader.swift b/NightscoutService/NightscoutServiceKit/Extensions/NightscoutUploader.swift -index 4a85888..fa35651 100644 ---- a/NightscoutService/NightscoutServiceKit/Extensions/NightscoutUploader.swift -+++ b/NightscoutService/NightscoutServiceKit/Extensions/NightscoutUploader.swift -@@ -117,25 +117,27 @@ extension NightscoutClient { - - extension NightscoutClient { - -- func createDoses(_ data: [DoseEntry], usingObjectIdCache objectIdCache: ObjectIdCache, completion: @escaping (Result<[String], Error>) -> Void) { -- guard !data.isEmpty else { -- completion(.success([])) -- return -- } -- -- let source = "loop://\(UIDevice.current.name)" -- -+ func createDoses( -+ _ data: [DoseEntry], -+ sourceMessage: (DoseEntry) -> String, -+ usingObjectIdCache objectIdCache: ObjectIdCache, -+ completion: @escaping (Result<[String], Error>) -> Void -+ ) { - let treatments = data.compactMap { (dose) -> NightscoutTreatment? in - var objectId: String? = nil -- -+ - if let syncIdentifier = dose.syncIdentifier { - objectId = objectIdCache.findObjectIdBySyncIdentifier(syncIdentifier) - } -- -- return dose.treatment(enteredBy: source, withObjectId: objectId) -+ -+ return dose.treatment(enteredBy: sourceMessage(dose), withObjectId: objectId) - } -- -- -+ -+ guard !treatments.isEmpty else { -+ completion(.success([])) -+ return -+ } -+ - self.upload(treatments) { (result) in - switch result { - case .failure(let error): -diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index 18ac515..a968a64 100644 ---- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift -+++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -65,10 +65,12 @@ public final class NightscoutService: Service { - - private let log = OSLog(category: "NightscoutService") - -+ private let notificationExpirationInMinutes = 10.0 -+ - public init() { - self.isOnboarded = false - self.lockedObjectIdCache = Locked(ObjectIdCache()) -- self.otpManager = OTPManager(secretStore: KeychainManager()) -+ self.otpManager = OTPManager(secretStore: KeychainManager(), maxMinutesValid: notificationExpirationInMinutes) - self.commandSourceV1 = RemoteCommandSourceV1(otpManager: otpManager) - self.commandSourceV1.delegate = self - } -@@ -83,11 +85,11 @@ public final class NightscoutService: Service { - } else { - self.lockedObjectIdCache = Locked(ObjectIdCache()) - } -- -- self.otpManager = OTPManager(secretStore: KeychainManager()) -+ -+ self.otpManager = OTPManager(secretStore: KeychainManager(), maxMinutesValid: notificationExpirationInMinutes) - self.commandSourceV1 = RemoteCommandSourceV1(otpManager: otpManager) - self.commandSourceV1.delegate = self -- -+ - restoreCredentials() - } - -@@ -238,12 +240,35 @@ extension NightscoutService: RemoteDataService { - public var doseDataLimit: Int? { return 1000 } - - public func uploadDoseData(created: [DoseEntry], deleted: [DoseEntry], completion: @escaping (_ result: Result) -> Void) { -+ Task { -+ let notificationHistory = await notificationHistory() -+ uploadDoseData(created: created, deleted: deleted, notificationHistory: notificationHistory, completion: completion) -+ -+ // Upload pending stored notifications -+ await uploadPendingNotifications() -+ } -+ } -+ -+ func uploadPendingNotifications() async { -+ await commandSourceV1.uploadPendingNotifications() -+ } -+ -+ public func uploadDoseData(created: [DoseEntry], deleted: [DoseEntry], notificationHistory: [StoredRemoteNotification], completion: @escaping (_ result: Result) -> Void) { - guard hasConfiguration, let uploader = uploader else { - completion(.success(true)) - return - } - -- uploader.createDoses(created, usingObjectIdCache: self.objectIdCache) { (result) in -+ let deviceName = UIDevice.current.name -+ let sourceMessage: (DoseEntry) -> String = { (dose) -> String in -+ if notificationHistory.contains(where: { $0.containsDose(dose) }) { -+ return "Loop (via remote command)" -+ } else { -+ return "loop://\(deviceName)" -+ } -+ } -+ -+ uploader.createDoses(created, sourceMessage: sourceMessage, usingObjectIdCache: objectIdCache) { (result) in - switch (result) { - case .failure(let error): - completion(.failure(error)) -@@ -399,10 +424,34 @@ extension NightscoutService: RemoteDataService { - - - public func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async throws { -+ guard let serviceDelegate else { -+ return -+ } -+ -+ // Set the expiration to longer duration. Ideally this would be done in Nightscout instead. -+ var notification = notification -+ let dateFormatter = DateFormatter.iso8601DateDecoder -+ if let sentDateString = notification["sent-at"] as? String, let sentDate = dateFormatter.date(from: sentDateString) { -+ let expirationDate = sentDate.addingTimeInterval(60 * notificationExpirationInMinutes) -+ notification["expiration"] = dateFormatter.string(from: expirationDate) as AnyObject -+ } -+ - let commandSource = try commandSource(notification: notification) -- await commandSource.remoteNotificationWasReceived(notification) -+ await commandSource.remoteNotificationWasReceived(notification, serviceDelegate: serviceDelegate) - } -- -+ -+ public func notificationHistory() async -> [StoredRemoteNotification] { -+ return await commandSourceV1.notificationHistory() -+ } -+ -+ public func notificationPublisher() async -> AsyncStream<[StoredRemoteNotification]> { -+ return await commandSourceV1.notificationPublisher() -+ } -+ -+ public func deleteNotificationHistory() { -+ commandSourceV1.deleteNotificationHistory() -+ } -+ - private func commandSource(notification: [String: AnyObject]) throws -> RemoteCommandSource { - return commandSourceV1 - } -@@ -410,144 +459,29 @@ extension NightscoutService: RemoteDataService { - } - - extension NightscoutService: RemoteCommandSourceV1Delegate { -- -- func commandSourceV1(_: RemoteCommandSourceV1, handleAction action: Action, remoteNotification: RemoteNotification) async throws { -- -- let returnInfo = remoteNotification.getReturnNotificationInfo() -- if returnInfo == nil { -- os_log("No return notification info available, response will not be sent", log: .default, type: .info) -- } else { -- os_log("Return notification info available, will send response after command processing", log: .default, type: .info) -- } -- -- var commandType: RemoteNotificationResponseManager.CommandType = .bolus // Default, will be set in switch -- var success = false -- var message = "" -- var syncIdentifier: String? = nil -- -- do { -- switch action { -- case .temporaryScheduleOverride(let overrideCommand): -- commandType = .override -- try await self.serviceDelegate?.enactRemoteOverride( -- name: overrideCommand.name, -- durationTime: overrideCommand.durationTime, -- remoteAddress: overrideCommand.remoteAddress -- ) -- success = true -- message = "Override '\(overrideCommand.name)' enacted successfully" -- -- case .profileSwitch(let profileSwitchCommand): -- commandType = .profileSwitch -- guard let profileDelegate = self.serviceDelegate as? RemoteProfileActionDelegate else { -- throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -- } -- do { -- try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -- let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -- guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -- throw RemoteProfileSwitchError.profileChanged -- } -- } -- } catch { -- throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -- } -- success = true -- message = "Profile '\(profileSwitchCommand.name)' activated" -- -- case .cancelTemporaryOverride: -- commandType = .cancelOverride -- try await self.serviceDelegate?.cancelRemoteOverride() -- success = true -- message = "Override cancelled successfully" -- -- case .bolusEntry(let bolusCommand): -- commandType = .bolus -- try await self.serviceDelegate?.deliverRemoteBolus(amountInUnits: bolusCommand.amountInUnits) -- success = true -- message = String(format: "Bolus of %.2f units delivered successfully", bolusCommand.amountInUnits) -- -- case .carbsEntry(let carbCommand): -- commandType = .carbs -- try await self.serviceDelegate?.deliverRemoteCarbs( -- amountInGrams: carbCommand.amountInGrams, -- absorptionTime: carbCommand.absorptionTime, -- foodType: carbCommand.foodType, -- startDate: carbCommand.startDate -- ) -- success = true -- message = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) -- -- case .carbsDelete(let deleteCommand): -- commandType = .carbsDelete -- syncIdentifier = deleteCommand.syncIdentifier -- try await self.serviceDelegate?.deleteRemoteCarbs(syncIdentifier: deleteCommand.syncIdentifier) -- success = true -- message = "Carb entry deleted successfully" -- -- case .carbsEdit(let editCommand): -- commandType = .carbsEdit -- syncIdentifier = editCommand.syncIdentifier -- try await self.serviceDelegate?.replaceRemoteCarbs( -- syncIdentifier: editCommand.syncIdentifier, -- amountInGrams: editCommand.amountInGrams, -- absorptionTime: editCommand.absorptionTime, -- foodType: editCommand.foodType, -- startDate: editCommand.startDate -- ) -- success = true -- message = String(format: "Carb entry updated to %.1f g", editCommand.amountInGrams) -- } -- } catch { -- message = "Command failed: \(error.localizedDescription)" -- // Send failure response before rethrowing -- if let returnInfo = returnInfo { -- await RemoteNotificationResponseManager.shared.sendResponseNotification( -- to: returnInfo, -- commandType: commandType, -- success: false, -- message: message, -- syncIdentifier: syncIdentifier -- ) -- } -- throw error -- } -- -- // Send success response -- if let returnInfo = returnInfo { -- await RemoteNotificationResponseManager.shared.sendResponseNotification( -- to: returnInfo, -- commandType: commandType, -- success: success, -- message: message, -- syncIdentifier: syncIdentifier -- ) -- } -- } -- -- func commandSourceV1(_: RemoteCommandSourceV1, uploadError error: Error, notification: [String: AnyObject]) async throws { -- -+ func commandSourceV1(_: RemoteCommandSourceV1, uploadError errorMessage: String, receivedDate: Date, notification: [String: AnyObject]) async throws { -+ - guard let uploader = self.uploader else {throw NightscoutServiceError.missingCredentials} - var commandDescription = "Loop Remote Action Error" - if let remoteNotification = try? notification.toRemoteNotification() { - commandDescription = remoteNotification.toRemoteAction().description - } -- -+ - let notificationJSON = try JSONSerialization.data(withJSONObject: notification) - let notificationJSONString = String(data: notificationJSON, encoding: .utf8) ?? "" -- -+ - let noteBody = """ -- \(error.localizedDescription) -+ \(errorMessage) - \(notificationJSONString) - """ - - let treatment = NightscoutTreatment( -- timestamp: Date(), -+ timestamp: receivedDate, - enteredBy: commandDescription, - notes: noteBody, - eventType: .note - ) -- -+ - return try await withCheckedThrowingContinuation { continuation in - uploader.upload([treatment], completionHandler: { result in - switch result { -diff --git a/NightscoutService/NightscoutServiceKit/OTPManager.swift b/NightscoutService/NightscoutServiceKit/OTPManager.swift -index 3d51b72..7a572d3 100644 ---- a/NightscoutService/NightscoutServiceKit/OTPManager.swift -+++ b/NightscoutService/NightscoutServiceKit/OTPManager.swift -@@ -77,8 +77,19 @@ public class OTPManager { - let maxOTPsToAccept: Int - - public static var defaultTokenPeriod: TimeInterval = 30 -- public static var defaultMaxOTPsToAccept = 2 -- -+ public static var defaultMaxOTPsToAccept = 30 -+ -+ public init(secretStore: OTPSecretStore = KeychainManager(), nowDateSource: @escaping () -> Date = {Date()}, maxMinutesValid: Double) { -+ self.secretStore = secretStore -+ self.nowDateSource = nowDateSource -+ self.tokenPeriod = OTPManager.defaultTokenPeriod -+ let secondsValid = 60.0 * maxMinutesValid -+ self.maxOTPsToAccept = Int(secondsValid / OTPManager.defaultTokenPeriod) -+ if secretStore.tokenSecretKey() == nil || secretStore.tokenSecretKeyName() == nil { -+ resetSecretKey() -+ } -+ } -+ - public init(secretStore: OTPSecretStore = KeychainManager(), nowDateSource: @escaping () -> Date = {Date()}, tokenPeriod: TimeInterval = OTPManager.defaultTokenPeriod, maxOTPsToAccept: Int = OTPManager.defaultMaxOTPsToAccept) { - self.secretStore = secretStore - self.nowDateSource = nowDateSource -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift -index 3a56d81..6db615c 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift -@@ -9,10 +9,12 @@ - import Foundation - - public struct BolusAction: Codable { -- -+ - public let amountInUnits: Double -- -- public init(amountInUnits: Double) { -+ public let userCreatedDate: Date -+ -+ public init(amountInUnits: Double, userCreatedDate: Date) { - self.amountInUnits = amountInUnits -+ self.userCreatedDate = userCreatedDate - } - } -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift -index 3865989..b315b16 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift -@@ -14,11 +14,13 @@ public struct CarbAction: Codable{ - public let absorptionTime: TimeInterval? - public let foodType: String? - public let startDate: Date? -- -- public init(amountInGrams: Double, absorptionTime: TimeInterval? = nil, foodType: String? = nil, startDate: Date? = nil) { -+ public let userCreatedDate: Date -+ -+ public init(amountInGrams: Double, absorptionTime: TimeInterval? = nil, foodType: String? = nil, startDate: Date? = nil, userCreatedDate: Date) { - self.amountInGrams = amountInGrams - self.absorptionTime = absorptionTime - self.foodType = foodType - self.startDate = startDate -+ self.userCreatedDate = userCreatedDate - } - } -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteCommandSource.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteCommandSource.swift -index e3cea7f..eb57fac 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteCommandSource.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/RemoteCommandSource.swift -@@ -9,5 +9,6 @@ - import LoopKit - - protocol RemoteCommandSource { -- func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async -+ func remoteNotificationWasReceived(_ notification: [String: AnyObject], serviceDelegate: ServiceDelegate) async -+ func notificationHistory() async -> [StoredRemoteNotification] - } -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift -index 1c14cdf..869abc2 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift -@@ -14,11 +14,11 @@ public struct BolusRemoteNotification: RemoteNotification, Codable { - public let amount: Double - public let remoteAddress: String - public let expiration: Date? -- public let sentAt: Date? -+ public let sentAt: Date - public let otp: String? - public let enteredBy: String? - public let encryptedReturnNotification: String? -- -+ - enum CodingKeys: String, CodingKey { - case remoteAddress = "remote-address" - case amount = "bolus-entry" -@@ -28,9 +28,13 @@ public struct BolusRemoteNotification: RemoteNotification, Codable { - case enteredBy = "entered-by" - case encryptedReturnNotification = "encrypted_return_notification" - } -- -+ - func toRemoteAction() -> Action { -- return .bolusEntry(BolusAction(amountInUnits: amount)) -+ return .bolusEntry(toBolusAction()) -+ } -+ -+ func toBolusAction() -> BolusAction { -+ return BolusAction(amountInUnits: amount, userCreatedDate: sentAt) - } - - func otpValidationRequired() -> Bool { -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift -index 1593a74..eda9134 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift -@@ -11,7 +11,7 @@ public struct CarbDeleteRemoteNotification: RemoteNotification, Codable { - public let syncIdentifier: String - public let remoteAddress: String - public let expiration: Date? -- public let sentAt: Date? -+ public let sentAt: Date - public let otp: String? - public let enteredBy: String? - public let encryptedReturnNotification: String? -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift -index fde3e4c..f1ffb51 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift -@@ -16,7 +16,7 @@ public struct CarbEditRemoteNotification: RemoteNotification, Codable { - public let startDate: Date - public let remoteAddress: String - public let expiration: Date? -- public let sentAt: Date? -+ public let sentAt: Date - public let otp: String? - public let enteredBy: String? - public let encryptedReturnNotification: String? -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift -index 6c238ed..c069c34 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift -@@ -17,7 +17,7 @@ public struct CarbRemoteNotification: RemoteNotification, Codable { - public let startDate: Date? - public let remoteAddress: String - public let expiration: Date? -- public let sentAt: Date? -+ public let sentAt: Date - public let otp: String? - public let enteredBy: String? - public let encryptedReturnNotification: String? -@@ -43,7 +43,7 @@ public struct CarbRemoteNotification: RemoteNotification, Codable { - } - - func toRemoteAction() -> Action { -- let action = CarbAction(amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate) -+ let action = CarbAction(amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate, userCreatedDate: sentAt) - return .carbsEntry(action) - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift -index fdd6d6b..babb71f 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift -@@ -13,7 +13,7 @@ public struct OverrideCancelRemoteNotification: RemoteNotification, Codable { - - public let remoteAddress: String - public let expiration: Date? -- public let sentAt: Date? -+ public let sentAt: Date - public let cancelOverride: String - public let enteredBy: String? - public let otp: String? -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift -index 2060a74..426ff89 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift -@@ -15,7 +15,7 @@ public struct OverrideRemoteNotification: RemoteNotification, Codable { - public let durationInMinutes: Double? - public let remoteAddress: String - public let expiration: Date? -- public let sentAt: Date? -+ public let sentAt: Date - public let enteredBy: String? - public let otp: String? - public let encryptedReturnNotification: String? -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -index 487c620..ebcd6ef 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -16,7 +16,7 @@ public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { - public let fingerprint: String - public let remoteAddress: String - public let expirationDate: Date -- public let sentAt: Date? -+ public let sentAt: Date - public let otp: String? - public let enteredBy: String? - public let encryptedReturnNotification: String? -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index 87f1e92..1ea8b96 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -14,7 +14,7 @@ protocol RemoteNotification: Codable { - - var id: String {get} - var expiration: Date? {get} -- var sentAt: Date? {get} -+ var sentAt: Date {get} - var otp: String? {get} - var remoteAddress: String {get} - var enteredBy: String? {get} -@@ -40,12 +40,8 @@ extension RemoteNotification { - } - - var id: String { -- //There is no unique identifier so we use the sent date when available -- if let sentAt = sentAt { -- return "\(sentAt.timeIntervalSince1970)" -- } else { -- return UUID().uuidString -- } -+ //There is no unique identifier so we use the sent date -+ return "\(sentAt.timeIntervalSince1970)" - } - - func getReturnNotificationInfo() -> ReturnNotificationInfo? { -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -index 0165004..bff69c8 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/RemoteCommandSourceV1.swift -@@ -7,69 +7,558 @@ - // - - import Foundation -+import LoopKit - import OSLog - - class RemoteCommandSourceV1: RemoteCommandSource { -- -+ - weak var delegate: RemoteCommandSourceV1Delegate? - private let otpManager: OTPManager - private let log = OSLog(category: "Remote Command Source V1") - private var commandValidator: RemoteCommandValidator - private var recentNotifications = RecentNotifications() -- -+ - init(otpManager: OTPManager) { - self.otpManager = otpManager - self.commandValidator = RemoteCommandValidator(otpManager: otpManager) - } -- -+ - //MARK: RemoteCommandSource -- -- func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async { -- -- if let encryptedReturnNotification = notification["encrypted_return_notification"] { -- log.info("Found encrypted_return_notification in notification: %{public}@", String(describing: encryptedReturnNotification)) -- } else { -- log.info("No encrypted_return_notification found in notification. Available keys: %{public}@", Array(notification.keys).joined(separator: ", ")) -+ -+ func remoteNotificationWasReceived(_ notification: [String: AnyObject], serviceDelegate: ServiceDelegate) async { -+ -+ guard let remoteNotification = try? notification.toRemoteNotification() else { -+ log.error("Remote Notification: Malformed notification payload") -+ return -+ } -+ -+ guard await !recentNotifications.contains(pushIdentifier: remoteNotification.id) else { -+ // Duplicate notifications are expected after app is force killed -+ // https://github.com/LoopKit/Loop/issues/2174 -+ return -+ } -+ -+ // Extract APNS return info for sending responses back to the caregiver -+ let returnInfo = remoteNotification.getReturnNotificationInfo() -+ if returnInfo != nil { -+ log.info("Return notification info available, will send APNS response after command processing") - } -- -+ -+ var commandType: RemoteNotificationResponseManager.CommandType = .bolus -+ var successMessage = "" -+ var syncIdentifier: String? = nil -+ - do { -- guard let delegate = delegate else {return} -- let remoteNotification = try notification.toRemoteNotification() -- -- // Log after parsing to see if the field was preserved -- if let encryptedReturnNotification = remoteNotification.encryptedReturnNotification { -- log.info("Parsed encrypted_return_notification successfully, length: %d", encryptedReturnNotification.count) -- } else { -- log.info("encrypted_return_notification is nil after parsing") -+ try await recentNotifications.trackReceivedRemoteNotification(remoteNotification, rawNotification: notification) -+ try commandValidator.validate(remoteNotification: remoteNotification) -+ -+ switch remoteNotification.toRemoteAction() { -+ case .bolusEntry(let bolusCommand): -+ commandType = .bolus -+ let doseEntry = try await serviceDelegate.deliverRemoteBolus( -+ amountInUnits: bolusCommand.amountInUnits, -+ userCreatedDate: bolusCommand.userCreatedDate -+ ) -+ var adjustmentMessage: String? = nil -+ if bolusCommand.amountInUnits > doseEntry.programmedUnits { -+ let quantityFormatter = QuantityFormatter(for: .internationalUnit()) -+ if let bolusAmountDescription = quantityFormatter.numberFormatter.string(from: bolusCommand.amountInUnits as NSNumber), -+ let doseAmountDescription = quantityFormatter.numberFormatter.string(from: doseEntry.programmedUnits as NSNumber){ -+ adjustmentMessage = "Bolus amount was reduced from \(bolusAmountDescription) U to \(doseAmountDescription) U due to other recent treatments." -+ } -+ } -+ successMessage = String(format: "Bolus of %.2f units delivered successfully", doseEntry.programmedUnits) -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .success(date: Date(), syncIdentifier: doseEntry.syncIdentifier ?? "", completionMessage: adjustmentMessage), -+ notificationJSON: notification -+ ) -+ case .cancelTemporaryOverride: -+ commandType = .cancelOverride -+ let cancelledOverride = try await serviceDelegate.cancelRemoteOverride() -+ successMessage = "Override cancelled successfully" -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .success(date: Date(), syncIdentifier: cancelledOverride.syncIdentifier.uuidString, completionMessage: nil), -+ notificationJSON: notification -+ ) -+ case .carbsEntry(let carbCommand): -+ commandType = .carbs -+ let carbEntry = try await serviceDelegate.deliverRemoteCarbs( -+ amountInGrams: carbCommand.amountInGrams, -+ absorptionTime: carbCommand.absorptionTime, -+ foodType: carbCommand.foodType, -+ startDate: carbCommand.startDate, -+ userCreatedDate: carbCommand.userCreatedDate -+ ) -+ successMessage = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .success(date: Date(), syncIdentifier: carbEntry.syncIdentifier ?? "", completionMessage: nil), -+ notificationJSON: notification -+ ) -+ case .carbsDelete(let deleteCommand): -+ commandType = .carbsDelete -+ syncIdentifier = deleteCommand.syncIdentifier -+ let deletedEntry = try await serviceDelegate.deleteRemoteCarbs(syncIdentifier: deleteCommand.syncIdentifier) -+ successMessage = "Carb entry deleted successfully" -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .success(date: Date(), syncIdentifier: deletedEntry.syncIdentifier ?? "", completionMessage: nil), -+ notificationJSON: notification -+ ) -+ case .carbsEdit(let editCommand): -+ commandType = .carbsEdit -+ syncIdentifier = editCommand.syncIdentifier -+ let replacementEntry = try await serviceDelegate.replaceRemoteCarbs( -+ syncIdentifier: editCommand.syncIdentifier, -+ amountInGrams: editCommand.amountInGrams, -+ absorptionTime: editCommand.absorptionTime, -+ foodType: editCommand.foodType, -+ startDate: editCommand.startDate -+ ) -+ successMessage = String(format: "Carb entry updated to %.1f g", editCommand.amountInGrams) -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .success(date: Date(), syncIdentifier: replacementEntry.syncIdentifier ?? "", completionMessage: nil), -+ notificationJSON: notification -+ ) -+ case .profileSwitch(let profileSwitchCommand): -+ commandType = .profileSwitch -+ guard let profileDelegate = serviceDelegate as? RemoteProfileActionDelegate else { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, RemoteProfileSwitchError.unavailable) -+ } -+ do { -+ try await profileDelegate.activateRemoteProfile(name: profileSwitchCommand.name) { correctionRange, carbRatio, basalRate, insulinSensitivity in -+ let savedProfile = SavedProfile(name: profileSwitchCommand.name, correctionRange: correctionRange, carbRatioSchedule: carbRatio, basalRateSchedule: basalRate, insulinSensitivitySchedule: insulinSensitivity) -+ guard savedProfile.matches(fingerprint: profileSwitchCommand.fingerprint) else { -+ throw RemoteProfileSwitchError.profileChanged -+ } -+ } -+ } catch { -+ throw RemoteProfileSwitchError.notActivated(profileSwitchCommand.name, error) -+ } -+ successMessage = "Profile '\(profileSwitchCommand.name)' activated" -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .success(date: Date(), syncIdentifier: "", completionMessage: nil), -+ notificationJSON: notification -+ ) -+ case .temporaryScheduleOverride(let overrideCommand): -+ commandType = .override -+ let override = try await serviceDelegate.enactRemoteOverride( -+ name: overrideCommand.name, -+ durationTime: overrideCommand.durationTime, -+ remoteAddress: overrideCommand.remoteAddress -+ ) -+ successMessage = "Override '\(overrideCommand.name)' enacted successfully" -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .success(date: Date(), syncIdentifier: override.syncIdentifier.uuidString, completionMessage: nil), -+ notificationJSON: notification -+ ) - } -- -- guard await !recentNotifications.isDuplicate(remoteNotification) else { -- // Duplicate notifications are expected after app is force killed -- // https://github.com/LoopKit/Loop/issues/2174 -- return -+ -+ // Send APNS success response back to caregiver -+ if let returnInfo = returnInfo { -+ await RemoteNotificationResponseManager.shared.sendResponseNotification( -+ to: returnInfo, -+ commandType: commandType, -+ success: true, -+ message: successMessage, -+ syncIdentifier: syncIdentifier -+ ) - } -- try commandValidator.validate(remoteNotification: remoteNotification) -- try await delegate.commandSourceV1(self, handleAction: remoteNotification.toRemoteAction(), remoteNotification: remoteNotification) - } catch { - log.error("Remote Notification: %{public}@. Error: %{public}@", String(describing: notification), String(describing: error)) -- try? await self.delegate?.commandSourceV1(self, uploadError: error, notification: notification) -+ await handleEnactmentCompletion( -+ remoteNotification: remoteNotification, -+ status: .failure(date: Date(), errorMessage: error.localizedDescription), -+ notificationJSON: notification -+ ) -+ -+ // Send APNS failure response back to caregiver -+ if let returnInfo = returnInfo { -+ await RemoteNotificationResponseManager.shared.sendResponseNotification( -+ to: returnInfo, -+ commandType: commandType, -+ success: false, -+ message: "Command failed: \(error.localizedDescription)", -+ syncIdentifier: syncIdentifier -+ ) -+ } -+ } -+ } -+ -+ func handleEnactmentCompletion( -+ remoteNotification: RemoteNotification, -+ status: RemoteNotificationStatus, -+ notificationJSON: [String: AnyObject] -+ ) async { -+ do { -+ let storedNotification = try await recentNotifications.updateStatus(status, for: remoteNotification.id) -+ await uploadStoredNotification(storedNotification) -+ } catch { -+ log.error("Remote Notification: %{public}@. Error: %{public}@", String(describing: notificationJSON), String(describing: error)) -+ } -+ } -+ -+ func uploadStoredNotification(_ storedNotification: StoredRemoteNotification) async { -+ do { -+ switch storedNotification.status { -+ case .success(_, _, let completionMessage): -+ if let completionMessage { -+ // Store adjustments as an error note to Nightscout -+ // try await self.delegate?.commandSourceV1(self, uploadError: completionMessage, receivedDate: storedNotification.receivedDate, notification: storedNotification.notificationJSON()) -+ } -+ case .failure(_, let errorMessage): -+ try await self.delegate?.commandSourceV1(self, uploadError: errorMessage, receivedDate: storedNotification.receivedDate, notification: storedNotification.notificationJSON()) -+ case .none: -+ return -+ } -+ try await recentNotifications.updateUploadStatus(true, for: storedNotification.pushIdentifier) -+ } catch { -+ log.error("Remote Notification: %{public}@. Error: %{public}@", String(describing: storedNotification), String(describing: error)) -+ } -+ } -+ -+ func notificationHistory() async -> [StoredRemoteNotification] { -+ return await recentNotifications.notifications -+ } -+ -+ /// Uploads pending notifications. Limited to a few at a time to avoid long background delays. -+ func uploadPendingNotifications() async { -+ guard let mostRecentPendingNotification = await notificationHistory().filter({$0.isPendingUpload}).sorted(by: {$0.receivedDate > $1.receivedDate}).last else { -+ return -+ } -+ await uploadStoredNotification(mostRecentPendingNotification) -+ } -+ -+ func notificationPublisher() async -> AsyncStream<[StoredRemoteNotification]> { -+ return await recentNotifications.notificationPublisher() -+ } -+ -+ func deleteNotificationHistory() { -+ Task { -+ await recentNotifications.deleteNotificationHistory() - } - } - } - - protocol RemoteCommandSourceV1Delegate: AnyObject { -- func commandSourceV1(_: RemoteCommandSourceV1, handleAction action: Action, remoteNotification: RemoteNotification) async throws -- func commandSourceV1(_: RemoteCommandSourceV1, uploadError error: Error, notification: [String: AnyObject]) async throws -+ func commandSourceV1(_: RemoteCommandSourceV1, uploadError errorMessage: String, receivedDate: Date, notification: [String: AnyObject]) async throws - } - --private actor RecentNotifications { -- private var recentNotifications = [RemoteNotification]() -- -- func isDuplicate(_ remoteNotification: RemoteNotification) -> Bool { -- if recentNotifications.contains(where: {remoteNotification.id == $0.id}) { -+// MARK: Notification history -+ -+public class StoredRemoteNotification: NSObject, Codable { -+ public let remoteNotificationType: RemoteNotificationType -+ public let notificationJSONData: Data -+ public var status: RemoteNotificationStatus? = nil -+ public var receivedDate: Date -+ public var uploaded: Bool = false -+ -+ init(notificationType: RemoteNotificationType, notificationJSONData: Data) { -+ self.remoteNotificationType = notificationType -+ self.notificationJSONData = notificationJSONData -+ self.receivedDate = Date() -+ self.uploaded = false -+ } -+ -+ convenience init(bolusNotification: BolusRemoteNotification, notificationJSONData: Data) { -+ self.init(notificationType: .bolus(bolusNotification), notificationJSONData: notificationJSONData) -+ } -+ -+ convenience init(carbNotification: CarbRemoteNotification, notificationJSONData: Data) { -+ self.init(notificationType: .carbs(carbNotification), notificationJSONData: notificationJSONData) -+ } -+ -+ convenience init(carbDeleteNotification: CarbDeleteRemoteNotification, notificationJSONData: Data) { -+ self.init(notificationType: .carbsDelete(carbDeleteNotification), notificationJSONData: notificationJSONData) -+ } -+ -+ convenience init(carbEditNotification: CarbEditRemoteNotification, notificationJSONData: Data) { -+ self.init(notificationType: .carbsEdit(carbEditNotification), notificationJSONData: notificationJSONData) -+ } -+ -+ convenience init(profileSwitchNotification: ProfileSwitchRemoteNotification, notificationJSONData: Data) { -+ self.init(notificationType: .profileSwitch(profileSwitchNotification), notificationJSONData: notificationJSONData) -+ } -+ -+ convenience init(overrideNotification: OverrideRemoteNotification, notificationJSONData: Data) { -+ self.init(notificationType: .override(overrideNotification), notificationJSONData: notificationJSONData) -+ } -+ -+ convenience init(overrideCancelNotification: OverrideCancelRemoteNotification, notificationJSONData: Data) { -+ self.init(notificationType: .overrideCancel(overrideCancelNotification), notificationJSONData: notificationJSONData) -+ } -+ -+ public var pushIdentifier: String { -+ return remoteNotification().id -+ } -+ -+ var isPendingUpload: Bool { -+ guard !uploaded else { -+ return false -+ } -+ switch status { -+ case .success, .failure: - return true -+ case nil: -+ return false -+ } -+ } -+ -+ func containsDose(_ dose: DoseEntry) -> Bool { -+ guard case let .bolus(bolusNotification) = remoteNotificationType else { -+ return false -+ } -+ -+ if case let .success(_, syncIdentifier: syncIdentifier, _) = status { -+ return dose.syncIdentifier == syncIdentifier - } -- recentNotifications.append(remoteNotification) -- return false -+ -+ // If sync identifier not set yet, that could mean either a failure occurred -+ // or we are in the middle of processing this notification. -+ // Doses start uploading during remote bolus action, -+ // before syncIdentifier is set to StoredRemoteAction. -+ // Heuristics are used to match the dose. -+ guard isDateWithinEnactmentPeriod(dose.startDate) else { -+ return false -+ } -+ -+ return dose.programmedUnits <= bolusNotification.amount -+ } -+ -+ func isDateWithinEnactmentPeriod(_ date: Date) -> Bool { -+ guard date.isAfterOrEqual(otherDate: receivedDate) else { -+ return false -+ } -+ -+ guard let completionDate else { -+ // Either enactment is in progress or there was an app crash during enactment -+ // For the corner case of an app crash, we want only want to match if the -+ // received date is within a few minutes. It should be within seconds really -+ // but minutes help when paused in the Xcode debugger. -+ return date.timeIntervalSince(receivedDate) < 60 * 5 -+ } -+ -+ guard date.isBeforeOrEqual(otherDate: completionDate) else { -+ return false -+ } -+ -+ return true -+ } -+ -+ var completionDate: Date? { -+ guard let status else { -+ return nil -+ } -+ switch status { -+ case .success(let completionDate, _, _): -+ return completionDate -+ case .failure(let completionDate, _): -+ return completionDate -+ } -+ } -+ -+ func remoteNotification() -> RemoteNotification { -+ switch remoteNotificationType { -+ case let .bolus(bolusNotification): -+ return bolusNotification -+ case let .carbs(carbNotification): -+ return carbNotification -+ case let .carbsDelete(carbDeleteNotification): -+ return carbDeleteNotification -+ case let .carbsEdit(carbEditNotification): -+ return carbEditNotification -+ case let .profileSwitch(profileSwitchNotification): -+ return profileSwitchNotification -+ case let .override(overrideNotification): -+ return overrideNotification -+ case let .overrideCancel(overrideCancelNotification): -+ return overrideCancelNotification -+ } -+ } -+ -+ public func remoteAction() -> Action { -+ return remoteNotification().toRemoteAction() -+ } -+ -+ func notificationJSON() throws -> [String: AnyObject] { -+ let jsonObject = try JSONSerialization.jsonObject(with: notificationJSONData, options: []) -+ guard let notificationJSON = jsonObject as? [String: AnyObject] else { -+ throw StoredRemoteNotificationError.notificationJSONTypeIncorrect -+ } -+ return notificationJSON -+ } -+ -+ enum StoredRemoteNotificationError: Error { -+ case notificationJSONTypeIncorrect -+ } -+ -+ public enum RemoteNotificationType: Codable { -+ case bolus(BolusRemoteNotification) -+ case carbs(CarbRemoteNotification) -+ case carbsDelete(CarbDeleteRemoteNotification) -+ case carbsEdit(CarbEditRemoteNotification) -+ case override(OverrideRemoteNotification) -+ case overrideCancel(OverrideCancelRemoteNotification) -+ case profileSwitch(ProfileSwitchRemoteNotification) -+ } -+} -+ -+public enum RemoteNotificationStatus: Codable, Equatable { -+ case success(date: Date, syncIdentifier: String, completionMessage: String?) -+ case failure(date: Date, errorMessage: String) -+} -+ -+actor RecentNotifications { -+ var notifications: [StoredRemoteNotification] = [] -+ private var continuation: AsyncStream<[StoredRemoteNotification]>.Continuation? -+ -+ init() { -+ Task { -+ await loadNotifications() -+ } -+ } -+ -+ // Publish -+ -+ func notificationPublisher() -> AsyncStream<[StoredRemoteNotification]> { -+ return AsyncStream { continuation in -+ self.continuation = continuation -+ continuation.yield(notifications) -+ } -+ } -+ -+ private func publish(notifications: [StoredRemoteNotification]) { -+ self.notifications = notifications -+ continuation?.yield(notifications) -+ } -+ -+ // Misc -+ -+ func contains(pushIdentifier: String) -> Bool { -+ return storedNotification(for: pushIdentifier) != nil -+ } -+ -+ func storedNotification(for id: String) -> StoredRemoteNotification? { -+ return notifications.first(where: {$0.pushIdentifier == id}) -+ } -+ -+ func trackReceivedRemoteNotification(_ remoteNotification: RemoteNotification, rawNotification: [String : AnyObject]) throws { -+ let data = try JSONSerialization.data(withJSONObject: rawNotification, options: []) -+ if let bolusNotification = remoteNotification as? BolusRemoteNotification { -+ let storedNotification = StoredRemoteNotification(bolusNotification: bolusNotification, notificationJSONData: data) -+ try storeNotification(storedNotification) -+ } else if let carbNotification = remoteNotification as? CarbRemoteNotification { -+ let storedNotification = StoredRemoteNotification(carbNotification: carbNotification, notificationJSONData: data) -+ try storeNotification(storedNotification) -+ } else if let carbDeleteNotification = remoteNotification as? CarbDeleteRemoteNotification { -+ let storedNotification = StoredRemoteNotification(carbDeleteNotification: carbDeleteNotification, notificationJSONData: data) -+ try storeNotification(storedNotification) -+ } else if let carbEditNotification = remoteNotification as? CarbEditRemoteNotification { -+ let storedNotification = StoredRemoteNotification(carbEditNotification: carbEditNotification, notificationJSONData: data) -+ try storeNotification(storedNotification) -+ } else if let profileSwitchNotification = remoteNotification as? ProfileSwitchRemoteNotification { -+ let storedNotification = StoredRemoteNotification(profileSwitchNotification: profileSwitchNotification, notificationJSONData: data) -+ try storeNotification(storedNotification) -+ } else if let overrideNotification = remoteNotification as? OverrideRemoteNotification { -+ let storedNotification = StoredRemoteNotification(overrideNotification: overrideNotification, notificationJSONData: data) -+ try storeNotification(storedNotification) -+ } else if let overrideCancelNotification = remoteNotification as? OverrideCancelRemoteNotification { -+ let storedNotification = StoredRemoteNotification(overrideCancelNotification: overrideCancelNotification, notificationJSONData: data) -+ try storeNotification(storedNotification) -+ } else { -+ fatalError() -+ } -+ } -+ -+ func updateStatus(_ status: RemoteNotificationStatus, for pushIdentifier: String) throws -> StoredRemoteNotification { -+ guard let storedNotification = storedNotification(for: pushIdentifier) else { -+ throw RemoteNotificationError.notificationNotFound(pushIdentifier) -+ } -+ storedNotification.status = status -+ try storeNotification(storedNotification) -+ return storedNotification -+ } -+ -+ func updateUploadStatus(_ uploaded: Bool, for pushIdentifier: String) throws { -+ guard let storedNotification = storedNotification(for: pushIdentifier) else { -+ throw RemoteNotificationError.notificationNotFound(pushIdentifier) -+ } -+ storedNotification.uploaded = uploaded -+ try storeNotification(storedNotification) -+ } -+ -+ // Disk Storage -+ -+ static let maxStoredNotifications = 50 -+ -+ static var storageURL: URL { -+ let appSupportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! -+ return appSupportDir.appendingPathComponent("remote_notifications.json") -+ } -+ -+ private func storeNotification(_ notification: StoredRemoteNotification) throws { -+ if let existingIndex = notifications.firstIndex(where: {$0.pushIdentifier == notification.pushIdentifier}) { -+ notifications[existingIndex] = notification -+ } else { -+ notifications.append(notification) -+ } -+ -+ if notifications.count > Self.maxStoredNotifications { -+ notifications = Array(notifications.suffix(Self.maxStoredNotifications)) -+ } -+ -+ try saveNotifications() -+ publish(notifications: notifications) -+ } -+ -+ private func saveNotifications() throws { -+ let encoder = JSONEncoder() -+ encoder.dateEncodingStrategy = .iso8601 -+ let data = try encoder.encode(notifications) -+ try data.write(to: Self.storageURL) -+ } -+ -+ private func loadNotifications() { -+ let decoder = JSONDecoder() -+ decoder.dateDecodingStrategy = .iso8601 -+ guard let data = try? Data(contentsOf: Self.storageURL) else { -+ return -+ } -+ guard let loaded = try? decoder.decode([StoredRemoteNotification].self, from: data) else { -+ return -+ } -+ self.notifications = loaded -+ } -+ -+ func deleteNotificationHistory() { -+ notifications = [] -+ try? saveNotifications() -+ publish(notifications: notifications) -+ } -+} -+ -+enum RemoteNotificationError: LocalizedError { -+ case notificationNotFound(String) -+ -+ var errorDescription: String? { -+ switch self { -+ case .notificationNotFound(let id): -+ return "Notification not found: \(id)" -+ } -+ } -+} -+ -+extension Date { -+ func isBeforeOrEqual(otherDate: Date) -> Bool { -+ return self <= otherDate -+ } -+ -+ func isAfterOrEqual(otherDate: Date) -> Bool { -+ return self >= otherDate - } - } -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -index 21d0e0a..5be6e1c 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -@@ -20,9 +20,6 @@ struct RemoteCommandValidator { - guard remoteNotification.expiration != nil else { - throw NotificationValidationError.missingExpiration - } -- guard remoteNotification.sentAt != nil else { -- throw NotificationValidationError.missingSentAt -- } - } - try validateExpirationDate(remoteNotification: remoteNotification) - if remoteNotification.otpValidationRequired() { -@@ -31,13 +28,13 @@ struct RemoteCommandValidator { - } - - private func validateExpirationDate(remoteNotification: RemoteNotification) throws { -- -+ - guard let expirationDate = remoteNotification.expiration else { - return //Skip validation if no date included - } -- -+ - if nowDateSource() > expirationDate { -- throw NotificationValidationError.expiredNotification -+ throw NotificationValidationError.expiredNotification(sentDate: remoteNotification.sentAt, receivedDate: nowDateSource()) - } - } - -@@ -52,20 +49,19 @@ struct RemoteCommandValidator { - - enum NotificationValidationError: LocalizedError { - case missingOTP -- case expiredNotification -+ case expiredNotification(sentDate: Date, receivedDate: Date) - case missingExpiration -- case missingSentAt -- -+ - var errorDescription: String? { - switch self { - case .missingOTP: - return LocalizedString("Missing OTP", comment: "Remote command error description: Missing OTP.") - case .missingExpiration: - return LocalizedString("Missing expiration", comment: "Remote command error description: missing expiration.") -- case .missingSentAt: -- return LocalizedString("Missing sent-at time", comment: "Remote command error description: missing sent-at.") -- case .expiredNotification: -- return LocalizedString("Expired", comment: "Remote command error description: expired.") -+ case .expiredNotification(let sentDate, let receivedDate): -+ let dateFormatter = DateFormatter() -+ dateFormatter.dateFormat = "h:mm:ss a" -+ return String(format: LocalizedString("Expired. Sent: %@. Received: %@", comment: "Remote command error description: expired."), dateFormatter.string(from: sentDate), dateFormatter.string(from: receivedDate)) - } - } - } -diff --git a/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift -index 6a3aba6..f03768c 100644 ---- a/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift -+++ b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift -@@ -48,20 +48,14 @@ final class CarbDeleteRemoteNotificationTestCase: XCTestCase { - } - } - -- func testValidate_MissingSentAt_Throws() throws { -+ func testParseCarbDeleteNotification_MissingSentAt_Throws() throws { - let notification: [String: AnyObject] = [ - "remote-address": "::ffff:11.2.44.155" as AnyObject, - "expiration": "2099-02-25T20:51:35.778Z" as AnyObject, - "carbs-delete": "abc" as AnyObject, - "otp": "12345" as AnyObject - ] -- let remoteNotification = try notification.toRemoteNotification() -- let validator = RemoteCommandValidator(otpManager: OTPTestCoordinator().createOTPManager()) - -- XCTAssertThrowsError(try validator.validate(remoteNotification: remoteNotification)) { error in -- guard case RemoteCommandValidator.NotificationValidationError.missingSentAt = error else { -- return XCTFail("Unexpected error \(error)") -- } -- } -+ XCTAssertThrowsError(try notification.toRemoteNotification()) - } - } -diff --git a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -index 3d54080..bb9423b 100644 ---- a/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -+++ b/NightscoutService/NightscoutServiceKitUI/Models/ServiceStatusViewModel.swift -@@ -14,6 +14,9 @@ import LoopKit - protocol ServiceStatusViewModelDelegate { - func verifyConfiguration(completion: @escaping (Error?) -> Void) - var siteURL: URL? { get } -+ func notificationHistory() async -> [StoredRemoteNotification] -+ func notificationPublisher() async -> AsyncStream<[StoredRemoteNotification]> -+ func deleteNotificationHistory() - } - - enum ServiceStatus { -@@ -35,19 +38,100 @@ extension ServiceStatus: CustomStringConvertible { - } - } - -+struct RemoteCommand: Identifiable { -+ let id: String -+ let actionName: String -+ let createdDate: String -+ let details: String -+ let statusMessage: String -+ let isError: Bool -+} -+ -+extension StoredRemoteNotification { -+ var actionName: String { -+ switch remoteNotificationType { -+ case .bolus: -+ return "Bolus" -+ case .carbs: -+ return "Carbs" -+ case .carbsDelete: -+ return "Delete Carbs" -+ case .carbsEdit: -+ return "Edit Carbs" -+ case .override: -+ return "Override" -+ case .profileSwitch: -+ return "Profile Switch" -+ case .overrideCancel: -+ return "Cancel Override" -+ } -+ } -+ -+ var createdDateDescription: String { -+ let formatter = DateFormatter() -+ formatter.dateStyle = .short -+ formatter.timeStyle = .medium -+ return formatter.string(from: receivedDate) -+ } -+ -+ var details: String { -+ switch remoteNotificationType { -+ case .bolus(let notification): -+ return String(format: "%.2f U", notification.amount) -+ case .carbs(let notification): -+ return String(format: "%.0f g", notification.amount) -+ case .carbsDelete(let notification): -+ return String(notification.syncIdentifier.prefix(8)) -+ case .carbsEdit(let notification): -+ return String(format: "%.0f g", notification.amount) -+ case .override(let notification): -+ return notification.name -+ case .profileSwitch(let notification): -+ return notification.profileName -+ case .overrideCancel: -+ return "" -+ } -+ } -+ -+ var statusMessage: String { -+ guard let status else { -+ return "Pending..." -+ } -+ switch status { -+ case .success(_, _, let completionMessage): -+ return completionMessage ?? "Success" -+ case .failure(_, let errorMessage): -+ return errorMessage -+ } -+ } -+ -+ var isError: Bool { -+ guard let status else { -+ return false -+ } -+ switch status { -+ case .success: -+ return false -+ case .failure: -+ return true -+ } -+ } -+} -+ - class ServiceStatusViewModel: ObservableObject { - @Published var status: ServiceStatus = .checking -- -+ @Published var remoteCommands: [RemoteCommand] = [] -+ - let delegate: ServiceStatusViewModelDelegate - var didLogout: (() -> Void)? -- -+ - var urlString: String { - return delegate.siteURL?.absoluteString ?? LocalizedString("Not Available", comment: "Error when nightscout service url is not set") - } - - init(delegate: ServiceStatusViewModelDelegate) { - self.delegate = delegate -- -+ - delegate.verifyConfiguration { (error) in - DispatchQueue.main.async { - if let error = error { -@@ -57,5 +141,24 @@ class ServiceStatusViewModel: ObservableObject { - } - } - } -+ -+ Task { -+ let stream = await delegate.notificationPublisher() -+ for await notifications in stream { -+ let commands = notifications.reversed().map { notification in -+ RemoteCommand( -+ id: notification.pushIdentifier, -+ actionName: notification.actionName, -+ createdDate: notification.createdDateDescription, -+ details: notification.details, -+ statusMessage: notification.statusMessage, -+ isError: notification.isError -+ ) -+ } -+ await MainActor.run { -+ self.remoteCommands = commands -+ } -+ } -+ } - } - } -diff --git a/NightscoutService/NightscoutServiceKitUI/Views/ServiceStatusView.swift b/NightscoutService/NightscoutServiceKitUI/Views/ServiceStatusView.swift -index 4ecbdde..f213ccb 100644 ---- a/NightscoutService/NightscoutServiceKitUI/Views/ServiceStatusView.swift -+++ b/NightscoutService/NightscoutServiceKitUI/Views/ServiceStatusView.swift -@@ -17,56 +17,86 @@ struct ServiceStatusView: View, HorizontalSizeClassOverride { - @ObservedObject var otpViewModel: OTPViewModel - @State private var selectedItem: String? - var body: some View { -- VStack { -- Text("Nightscout") -- .font(.largeTitle) -- .fontWeight(.semibold) -- Image(frameworkImage: "nightscout", decorative: true) -- .resizable() -- .aspectRatio(contentMode: .fill) -- .frame(width: 150, height: 150) -- -+ List { -+ Section { -+ VStack { -+ Text("Nightscout") -+ .font(.largeTitle) -+ .fontWeight(.semibold) -+ Image(frameworkImage: "nightscout", decorative: true) -+ .resizable() -+ .aspectRatio(contentMode: .fill) -+ .frame(width: 150, height: 150) -+ } -+ .frame(maxWidth: .infinity) -+ .listRowBackground(Color.clear) -+ } - -- VStack(spacing: 0) { -+ Section { - HStack { - Text("URL") - Spacer() - Text(viewModel.urlString) - } -- .padding() -- Divider() - HStack { - Text("Status") - Spacer() - Text(String(describing: viewModel.status)) - } -- .padding() -- Divider() - NavigationLink(destination: OTPSelectionView(otpViewModel: otpViewModel), tag: "otp-view", selection: $selectedItem) { - HStack { - Text("One-Time Password") - Spacer() - Text(otpViewModel.otpCode) -- Image(systemName: "chevron.right") -- .font(.caption) - } -- }.foregroundColor(Color.primary) -- .padding() -+ } - } -- .background(Color(UIColor.secondarySystemBackground)) -- .cornerRadius(10) -- -- Button(action: { -- viewModel.didLogout?() -- } ) { -- Text("Logout").padding(.top, 20) -+ -+ Section(header: Text("Remote Commands")) { -+ if viewModel.remoteCommands.isEmpty { -+ Text("No remote commands received") -+ .foregroundColor(.secondary) -+ } else { -+ ForEach(viewModel.remoteCommands) { command in -+ VStack(alignment: .leading, spacing: 4) { -+ HStack { -+ Text(command.actionName) -+ .fontWeight(.medium) -+ if !command.details.isEmpty { -+ Text(command.details) -+ .foregroundColor(.secondary) -+ } -+ Spacer() -+ Text(command.createdDate) -+ .font(.caption) -+ .foregroundColor(.secondary) -+ } -+ Text(command.statusMessage) -+ .font(.caption) -+ .foregroundColor(command.isError ? .red : .secondary) -+ } -+ } -+ } -+ Button(role: .destructive) { -+ viewModel.delegate.deleteNotificationHistory() -+ } label: { -+ Text("Remove History") -+ } -+ } -+ -+ Section { -+ Button(action: { -+ viewModel.didLogout?() -+ } ) { -+ Text("Logout") -+ .foregroundColor(.red) -+ } - } - } -- .padding([.leading, .trailing]) - .navigationBarTitle("") - .navigationBarItems(trailing: dismissButton) - } -- -+ - private var dismissButton: some View { - Button(action: dismiss) { - Text("Done").bold() -diff --git a/remote_window.loopcustomization b/remote_window.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/remote_window/nextdev_remote_window_profiles.patch b/remote_window/nextdev_remote_window_profiles.patch deleted file mode 100644 index 97a8134..0000000 --- a/remote_window/nextdev_remote_window_profiles.patch +++ /dev/null @@ -1,542 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift -index fe3c3e7f..0b8248c2 100644 ---- a/Loop/Loop/Managers/LoopAppManager.swift -+++ b/Loop/Loop/Managers/LoopAppManager.swift -@@ -1071,8 +1071,42 @@ extension LoopAppManager: ResetLoopManagerDelegate { - // MARK: - ServicesManagerDosingDelegate - - extension LoopAppManager: ServicesManagerDosingDelegate { -- func deliverBolus(amountInUnits: Double, decisionId: UUID?) async throws { -- try await deviceDataManager.enactBolus(units: amountInUnits, decisionId: decisionId, activationType: .manualNoRecommendation) -+ func deliverBolus(amountInUnits: Double, decisionId: UUID?, userCreatedDate: Date) async throws -> Double { -+ // Guard B (double-bolus protection): subtract any bolus delivered within -+ // minTreatmentIntervalInMinutes of the command's userCreatedDate from the requested -+ // amount (look back further for safety in case of upload delays), refusing entirely -+ // if the remainder nets to zero. Returns the amount actually delivered. -+ let minTreatmentIntervalInMinutes = 10.0 -+ let boluses = try await doseStore.getBoluses(start: Date().addingTimeInterval(-.days(1))) -+ let conflictStartDate = userCreatedDate.addingTimeInterval(-.minutes(minTreatmentIntervalInMinutes)) -+ let conflictingBolusAmount = boluses.filter({ $0.startDate >= conflictStartDate }).map({ $0.programmedUnits }).reduce(0, +) -+ let adjustedDoseAmount = amountInUnits - conflictingBolusAmount -+ guard adjustedDoseAmount <= amountInUnits else { // sanity check on the bolus math -+ throw BolusActionError.invalidCalculation -+ } -+ guard adjustedDoseAmount > 0.0 else { -+ throw BolusActionError.bolusAmountAdjustedToZero(amountInUnits, conflictStartDate) -+ } -+ try await deviceDataManager.enactBolus(units: adjustedDoseAmount, decisionId: decisionId, activationType: .manualNoRecommendation) -+ return adjustedDoseAmount -+ } -+ -+ enum BolusActionError: LocalizedError { -+ case bolusAmountAdjustedToZero(_ requestedAmountInUnits: Double, _ conflictStartDate: Date) -+ case invalidCalculation -+ -+ var errorDescription: String? { -+ switch self { -+ case .bolusAmountAdjustedToZero(let amountInUnits, let conflictStartDate): -+ let numberFormatter = NumberFormatter() -+ numberFormatter.numberStyle = .decimal -+ numberFormatter.maximumFractionDigits = 2 -+ let treatmentAmountFormatted = (numberFormatter.string(from: NSNumber(value: amountInUnits)) ?? "") + " U" -+ return String(format: NSLocalizedString("All boluses delivered after %1$@ were subtracted from the requested bolus amount for safety. Those amount(s) exceed the requested amount of %2$@ so the remote bolus was not delivered.", comment: "Bolus error description: no remote bolus delivered."), conflictStartDate.formatted(date: .omitted, time: .complete), treatmentAmountFormatted) -+ case .invalidCalculation: -+ return NSLocalizedString("An invalid bolus calculation occurred.", comment: "Bolus error description: invalid calculation.") -+ } -+ } - } - } - -diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift -index 28f07307..1966dcde 100644 ---- a/Loop/Loop/Managers/LoopDataManager.swift -+++ b/Loop/Loop/Managers/LoopDataManager.swift -@@ -1415,8 +1415,8 @@ extension LoopDataManager: ServicesManagerDelegate { - - //Carb Entry - -- func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { -- -+ func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws { -+ - let absorptionTime = absorptionTime ?? LoopCoreConstants.defaultCarbAbsorptionTimes.medium - if absorptionTime < LoopConstants.minCarbAbsorptionTime || absorptionTime > LoopConstants.maxCarbAbsorptionTime { - throw CarbActionError.invalidAbsorptionTime(absorptionTime) -@@ -1438,6 +1438,22 @@ extension LoopDataManager: ServicesManagerDelegate { - } - } - -+ // Guard B (conflicting-treatment protection): reject remote carbs if any carb entry was -+ // created within minTreatmentIntervalInMinutes of the command's userCreatedDate. Look back -+ // a little further for safety (e.g. upload delays); zero tolerance for carbs. -+ let maxAllowedConflictingAmountInGrams = 0.0 -+ let minTreatmentIntervalInMinutes = 10.0 -+ let entries = try await carbStore.getCarbEntries(start: Date().addingTimeInterval(-.days(1)), end: Date().addingTimeInterval(.days(1))) -+ let conflictStartDate = userCreatedDate.addingTimeInterval(-.minutes(minTreatmentIntervalInMinutes)) -+ let conflictingEntries = entries.filter({ entry in -+ let createdDate = entry.userCreatedDate ?? entry.startDate -+ return createdDate >= conflictStartDate -+ }) -+ let conflictingCarbAmount = conflictingEntries.map({ $0.quantity.doubleValue(for: .gram) }).reduce(0, +) -+ guard conflictingCarbAmount <= maxAllowedConflictingAmountInGrams else { -+ throw CarbActionError.conflictingTreatments(conflictingCarbAmount, minTreatmentIntervalInMinutes) -+ } -+ - let quantity = LoopQuantity(unit: .gram, doubleValue: amountInGrams) - let candidateCarbEntry = NewCarbEntry(syncIdentifier: UUID().uuidString, quantity: quantity, startDate: startDate ?? now, foodType: foodType, absorptionTime: absorptionTime) - -@@ -1450,9 +1466,14 @@ extension LoopDataManager: ServicesManagerDelegate { - case invalidStartDate(Date) - case exceedsMaxCarbs - case invalidCarbs -- -+ case conflictingTreatments(_ conflictingAmountInGrams: Double, _ minutesRequiredBetweenTreatments: TimeInterval) -+ - var errorDescription: String? { - switch self { -+ case .conflictingTreatments(let conflictingAmountInGrams, let minutesRequiredBetweenTreatments): -+ let treatmentAmountFormatted = (Self.numberFormatter.string(from: NSNumber(value: conflictingAmountInGrams)) ?? "") + " g" -+ let minutesRequiredBetweenTreatmentsFormatted = Self.numberFormatter.string(from: NSNumber(value: minutesRequiredBetweenTreatments)) ?? "" -+ return String(format: NSLocalizedString("Conflicting carb treatments (%1$@) have occurred. At least %2$@ minutes must pass between carb entries.", comment: "Carb error description: conflicting treatments occurred."), treatmentAmountFormatted, minutesRequiredBetweenTreatmentsFormatted) - case .exceedsMaxCarbs: - return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Carb error description: carbs exceed maximum amount.") - case .invalidCarbs: -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index a7da8548..462e815b 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -245,13 +245,13 @@ class ServicesManager { - } - - public protocol ServicesManagerDosingDelegate: AnyObject { -- func deliverBolus(amountInUnits: Double, decisionId: UUID?) async throws -+ func deliverBolus(amountInUnits: Double, decisionId: UUID?, userCreatedDate: Date) async throws -> Double - } - - public protocol ServicesManagerDelegate: AnyObject { - func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws - func cancelCurrentOverride() async throws -- func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws -+ func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws - } - - // MARK: - StatefulPluggableDelegate -@@ -325,9 +325,10 @@ extension ServicesManager: ServiceDelegate { - await remoteDataServicesManager.performUpload(for: .overrides) - } - -- func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { -+ func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws { - do { -- try await servicesManagerDelegate?.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) -+ guard let servicesManagerDelegate else { throw BolusActionError.internalError } -+ try await servicesManagerDelegate.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate, userCreatedDate: userCreatedDate) - NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: amountInGrams) - await remoteDataServicesManager.performUpload(for: .carb) - analyticsServicesManager.didAddCarbs(source: "Remote") -@@ -337,25 +338,30 @@ extension ServicesManager: ServiceDelegate { - } - } - -- func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?) async throws { -+ func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?, userCreatedDate: Date) async throws -> Double { - do { -- -+ - guard amountInUnits > 0 else { - throw BolusActionError.invalidBolus - } -- -+ - guard let maxBolusAmount = settingsManager.loopSettings.maximumBolus else { - throw BolusActionError.missingMaxBolus - } -- -+ - guard amountInUnits <= maxBolusAmount else { - throw BolusActionError.exceedsMaxBolus - } -- -- try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits, decisionId: decisionId) -- NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) -+ -+ guard let servicesManagerDosingDelegate else { -+ throw BolusActionError.internalError -+ } -+ // May be reduced by the conflicting-treatment guard; report the amount actually delivered. -+ let deliveredUnits = try await servicesManagerDosingDelegate.deliverBolus(amountInUnits: amountInUnits, decisionId: decisionId, userCreatedDate: userCreatedDate) -+ NotificationManager.sendRemoteBolusNotification(amount: deliveredUnits) - await remoteDataServicesManager.performUpload(for: .dose) - analyticsServicesManager.didBolus(source: "Remote") -+ return deliveredUnits - } catch { - NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) - throw error -@@ -367,7 +373,8 @@ extension ServicesManager: ServiceDelegate { - case invalidBolus - case missingMaxBolus - case exceedsMaxBolus -- -+ case internalError -+ - var errorDescription: String? { - switch self { - case .invalidBolus: -@@ -376,6 +383,8 @@ extension ServicesManager: ServiceDelegate { - return NSLocalizedString("Missing maximum allowed bolus in settings", comment: "Bolus error description: missing maximum bolus in settings.") - case .exceedsMaxBolus: - return NSLocalizedString("Exceeds maximum allowed bolus in settings", comment: "Bolus error description: bolus exceeds maximum bolus in settings.") -+ case .internalError: -+ return NSLocalizedString("An internal error occurred.", comment: "Bolus error description: internal error.") - } - } - } -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index a7c9c653..b82f71ed 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -17,6 +17,6 @@ public protocol RemoteProfileActionDelegate: AnyObject { - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws - func cancelRemoteOverride() async throws -- func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws -- func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?) async throws -+ func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -+ func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?, userCreatedDate: Date) async throws -> Double - } -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index 73db786..ec997ef 100644 ---- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift -+++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -14,6 +14,7 @@ public enum NightscoutServiceError: Error { - case incompatibleTherapySettings - case missingCredentials - case missingCommandSource -+ case missingServiceDelegate - } - - -@@ -66,10 +67,12 @@ public final class NightscoutService: Service { - - private let log = OSLog(category: "NightscoutService") - -+ private let notificationExpirationInMinutes = 10.0 -+ - public init() { - self.isOnboarded = false - self.lockedObjectIdCache = Locked(ObjectIdCache()) -- self.otpManager = OTPManager(secretStore: KeychainManager()) -+ self.otpManager = OTPManager(secretStore: KeychainManager(), maxMinutesValid: notificationExpirationInMinutes) - self.commandSourceV1 = RemoteCommandSourceV1(otpManager: otpManager) - self.commandSourceV1.delegate = self - } -@@ -85,7 +88,7 @@ public final class NightscoutService: Service { - self.lockedObjectIdCache = Locked(ObjectIdCache()) - } - -- self.otpManager = OTPManager(secretStore: KeychainManager()) -+ self.otpManager = OTPManager(secretStore: KeychainManager(), maxMinutesValid: notificationExpirationInMinutes) - self.commandSourceV1 = RemoteCommandSourceV1(otpManager: otpManager) - self.commandSourceV1.delegate = self - -@@ -422,6 +425,14 @@ extension NightscoutService: RemoteDataService { - - - public func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async throws { -+ var notification = notification -+ // Widen the acceptance window: override the expiration to sent-at + notificationExpirationInMinutes. -+ // Ideally this would be done in Nightscout instead. -+ if let sentDateString = notification["sent-at"] as? String, -+ let sentDate = DateFormatter.iso8601DateDecoder.date(from: sentDateString) { -+ let expirationDate = sentDate.addingTimeInterval(60 * notificationExpirationInMinutes) -+ notification["expiration"] = DateFormatter.iso8601DateDecoder.string(from: expirationDate) as AnyObject -+ } - let commandSource = try commandSource(notification: notification) - await commandSource.remoteNotificationWasReceived(notification) - } -@@ -448,10 +459,13 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - var message = "" - - do { -+ guard let serviceDelegate = self.serviceDelegate else { -+ throw NightscoutServiceError.missingServiceDelegate -+ } - switch action { - case .temporaryScheduleOverride(let overrideCommand): - commandType = .override -- try await self.serviceDelegate?.enactRemoteOverride( -+ try await serviceDelegate.enactRemoteOverride( - name: overrideCommand.name, - durationTime: overrideCommand.durationTime, - remoteAddress: overrideCommand.remoteAddress -@@ -479,23 +493,28 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - - case .cancelTemporaryOverride: - commandType = .cancelOverride -- try await self.serviceDelegate?.cancelRemoteOverride() -+ try await serviceDelegate.cancelRemoteOverride() - success = true - message = "Override cancelled successfully" - - case .bolusEntry(let bolusCommand): - commandType = .bolus -- try await self.serviceDelegate?.deliverRemoteBolus(amountInUnits: bolusCommand.amountInUnits, decisionId: nil) -+ let deliveredUnits = try await serviceDelegate.deliverRemoteBolus(amountInUnits: bolusCommand.amountInUnits, decisionId: nil, userCreatedDate: bolusCommand.userCreatedDate) - success = true -- message = String(format: "Bolus of %.2f units delivered successfully", bolusCommand.amountInUnits) -+ if deliveredUnits < bolusCommand.amountInUnits { -+ message = String(format: "Bolus reduced to %.2f units (from %.2f) due to other recent treatments", deliveredUnits, bolusCommand.amountInUnits) -+ } else { -+ message = String(format: "Bolus of %.2f units delivered successfully", deliveredUnits) -+ } - - case .carbsEntry(let carbCommand): - commandType = .carbs -- try await self.serviceDelegate?.deliverRemoteCarbs( -+ try await serviceDelegate.deliverRemoteCarbs( - amountInGrams: carbCommand.amountInGrams, - absorptionTime: carbCommand.absorptionTime, - foodType: carbCommand.foodType, -- startDate: carbCommand.startDate -+ startDate: carbCommand.startDate, -+ userCreatedDate: carbCommand.userCreatedDate - ) - success = true - message = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) -diff --git a/NightscoutService/NightscoutServiceKit/OTPManager.swift b/NightscoutService/NightscoutServiceKit/OTPManager.swift -index f558a46..921ff02 100644 ---- a/NightscoutService/NightscoutServiceKit/OTPManager.swift -+++ b/NightscoutService/NightscoutServiceKit/OTPManager.swift -@@ -77,8 +77,19 @@ public class OTPManager { - let maxOTPsToAccept: Int - - public static var defaultTokenPeriod: TimeInterval = 30 -- public static var defaultMaxOTPsToAccept = 2 -- -+ public static var defaultMaxOTPsToAccept = 30 -+ -+ public init(secretStore: OTPSecretStore = KeychainManager(), nowDateSource: @escaping () -> Date = {Date()}, maxMinutesValid: Double) { -+ self.secretStore = secretStore -+ self.nowDateSource = nowDateSource -+ self.tokenPeriod = OTPManager.defaultTokenPeriod -+ let secondsValid = 60.0 * maxMinutesValid -+ self.maxOTPsToAccept = Int(secondsValid / OTPManager.defaultTokenPeriod) -+ if secretStore.tokenSecretKey() == nil || secretStore.tokenSecretKeyName() == nil { -+ resetSecretKey() -+ } -+ } -+ - public init(secretStore: OTPSecretStore = KeychainManager(), nowDateSource: @escaping () -> Date = {Date()}, tokenPeriod: TimeInterval = OTPManager.defaultTokenPeriod, maxOTPsToAccept: Int = OTPManager.defaultMaxOTPsToAccept) { - self.secretStore = secretStore - self.nowDateSource = nowDateSource -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift -index 3a56d81..6db615c 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift -@@ -9,10 +9,12 @@ - import Foundation - - public struct BolusAction: Codable { -- -+ - public let amountInUnits: Double -- -- public init(amountInUnits: Double) { -+ public let userCreatedDate: Date -+ -+ public init(amountInUnits: Double, userCreatedDate: Date) { - self.amountInUnits = amountInUnits -+ self.userCreatedDate = userCreatedDate - } - } -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift -index 3865989..b315b16 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift -@@ -14,11 +14,13 @@ public struct CarbAction: Codable{ - public let absorptionTime: TimeInterval? - public let foodType: String? - public let startDate: Date? -- -- public init(amountInGrams: Double, absorptionTime: TimeInterval? = nil, foodType: String? = nil, startDate: Date? = nil) { -+ public let userCreatedDate: Date -+ -+ public init(amountInGrams: Double, absorptionTime: TimeInterval? = nil, foodType: String? = nil, startDate: Date? = nil, userCreatedDate: Date) { - self.amountInGrams = amountInGrams - self.absorptionTime = absorptionTime - self.foodType = foodType - self.startDate = startDate -+ self.userCreatedDate = userCreatedDate - } - } -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift -index 1c14cdf..869abc2 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift -@@ -14,11 +14,11 @@ public struct BolusRemoteNotification: RemoteNotification, Codable { - public let amount: Double - public let remoteAddress: String - public let expiration: Date? -- public let sentAt: Date? -+ public let sentAt: Date - public let otp: String? - public let enteredBy: String? - public let encryptedReturnNotification: String? -- -+ - enum CodingKeys: String, CodingKey { - case remoteAddress = "remote-address" - case amount = "bolus-entry" -@@ -28,9 +28,13 @@ public struct BolusRemoteNotification: RemoteNotification, Codable { - case enteredBy = "entered-by" - case encryptedReturnNotification = "encrypted_return_notification" - } -- -+ - func toRemoteAction() -> Action { -- return .bolusEntry(BolusAction(amountInUnits: amount)) -+ return .bolusEntry(toBolusAction()) -+ } -+ -+ func toBolusAction() -> BolusAction { -+ return BolusAction(amountInUnits: amount, userCreatedDate: sentAt) - } - - func otpValidationRequired() -> Bool { -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift -index 6c238ed..c069c34 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift -@@ -17,7 +17,7 @@ public struct CarbRemoteNotification: RemoteNotification, Codable { - public let startDate: Date? - public let remoteAddress: String - public let expiration: Date? -- public let sentAt: Date? -+ public let sentAt: Date - public let otp: String? - public let enteredBy: String? - public let encryptedReturnNotification: String? -@@ -43,7 +43,7 @@ public struct CarbRemoteNotification: RemoteNotification, Codable { - } - - func toRemoteAction() -> Action { -- let action = CarbAction(amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate) -+ let action = CarbAction(amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate, userCreatedDate: sentAt) - return .carbsEntry(action) - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift -index fdd6d6b..babb71f 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift -@@ -13,7 +13,7 @@ public struct OverrideCancelRemoteNotification: RemoteNotification, Codable { - - public let remoteAddress: String - public let expiration: Date? -- public let sentAt: Date? -+ public let sentAt: Date - public let cancelOverride: String - public let enteredBy: String? - public let otp: String? -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift -index 2060a74..426ff89 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift -@@ -15,7 +15,7 @@ public struct OverrideRemoteNotification: RemoteNotification, Codable { - public let durationInMinutes: Double? - public let remoteAddress: String - public let expiration: Date? -- public let sentAt: Date? -+ public let sentAt: Date - public let enteredBy: String? - public let otp: String? - public let encryptedReturnNotification: String? -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -index 487c620..ebcd6ef 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -16,7 +16,7 @@ public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { - public let fingerprint: String - public let remoteAddress: String - public let expirationDate: Date -- public let sentAt: Date? -+ public let sentAt: Date - public let otp: String? - public let enteredBy: String? - public let encryptedReturnNotification: String? -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index 3362bf7..a0f58a0 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -14,7 +14,7 @@ protocol RemoteNotification: Codable { - - var id: String {get} - var expiration: Date? {get} -- var sentAt: Date? {get} -+ var sentAt: Date {get} - var otp: String? {get} - var remoteAddress: String {get} - var enteredBy: String? {get} -@@ -30,12 +30,8 @@ protocol RemoteNotification: Codable { - extension RemoteNotification { - - var id: String { -- //There is no unique identifier so we use the sent date when available -- if let sentAt = sentAt { -- return "\(sentAt.timeIntervalSince1970)" -- } else { -- return UUID().uuidString -- } -+ //There is no unique identifier so we use the sent date -+ return "\(sentAt.timeIntervalSince1970)" - } - - func getReturnNotificationInfo() -> ReturnNotificationInfo? { -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -index 634eb4e..fce4623 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -@@ -23,13 +23,13 @@ struct RemoteCommandValidator { - } - - private func validateExpirationDate(remoteNotification: RemoteNotification) throws { -- -+ - guard let expirationDate = remoteNotification.expiration else { - return //Skip validation if no date included - } -- -+ - if nowDateSource() > expirationDate { -- throw NotificationValidationError.expiredNotification -+ throw NotificationValidationError.expiredNotification(sentDate: remoteNotification.sentAt, receivedDate: nowDateSource()) - } - } - -@@ -44,14 +44,16 @@ struct RemoteCommandValidator { - - enum NotificationValidationError: LocalizedError { - case missingOTP -- case expiredNotification -- -+ case expiredNotification(sentDate: Date, receivedDate: Date) -+ - var errorDescription: String? { - switch self { - case .missingOTP: - return LocalizedString("Missing OTP", comment: "Remote command error description: Missing OTP.") -- case .expiredNotification: -- return LocalizedString("Expired", comment: "Remote command error description: expired.") -+ case .expiredNotification(let sentDate, let receivedDate): -+ let dateFormatter = DateFormatter() -+ dateFormatter.dateFormat = "h:mm:ss a" -+ return String(format: LocalizedString("Expired. Sent: %@. Received: %@", comment: "Remote command error description: expired."), dateFormatter.string(from: sentDate), dateFormatter.string(from: receivedDate)) - } - } - } -diff --git a/remote_window.loopcustomization b/remote_window.loopcustomization -new file mode 100644 -index 0000000..e69de29 diff --git a/remote_window/nextdev_remote_window_profiles_remote_carb_edit.patch b/remote_window/nextdev_remote_window_profiles_remote_carb_edit.patch deleted file mode 100644 index 9463a82..0000000 --- a/remote_window/nextdev_remote_window_profiles_remote_carb_edit.patch +++ /dev/null @@ -1,625 +0,0 @@ -Submodule Loop contains modified content -diff --git a/Loop/Loop/Managers/LoopAppManager.swift b/Loop/Loop/Managers/LoopAppManager.swift -index fe3c3e7f..0b8248c2 100644 ---- a/Loop/Loop/Managers/LoopAppManager.swift -+++ b/Loop/Loop/Managers/LoopAppManager.swift -@@ -1071,8 +1071,42 @@ extension LoopAppManager: ResetLoopManagerDelegate { - // MARK: - ServicesManagerDosingDelegate - - extension LoopAppManager: ServicesManagerDosingDelegate { -- func deliverBolus(amountInUnits: Double, decisionId: UUID?) async throws { -- try await deviceDataManager.enactBolus(units: amountInUnits, decisionId: decisionId, activationType: .manualNoRecommendation) -+ func deliverBolus(amountInUnits: Double, decisionId: UUID?, userCreatedDate: Date) async throws -> Double { -+ // Guard B (double-bolus protection): subtract any bolus delivered within -+ // minTreatmentIntervalInMinutes of the command's userCreatedDate from the requested -+ // amount (look back further for safety in case of upload delays), refusing entirely -+ // if the remainder nets to zero. Returns the amount actually delivered. -+ let minTreatmentIntervalInMinutes = 10.0 -+ let boluses = try await doseStore.getBoluses(start: Date().addingTimeInterval(-.days(1))) -+ let conflictStartDate = userCreatedDate.addingTimeInterval(-.minutes(minTreatmentIntervalInMinutes)) -+ let conflictingBolusAmount = boluses.filter({ $0.startDate >= conflictStartDate }).map({ $0.programmedUnits }).reduce(0, +) -+ let adjustedDoseAmount = amountInUnits - conflictingBolusAmount -+ guard adjustedDoseAmount <= amountInUnits else { // sanity check on the bolus math -+ throw BolusActionError.invalidCalculation -+ } -+ guard adjustedDoseAmount > 0.0 else { -+ throw BolusActionError.bolusAmountAdjustedToZero(amountInUnits, conflictStartDate) -+ } -+ try await deviceDataManager.enactBolus(units: adjustedDoseAmount, decisionId: decisionId, activationType: .manualNoRecommendation) -+ return adjustedDoseAmount -+ } -+ -+ enum BolusActionError: LocalizedError { -+ case bolusAmountAdjustedToZero(_ requestedAmountInUnits: Double, _ conflictStartDate: Date) -+ case invalidCalculation -+ -+ var errorDescription: String? { -+ switch self { -+ case .bolusAmountAdjustedToZero(let amountInUnits, let conflictStartDate): -+ let numberFormatter = NumberFormatter() -+ numberFormatter.numberStyle = .decimal -+ numberFormatter.maximumFractionDigits = 2 -+ let treatmentAmountFormatted = (numberFormatter.string(from: NSNumber(value: amountInUnits)) ?? "") + " U" -+ return String(format: NSLocalizedString("All boluses delivered after %1$@ were subtracted from the requested bolus amount for safety. Those amount(s) exceed the requested amount of %2$@ so the remote bolus was not delivered.", comment: "Bolus error description: no remote bolus delivered."), conflictStartDate.formatted(date: .omitted, time: .complete), treatmentAmountFormatted) -+ case .invalidCalculation: -+ return NSLocalizedString("An invalid bolus calculation occurred.", comment: "Bolus error description: invalid calculation.") -+ } -+ } - } - } - -diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift -index 61cdb5c0..feb53338 100644 ---- a/Loop/Loop/Managers/LoopDataManager.swift -+++ b/Loop/Loop/Managers/LoopDataManager.swift -@@ -1415,11 +1415,27 @@ extension LoopDataManager: ServicesManagerDelegate { - - //Carb Entry - -- func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { -+ func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws { - let absorptionTime = absorptionTime ?? LoopCoreConstants.defaultCarbAbsorptionTimes.medium - let startDate = startDate ?? now - try validateRemoteCarbParameters(amountInGrams: amountInGrams, absorptionTime: absorptionTime, startDate: startDate) - -+ // Guard B (conflicting-treatment protection): reject remote carbs if any carb entry was -+ // created within minTreatmentIntervalInMinutes of the command's userCreatedDate. Look back -+ // a little further for safety (e.g. upload delays); zero tolerance for carbs. -+ let maxAllowedConflictingAmountInGrams = 0.0 -+ let minTreatmentIntervalInMinutes = 10.0 -+ let entries = try await carbStore.getCarbEntries(start: Date().addingTimeInterval(-.days(1)), end: Date().addingTimeInterval(.days(1))) -+ let conflictStartDate = userCreatedDate.addingTimeInterval(-.minutes(minTreatmentIntervalInMinutes)) -+ let conflictingEntries = entries.filter({ entry in -+ let createdDate = entry.userCreatedDate ?? entry.startDate -+ return createdDate >= conflictStartDate -+ }) -+ let conflictingCarbAmount = conflictingEntries.map({ $0.quantity.doubleValue(for: .gram) }).reduce(0, +) -+ guard conflictingCarbAmount <= maxAllowedConflictingAmountInGrams else { -+ throw CarbActionError.conflictingTreatments(conflictingCarbAmount, minTreatmentIntervalInMinutes) -+ } -+ - let quantity = LoopQuantity(unit: .gram, doubleValue: amountInGrams) - let candidateCarbEntry = NewCarbEntry(syncIdentifier: UUID().uuidString, quantity: quantity, startDate: startDate, foodType: foodType, absorptionTime: absorptionTime) - -@@ -1485,9 +1501,14 @@ extension LoopDataManager: ServicesManagerDelegate { - case invalidCarbs - case entryNotFound - case entryNotEditable -- -+ case conflictingTreatments(_ conflictingAmountInGrams: Double, _ minutesRequiredBetweenTreatments: TimeInterval) -+ - var errorDescription: String? { - switch self { -+ case .conflictingTreatments(let conflictingAmountInGrams, let minutesRequiredBetweenTreatments): -+ let treatmentAmountFormatted = (Self.numberFormatter.string(from: NSNumber(value: conflictingAmountInGrams)) ?? "") + " g" -+ let minutesRequiredBetweenTreatmentsFormatted = Self.numberFormatter.string(from: NSNumber(value: minutesRequiredBetweenTreatments)) ?? "" -+ return String(format: NSLocalizedString("Conflicting carb treatments (%1$@) have occurred. At least %2$@ minutes must pass between carb entries.", comment: "Carb error description: conflicting treatments occurred."), treatmentAmountFormatted, minutesRequiredBetweenTreatmentsFormatted) - case .exceedsMaxCarbs: - return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Carb error description: carbs exceed maximum amount.") - case .invalidCarbs: -diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift -index 20d2f3d4..f710fb3e 100644 ---- a/Loop/Loop/Managers/ServicesManager.swift -+++ b/Loop/Loop/Managers/ServicesManager.swift -@@ -245,13 +245,13 @@ class ServicesManager { - } - - public protocol ServicesManagerDosingDelegate: AnyObject { -- func deliverBolus(amountInUnits: Double, decisionId: UUID?) async throws -+ func deliverBolus(amountInUnits: Double, decisionId: UUID?, userCreatedDate: Date) async throws -> Double - } - - public protocol ServicesManagerDelegate: AnyObject { - func enactOverride(name: String, duration: TemporaryScheduleOverride.Duration?, remoteAddress: String) async throws - func cancelCurrentOverride() async throws -- func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws -+ func deliverCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws - func deleteCarbs(syncIdentifier: String) async throws -> StoredCarbEntry - func replaceCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws -> StoredCarbEntry - } -@@ -327,9 +327,10 @@ extension ServicesManager: ServiceDelegate { - await remoteDataServicesManager.performUpload(for: .overrides) - } - -- func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws { -+ func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws { - do { -- try await servicesManagerDelegate?.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) -+ guard let servicesManagerDelegate else { throw BolusActionError.internalError } -+ try await servicesManagerDelegate.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate, userCreatedDate: userCreatedDate) - NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: amountInGrams) - await remoteDataServicesManager.performUpload(for: .carb) - analyticsServicesManager.didAddCarbs(source: "Remote") -@@ -381,25 +382,30 @@ extension ServicesManager: ServiceDelegate { - } - } - -- func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?) async throws { -+ func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?, userCreatedDate: Date) async throws -> Double { - do { -- -+ - guard amountInUnits > 0 else { - throw BolusActionError.invalidBolus - } -- -+ - guard let maxBolusAmount = settingsManager.loopSettings.maximumBolus else { - throw BolusActionError.missingMaxBolus - } -- -+ - guard amountInUnits <= maxBolusAmount else { - throw BolusActionError.exceedsMaxBolus - } -- -- try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits, decisionId: decisionId) -- NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) -+ -+ guard let servicesManagerDosingDelegate else { -+ throw BolusActionError.internalError -+ } -+ // May be reduced by the conflicting-treatment guard; report the amount actually delivered. -+ let deliveredUnits = try await servicesManagerDosingDelegate.deliverBolus(amountInUnits: amountInUnits, decisionId: decisionId, userCreatedDate: userCreatedDate) -+ NotificationManager.sendRemoteBolusNotification(amount: deliveredUnits) - await remoteDataServicesManager.performUpload(for: .dose) - analyticsServicesManager.didBolus(source: "Remote") -+ return deliveredUnits - } catch { - NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) - throw error -@@ -411,7 +417,8 @@ extension ServicesManager: ServiceDelegate { - case invalidBolus - case missingMaxBolus - case exceedsMaxBolus -- -+ case internalError -+ - var errorDescription: String? { - switch self { - case .invalidBolus: -@@ -420,6 +427,8 @@ extension ServicesManager: ServiceDelegate { - return NSLocalizedString("Missing maximum allowed bolus in settings", comment: "Bolus error description: missing maximum bolus in settings.") - case .exceedsMaxBolus: - return NSLocalizedString("Exceeds maximum allowed bolus in settings", comment: "Bolus error description: bolus exceeds maximum bolus in settings.") -+ case .internalError: -+ return NSLocalizedString("An internal error occurred.", comment: "Bolus error description: internal error.") - } - } - } -Submodule LoopKit contains modified content -diff --git a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -index 5f1f37f3..39c0eee9 100644 ---- a/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -+++ b/LoopKit/LoopKit/Service/Remote/RemoteActionDelegate.swift -@@ -17,8 +17,8 @@ public protocol RemoteProfileActionDelegate: AnyObject { - public protocol RemoteActionDelegate: AnyObject { - func enactRemoteOverride(name: String, durationTime: TimeInterval?, remoteAddress: String) async throws - func cancelRemoteOverride() async throws -- func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?) async throws -- func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?) async throws -+ func deliverRemoteCarbs(amountInGrams: Double, absorptionTime: TimeInterval?, foodType: String?, startDate: Date?, userCreatedDate: Date) async throws -+ func deliverRemoteBolus(amountInUnits: Double, decisionId: UUID?, userCreatedDate: Date) async throws -> Double - func deleteRemoteCarbs(syncIdentifier: String) async throws - func replaceRemoteCarbs(syncIdentifier: String, amountInGrams: Double, absorptionTime: TimeInterval, foodType: String?, startDate: Date) async throws - } -Submodule NightscoutService contains modified content -diff --git a/NightscoutService/NightscoutServiceKit/NightscoutService.swift b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -index 3a513fb..64a668c 100644 ---- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift -+++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift -@@ -14,6 +14,7 @@ public enum NightscoutServiceError: Error { - case incompatibleTherapySettings - case missingCredentials - case missingCommandSource -+ case missingServiceDelegate - } - - -@@ -66,10 +67,12 @@ public final class NightscoutService: Service { - - private let log = OSLog(category: "NightscoutService") - -+ private let notificationExpirationInMinutes = 10.0 -+ - public init() { - self.isOnboarded = false - self.lockedObjectIdCache = Locked(ObjectIdCache()) -- self.otpManager = OTPManager(secretStore: KeychainManager()) -+ self.otpManager = OTPManager(secretStore: KeychainManager(), maxMinutesValid: notificationExpirationInMinutes) - self.commandSourceV1 = RemoteCommandSourceV1(otpManager: otpManager) - self.commandSourceV1.delegate = self - } -@@ -85,7 +88,7 @@ public final class NightscoutService: Service { - self.lockedObjectIdCache = Locked(ObjectIdCache()) - } - -- self.otpManager = OTPManager(secretStore: KeychainManager()) -+ self.otpManager = OTPManager(secretStore: KeychainManager(), maxMinutesValid: notificationExpirationInMinutes) - self.commandSourceV1 = RemoteCommandSourceV1(otpManager: otpManager) - self.commandSourceV1.delegate = self - -@@ -422,6 +425,14 @@ extension NightscoutService: RemoteDataService { - - - public func remoteNotificationWasReceived(_ notification: [String: AnyObject]) async throws { -+ var notification = notification -+ // Widen the acceptance window: override the expiration to sent-at + notificationExpirationInMinutes. -+ // Ideally this would be done in Nightscout instead. -+ if let sentDateString = notification["sent-at"] as? String, -+ let sentDate = DateFormatter.iso8601DateDecoder.date(from: sentDateString) { -+ let expirationDate = sentDate.addingTimeInterval(60 * notificationExpirationInMinutes) -+ notification["expiration"] = DateFormatter.iso8601DateDecoder.string(from: expirationDate) as AnyObject -+ } - let commandSource = try commandSource(notification: notification) - await commandSource.remoteNotificationWasReceived(notification) - } -@@ -449,10 +460,13 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - var syncIdentifier: String? = nil - - do { -+ guard let serviceDelegate = self.serviceDelegate else { -+ throw NightscoutServiceError.missingServiceDelegate -+ } - switch action { - case .temporaryScheduleOverride(let overrideCommand): - commandType = .override -- try await self.serviceDelegate?.enactRemoteOverride( -+ try await serviceDelegate.enactRemoteOverride( - name: overrideCommand.name, - durationTime: overrideCommand.durationTime, - remoteAddress: overrideCommand.remoteAddress -@@ -480,23 +494,28 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - - case .cancelTemporaryOverride: - commandType = .cancelOverride -- try await self.serviceDelegate?.cancelRemoteOverride() -+ try await serviceDelegate.cancelRemoteOverride() - success = true - message = "Override cancelled successfully" - - case .bolusEntry(let bolusCommand): - commandType = .bolus -- try await self.serviceDelegate?.deliverRemoteBolus(amountInUnits: bolusCommand.amountInUnits, decisionId: nil) -+ let deliveredUnits = try await serviceDelegate.deliverRemoteBolus(amountInUnits: bolusCommand.amountInUnits, decisionId: nil, userCreatedDate: bolusCommand.userCreatedDate) - success = true -- message = String(format: "Bolus of %.2f units delivered successfully", bolusCommand.amountInUnits) -+ if deliveredUnits < bolusCommand.amountInUnits { -+ message = String(format: "Bolus reduced to %.2f units (from %.2f) due to other recent treatments", deliveredUnits, bolusCommand.amountInUnits) -+ } else { -+ message = String(format: "Bolus of %.2f units delivered successfully", deliveredUnits) -+ } - - case .carbsEntry(let carbCommand): - commandType = .carbs -- try await self.serviceDelegate?.deliverRemoteCarbs( -+ try await serviceDelegate.deliverRemoteCarbs( - amountInGrams: carbCommand.amountInGrams, - absorptionTime: carbCommand.absorptionTime, - foodType: carbCommand.foodType, -- startDate: carbCommand.startDate -+ startDate: carbCommand.startDate, -+ userCreatedDate: carbCommand.userCreatedDate - ) - success = true - message = String(format: "Carbs entry of %.1f g delivered successfully", carbCommand.amountInGrams) -@@ -504,14 +523,14 @@ extension NightscoutService: RemoteCommandSourceV1Delegate { - case .carbsDelete(let deleteCommand): - commandType = .carbsDelete - syncIdentifier = deleteCommand.syncIdentifier -- try await self.serviceDelegate?.deleteRemoteCarbs(syncIdentifier: deleteCommand.syncIdentifier) -+ try await serviceDelegate.deleteRemoteCarbs(syncIdentifier: deleteCommand.syncIdentifier) - success = true - message = "Carb entry deleted successfully" - - case .carbsEdit(let editCommand): - commandType = .carbsEdit - syncIdentifier = editCommand.syncIdentifier -- try await self.serviceDelegate?.replaceRemoteCarbs( -+ try await serviceDelegate.replaceRemoteCarbs( - syncIdentifier: editCommand.syncIdentifier, - amountInGrams: editCommand.amountInGrams, - absorptionTime: editCommand.absorptionTime, -diff --git a/NightscoutService/NightscoutServiceKit/OTPManager.swift b/NightscoutService/NightscoutServiceKit/OTPManager.swift -index f558a46..921ff02 100644 ---- a/NightscoutService/NightscoutServiceKit/OTPManager.swift -+++ b/NightscoutService/NightscoutServiceKit/OTPManager.swift -@@ -77,8 +77,19 @@ public class OTPManager { - let maxOTPsToAccept: Int - - public static var defaultTokenPeriod: TimeInterval = 30 -- public static var defaultMaxOTPsToAccept = 2 -- -+ public static var defaultMaxOTPsToAccept = 30 -+ -+ public init(secretStore: OTPSecretStore = KeychainManager(), nowDateSource: @escaping () -> Date = {Date()}, maxMinutesValid: Double) { -+ self.secretStore = secretStore -+ self.nowDateSource = nowDateSource -+ self.tokenPeriod = OTPManager.defaultTokenPeriod -+ let secondsValid = 60.0 * maxMinutesValid -+ self.maxOTPsToAccept = Int(secondsValid / OTPManager.defaultTokenPeriod) -+ if secretStore.tokenSecretKey() == nil || secretStore.tokenSecretKeyName() == nil { -+ resetSecretKey() -+ } -+ } -+ - public init(secretStore: OTPSecretStore = KeychainManager(), nowDateSource: @escaping () -> Date = {Date()}, tokenPeriod: TimeInterval = OTPManager.defaultTokenPeriod, maxOTPsToAccept: Int = OTPManager.defaultMaxOTPsToAccept) { - self.secretStore = secretStore - self.nowDateSource = nowDateSource -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift -index 3a56d81..6db615c 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/BolusAction.swift -@@ -9,10 +9,12 @@ - import Foundation - - public struct BolusAction: Codable { -- -+ - public let amountInUnits: Double -- -- public init(amountInUnits: Double) { -+ public let userCreatedDate: Date -+ -+ public init(amountInUnits: Double, userCreatedDate: Date) { - self.amountInUnits = amountInUnits -+ self.userCreatedDate = userCreatedDate - } - } -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift -index 3865989..b315b16 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Actions/CarbAction.swift -@@ -14,11 +14,13 @@ public struct CarbAction: Codable{ - public let absorptionTime: TimeInterval? - public let foodType: String? - public let startDate: Date? -- -- public init(amountInGrams: Double, absorptionTime: TimeInterval? = nil, foodType: String? = nil, startDate: Date? = nil) { -+ public let userCreatedDate: Date -+ -+ public init(amountInGrams: Double, absorptionTime: TimeInterval? = nil, foodType: String? = nil, startDate: Date? = nil, userCreatedDate: Date) { - self.amountInGrams = amountInGrams - self.absorptionTime = absorptionTime - self.foodType = foodType - self.startDate = startDate -+ self.userCreatedDate = userCreatedDate - } - } -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift -index 1c14cdf..869abc2 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/BolusRemoteNotification.swift -@@ -14,11 +14,11 @@ public struct BolusRemoteNotification: RemoteNotification, Codable { - public let amount: Double - public let remoteAddress: String - public let expiration: Date? -- public let sentAt: Date? -+ public let sentAt: Date - public let otp: String? - public let enteredBy: String? - public let encryptedReturnNotification: String? -- -+ - enum CodingKeys: String, CodingKey { - case remoteAddress = "remote-address" - case amount = "bolus-entry" -@@ -28,9 +28,13 @@ public struct BolusRemoteNotification: RemoteNotification, Codable { - case enteredBy = "entered-by" - case encryptedReturnNotification = "encrypted_return_notification" - } -- -+ - func toRemoteAction() -> Action { -- return .bolusEntry(BolusAction(amountInUnits: amount)) -+ return .bolusEntry(toBolusAction()) -+ } -+ -+ func toBolusAction() -> BolusAction { -+ return BolusAction(amountInUnits: amount, userCreatedDate: sentAt) - } - - func otpValidationRequired() -> Bool { -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift -index 1593a74..eda9134 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbDeleteRemoteNotification.swift -@@ -11,7 +11,7 @@ public struct CarbDeleteRemoteNotification: RemoteNotification, Codable { - public let syncIdentifier: String - public let remoteAddress: String - public let expiration: Date? -- public let sentAt: Date? -+ public let sentAt: Date - public let otp: String? - public let enteredBy: String? - public let encryptedReturnNotification: String? -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift -index fde3e4c..f1ffb51 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbEditRemoteNotification.swift -@@ -16,7 +16,7 @@ public struct CarbEditRemoteNotification: RemoteNotification, Codable { - public let startDate: Date - public let remoteAddress: String - public let expiration: Date? -- public let sentAt: Date? -+ public let sentAt: Date - public let otp: String? - public let enteredBy: String? - public let encryptedReturnNotification: String? -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift -index 6c238ed..c069c34 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/CarbRemoteNotification.swift -@@ -17,7 +17,7 @@ public struct CarbRemoteNotification: RemoteNotification, Codable { - public let startDate: Date? - public let remoteAddress: String - public let expiration: Date? -- public let sentAt: Date? -+ public let sentAt: Date - public let otp: String? - public let enteredBy: String? - public let encryptedReturnNotification: String? -@@ -43,7 +43,7 @@ public struct CarbRemoteNotification: RemoteNotification, Codable { - } - - func toRemoteAction() -> Action { -- let action = CarbAction(amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate) -+ let action = CarbAction(amountInGrams: amount, absorptionTime: absorptionTime(), foodType: foodType, startDate: startDate, userCreatedDate: sentAt) - return .carbsEntry(action) - } - -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift -index fdd6d6b..babb71f 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideCancelRemoteNotification.swift -@@ -13,7 +13,7 @@ public struct OverrideCancelRemoteNotification: RemoteNotification, Codable { - - public let remoteAddress: String - public let expiration: Date? -- public let sentAt: Date? -+ public let sentAt: Date - public let cancelOverride: String - public let enteredBy: String? - public let otp: String? -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift -index 2060a74..426ff89 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/OverrideRemoteNotification.swift -@@ -15,7 +15,7 @@ public struct OverrideRemoteNotification: RemoteNotification, Codable { - public let durationInMinutes: Double? - public let remoteAddress: String - public let expiration: Date? -- public let sentAt: Date? -+ public let sentAt: Date - public let enteredBy: String? - public let otp: String? - public let encryptedReturnNotification: String? -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -index 487c620..ebcd6ef 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/ProfileSwitchRemoteNotification.swift -@@ -16,7 +16,7 @@ public struct ProfileSwitchRemoteNotification: RemoteNotification, Codable { - public let fingerprint: String - public let remoteAddress: String - public let expirationDate: Date -- public let sentAt: Date? -+ public let sentAt: Date - public let otp: String? - public let enteredBy: String? - public let encryptedReturnNotification: String? -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -index 87f1e92..1ea8b96 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/V1/Notifications/RemoteNotification.swift -@@ -14,7 +14,7 @@ protocol RemoteNotification: Codable { - - var id: String {get} - var expiration: Date? {get} -- var sentAt: Date? {get} -+ var sentAt: Date {get} - var otp: String? {get} - var remoteAddress: String {get} - var enteredBy: String? {get} -@@ -40,12 +40,8 @@ extension RemoteNotification { - } - - var id: String { -- //There is no unique identifier so we use the sent date when available -- if let sentAt = sentAt { -- return "\(sentAt.timeIntervalSince1970)" -- } else { -- return UUID().uuidString -- } -+ //There is no unique identifier so we use the sent date -+ return "\(sentAt.timeIntervalSince1970)" - } - - func getReturnNotificationInfo() -> ReturnNotificationInfo? { -diff --git a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -index 21d0e0a..5be6e1c 100644 ---- a/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -+++ b/NightscoutService/NightscoutServiceKit/RemoteCommands/Validators/RemoteCommandValidator.swift -@@ -20,9 +20,6 @@ struct RemoteCommandValidator { - guard remoteNotification.expiration != nil else { - throw NotificationValidationError.missingExpiration - } -- guard remoteNotification.sentAt != nil else { -- throw NotificationValidationError.missingSentAt -- } - } - try validateExpirationDate(remoteNotification: remoteNotification) - if remoteNotification.otpValidationRequired() { -@@ -31,13 +28,13 @@ struct RemoteCommandValidator { - } - - private func validateExpirationDate(remoteNotification: RemoteNotification) throws { -- -+ - guard let expirationDate = remoteNotification.expiration else { - return //Skip validation if no date included - } -- -+ - if nowDateSource() > expirationDate { -- throw NotificationValidationError.expiredNotification -+ throw NotificationValidationError.expiredNotification(sentDate: remoteNotification.sentAt, receivedDate: nowDateSource()) - } - } - -@@ -52,20 +49,19 @@ struct RemoteCommandValidator { - - enum NotificationValidationError: LocalizedError { - case missingOTP -- case expiredNotification -+ case expiredNotification(sentDate: Date, receivedDate: Date) - case missingExpiration -- case missingSentAt -- -+ - var errorDescription: String? { - switch self { - case .missingOTP: - return LocalizedString("Missing OTP", comment: "Remote command error description: Missing OTP.") - case .missingExpiration: - return LocalizedString("Missing expiration", comment: "Remote command error description: missing expiration.") -- case .missingSentAt: -- return LocalizedString("Missing sent-at time", comment: "Remote command error description: missing sent-at.") -- case .expiredNotification: -- return LocalizedString("Expired", comment: "Remote command error description: expired.") -+ case .expiredNotification(let sentDate, let receivedDate): -+ let dateFormatter = DateFormatter() -+ dateFormatter.dateFormat = "h:mm:ss a" -+ return String(format: LocalizedString("Expired. Sent: %@. Received: %@", comment: "Remote command error description: expired."), dateFormatter.string(from: sentDate), dateFormatter.string(from: receivedDate)) - } - } - } -diff --git a/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift -index 6a3aba6..f03768c 100644 ---- a/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift -+++ b/NightscoutService/NightscoutServiceKitTests/CarbDeleteRemoteNotificationTestCase.swift -@@ -48,20 +48,14 @@ final class CarbDeleteRemoteNotificationTestCase: XCTestCase { - } - } - -- func testValidate_MissingSentAt_Throws() throws { -+ func testParseCarbDeleteNotification_MissingSentAt_Throws() throws { - let notification: [String: AnyObject] = [ - "remote-address": "::ffff:11.2.44.155" as AnyObject, - "expiration": "2099-02-25T20:51:35.778Z" as AnyObject, - "carbs-delete": "abc" as AnyObject, - "otp": "12345" as AnyObject - ] -- let remoteNotification = try notification.toRemoteNotification() -- let validator = RemoteCommandValidator(otpManager: OTPTestCoordinator().createOTPManager()) - -- XCTAssertThrowsError(try validator.validate(remoteNotification: remoteNotification)) { error in -- guard case RemoteCommandValidator.NotificationValidationError.missingSentAt = error else { -- return XCTFail("Unexpected error \(error)") -- } -- } -+ XCTAssertThrowsError(try notification.toRemoteNotification()) - } - } -diff --git a/remote_window.loopcustomization b/remote_window.loopcustomization -new file mode 100644 -index 0000000..e69de29