diff --git a/profiles/README.md b/profiles/README.md new file mode 100644 index 0000000..f2dd9e0 --- /dev/null +++ b/profiles/README.md @@ -0,0 +1,49 @@ +# Profiles + +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. + +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, 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_profiles.patch +``` + +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. + +## Other remote commands + +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. + +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_basal_lock.patch b/profiles/archive/dev_profiles_basal_lock_noremote.patch similarity index 100% rename from profiles/dev_profiles_basal_lock.patch rename to profiles/archive/dev_profiles_basal_lock_noremote.patch 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..7790cab 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,95 +37,184 @@ 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/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 { + + 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..18c25671 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,73 @@ 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) ++ } ++ } ++ } ++ sendRemoteProfileNotification(name: name, error: nil) ++ } catch { ++ 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 ++ 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 +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 */, @@ -173,12 +270,34 @@ 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/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 ++} 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..b571cf7b --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,408 @@ +@@ -0,0 +1,466 @@ +// +// ProfileViewModel+FileManagement.swift +// LoopKitUI @@ -355,6 +474,7 @@ index 00000000..74724fb7 + try jsonData.write(to: fileURL) + + self.loadProfiles() ++ triggerProfileSync() + } catch { + print("An error occurred while renaming profile: \(error)") + } @@ -415,17 +535,24 @@ index 00000000..74724fb7 + 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): @@ -451,6 +578,8 @@ index 00000000..74724fb7 + removeProfile(profileReference: profileReference) + if getCurrentProfileName() == profile.name { + clearCurrentProfile() ++ } else { ++ triggerProfileSync() + } + } + @@ -476,6 +605,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) @@ -571,11 +708,11 @@ index 00000000..74724fb7 + 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 + } @@ -587,6 +724,46 @@ 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) ++ } ++} ++ ++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 @@ -778,10 +955,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 @@ -799,6 +976,7 @@ index 00000000..6de3598f + case load + case delete + case error ++ case loadEdited +} + +struct ProfilePreviewView: View { @@ -806,7 +984,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 @@ -817,11 +998,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]) @@ -888,30 +1079,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: @@ -930,6 +1098,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 { @@ -951,6 +1126,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() @@ -958,6 +1149,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] = [] + @@ -1055,27 +1280,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 { + @@ -1089,8 +1293,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() + } + } +} @@ -1108,9 +1338,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 @@ @@ -1228,15 +1502,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!"))) + } + } @@ -1401,15 +1675,487 @@ 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..2c269a0 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -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 */; }; ++ 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 /* 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 +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 = ""; }; ++ 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 /* 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 +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 +332,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* RemoteProfileSwitchTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -744,6 +753,8 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.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 +768,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.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..2b9b2d1 +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,86 @@ ++// ++// 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)) ++ } ++} 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 +2174,160 @@ 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..1fb455a 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -293,7 +293,7 @@ extension NightscoutService: RemoteDataService { + } + + let statuses = uploadPairs.map { (decision, automaticDoseDecision) in +- return decision.deviceStatus(automaticDoseDecision: automaticDoseDecision) ++ return decision.deviceStatus(automaticDoseDecision: automaticDoseDecision).addingRemoteCommand(ProfileSwitchCommand.commandKey) + } + + guard statuses.count > 0 else { +diff --git a/NightscoutService/NightscoutServiceKitTests/RemoteProfileSwitchTestCase.swift b/NightscoutService/NightscoutServiceKitTests/RemoteProfileSwitchTestCase.swift +new file mode 100644 +index 0000000..369fb3e +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/RemoteProfileSwitchTestCase.swift +@@ -0,0 +1,135 @@ ++// ++// RemoteProfileSwitchTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class RemoteProfileSwitchTestCase: XCTestCase { ++ ++ func testParseProfileSwitchCommand_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" ++ ] ++ ++ let command = try ProfileSwitchCommand(dictionary: notification) ++ ++ 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 testParseProfileSwitchCommand_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchCommand(dictionary: notification)) ++ } ++ ++ 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), ++ 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") ++ } ++ ++ 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 { ++ 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.patch b/profiles/nextdev_profiles.patch index ce9bf2d..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..6b70853e 100644 +index 856aaca6..1cd6181e 100644 --- a/Loop/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Loop/Managers/DeviceDataManager.swift -@@ -1407,6 +1407,12 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { +@@ -1408,6 +1408,13 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { return try await pumpManager?.syncDeliveryLimits(limits: deliveryLimits) ?? deliveryLimits } @@ -11,26 +11,126 @@ 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/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 +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/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) } -- -+ - var scheduleOverride: TemporaryScheduleOverride? { - temporaryPresetsManager.scheduleOverride + +diff --git a/Loop/Loop/Managers/ServicesManager.swift b/Loop/Loop/Managers/ServicesManager.swift +index b557f184..7fd5b74d 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,73 @@ extension Service { + ] } + } ++ ++// MARK: - RemoteProfileActionDelegate ++ ++extension ServicesManager: RemoteProfileActionDelegate { ++ @MainActor ++ 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) ++ } ++ } ++ } ++ sendRemoteProfileNotification(name: name, error: nil) ++ } catch { ++ 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 ++ 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 +index e02112db..7a713a9f 100644 --- a/Loop/Loop/Views/SettingsView.swift +++ b/Loop/Loop/Views/SettingsView.swift @@ -55,6 +55,7 @@ struct SettingsView: View { @@ -41,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) @@ -51,7 +151,7 @@ index 1faefbc5..737acf28 100644 } } .environmentObject(displayGlucosePreference) -@@ -416,6 +420,11 @@ extension SettingsView { +@@ -463,6 +467,11 @@ extension SettingsView { .accessibilityIdentifier("button_TherapySettings") } @@ -127,12 +227,34 @@ 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/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 ++} 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..6c27aa9f --- /dev/null +++ b/LoopKit/LoopKitUI/ViewModels/ProfileViewModel+FileManagement.swift -@@ -0,0 +1,409 @@ +@@ -0,0 +1,467 @@ +// +// ProfileViewModel+FileManagement.swift +// LoopKitUI @@ -310,6 +432,7 @@ index 00000000..cd8d48b8 + try jsonData.write(to: fileURL) + + self.loadProfiles() ++ triggerProfileSync() + } catch { + print("An error occurred while renaming profile: \(error)") + } @@ -370,17 +493,24 @@ index 00000000..cd8d48b8 + 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): @@ -406,6 +536,8 @@ index 00000000..cd8d48b8 + removeProfile(profileReference: profileReference) + if getCurrentProfileName() == profile.name { + clearCurrentProfile() ++ } else { ++ triggerProfileSync() + } + } + @@ -431,6 +563,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) @@ -526,11 +666,11 @@ index 00000000..cd8d48b8 + 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 + } @@ -542,6 +682,46 @@ 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) ++ } ++} ++ ++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 @@ -733,10 +913,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 @@ -755,6 +935,7 @@ index 00000000..3566055d + case load + case delete + case error ++ case loadEdited +} + +struct ProfilePreviewView: View { @@ -762,7 +943,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 @@ -773,11 +958,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]) @@ -844,30 +1039,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: @@ -886,6 +1058,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 { @@ -907,6 +1086,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() @@ -914,6 +1109,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] = [] + @@ -1011,27 +1240,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 { + @@ -1052,8 +1260,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() + } + } +} @@ -1071,9 +1305,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 @@ @@ -1191,15 +1469,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!"))) + } + } @@ -1363,15 +1641,490 @@ 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..eaf139d 100644 +--- a/NightscoutService/NightscoutService.xcodeproj/project.pbxproj ++++ b/NightscoutService/NightscoutService.xcodeproj/project.pbxproj +@@ -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 */; }; ++ 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 /* 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 +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 = ""; }; ++ 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 /* 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 +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 +329,7 @@ + A95F887426C694E900D8A009 /* OTPManagerTestCase.swift */, + A941B09729BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.swift */, + A941B09829BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift */, ++ 3E7A51C72F0D4B8100A1C6E2 /* RemoteProfileSwitchTestCase.swift */, + A95F887326C694E800D8A009 /* NightscoutServiceKitTests-Bridging-Header.h */, + ); + path = NightscoutServiceKitTests; +@@ -740,6 +749,8 @@ + C1398D2227C434FB00416AD6 /* Data.swift in Sources */, + A90E39A522BC791E0016DFE8 /* DoseEntry.swift in Sources */, + A934B6182358F49300949C8B /* StoredSettings.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 +764,7 @@ + A941B09C29BD4ACA00F91340 /* OverrideRemoteNotificationTestCase.swift in Sources */, + A941B09929BD4ACA00F91340 /* BolusRemoteNotificationTestCase.swift in Sources */, + A941B09B29BD4ACA00F91340 /* OverrideCancelRemoteNotificationTestCase.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..4f5fdeb +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKit/Extensions/SavedProfile.swift +@@ -0,0 +1,86 @@ ++// ++// 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)) ++ } ++} 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 +2143,160 @@ 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..3561e04 100644 +--- a/NightscoutService/NightscoutServiceKit/NightscoutService.swift ++++ b/NightscoutService/NightscoutServiceKit/NightscoutService.swift +@@ -292,7 +292,7 @@ extension NightscoutService: RemoteDataService { + } + + let statuses = uploadPairs.map { (decision, automaticDoseDecision) in +- return decision.deviceStatus(automaticDoseDecision: automaticDoseDecision) ++ return decision.deviceStatus(automaticDoseDecision: automaticDoseDecision).addingRemoteCommand(ProfileSwitchCommand.commandKey) + } + + guard statuses.count > 0 else { +diff --git a/NightscoutService/NightscoutServiceKitTests/RemoteProfileSwitchTestCase.swift b/NightscoutService/NightscoutServiceKitTests/RemoteProfileSwitchTestCase.swift +new file mode 100644 +index 0000000..369fb3e +--- /dev/null ++++ b/NightscoutService/NightscoutServiceKitTests/RemoteProfileSwitchTestCase.swift +@@ -0,0 +1,135 @@ ++// ++// RemoteProfileSwitchTestCase.swift ++// NightscoutServiceKitTests ++// ++ ++import XCTest ++import LoopKit ++import NightscoutKit ++@testable import NightscoutServiceKit ++ ++final class RemoteProfileSwitchTestCase: XCTestCase { ++ ++ func testParseProfileSwitchCommand_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" ++ ] ++ ++ let command = try ProfileSwitchCommand(dictionary: notification) ++ ++ 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 testParseProfileSwitchCommand_MissingExpiration_Fails() { ++ let notification: [String: Any] = [ ++ "profile-switch": "Sport", ++ "profile-switch-fingerprint": "fec4f066b5a54ecd", ++ "remote-address": "LoopFollow", ++ "otp": "123456" ++ ] ++ ++ XCTAssertThrowsError(try ProfileSwitchCommand(dictionary: notification)) ++ } ++ ++ 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), ++ 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") ++ } ++ ++ 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 { ++ 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