diff --git a/Model/Classes+Properties/SettingsChangeStored+CoreDataClass.swift b/Model/Classes+Properties/SettingsChangeStored+CoreDataClass.swift new file mode 100644 index 00000000000..f4c8e073f83 --- /dev/null +++ b/Model/Classes+Properties/SettingsChangeStored+CoreDataClass.swift @@ -0,0 +1,4 @@ +import CoreData +import Foundation + +@objc(SettingsChangeStored) public class SettingsChangeStored: NSManagedObject {} diff --git a/Model/Classes+Properties/SettingsChangeStored+CoreDataProperties.swift b/Model/Classes+Properties/SettingsChangeStored+CoreDataProperties.swift new file mode 100644 index 00000000000..e035e559b22 --- /dev/null +++ b/Model/Classes+Properties/SettingsChangeStored+CoreDataProperties.swift @@ -0,0 +1,23 @@ +import CoreData +import Foundation + +public extension SettingsChangeStored { + @nonobjc class func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "SettingsChangeStored") + } + + @NSManaged var id: UUID? + @NSManaged var date: Date? + @NSManaged var category: String? + @NSManaged var subcategory: String? + @NSManaged var settingName: String? + @NSManaged var settingKey: String? + @NSManaged var oldValue: String? + @NSManaged var newValue: String? + @NSManaged var unit: String? + @NSManaged var note: String? + @NSManaged var source: String? + @NSManaged var groupId: UUID? +} + +extension SettingsChangeStored: Identifiable {} diff --git a/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents b/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents index 332c3373a55..a2a2929a15c 100644 --- a/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents +++ b/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents @@ -247,6 +247,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 5d96561bc8c..5d6303f1f48 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -743,6 +743,14 @@ FE41E4D629463EE20047FD55 /* NightscoutPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE41E4D529463EE20047FD55 /* NightscoutPreferences.swift */; }; FE66D16B291F74F8005D6F77 /* Bundle+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE66D16A291F74F8005D6F77 /* Bundle+Extensions.swift */; }; FEFFA7A22929FE49007B8193 /* UIDevice+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEFFA7A12929FE49007B8193 /* UIDevice+Extensions.swift */; }; + FBCB3699E2A64154804513CB /* SettingsAuditStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AAA3132E8B54247814312BF /* SettingsAuditStorage.swift */; }; + 48CA13DF0276417A91EC645F /* SettingsMetadataRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E99596B1BB84920872C57F3 /* SettingsMetadataRegistry.swift */; }; + 7653F937FB444D939244C3F4 /* SettingsChangeStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAD0682F55104692AB78F945 /* SettingsChangeStored+CoreDataClass.swift */; }; + 265022BEAF494CF3A17FBE3B /* SettingsChangeStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E890DBA44C94C3EBFB05B4F /* SettingsChangeStored+CoreDataProperties.swift */; }; + FA78C14237D94C23A43D7364 /* SettingsAuditLogDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D9D82C45974BABB46050FE /* SettingsAuditLogDataFlow.swift */; }; + 6134855D5A744E32B610E66D /* SettingsAuditLogProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0761A354F4004A5690B74369 /* SettingsAuditLogProvider.swift */; }; + 80ACD53EB1C24FAC88CD980F /* SettingsAuditLogStateModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45D942BE6567401B80F6F0C4 /* SettingsAuditLogStateModel.swift */; }; + D21CE159AA2F40C099B3F5CC /* SettingsAuditLogRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE96FC62A4B242278697EBBA /* SettingsAuditLogRootView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1584,6 +1592,14 @@ FE41E4D529463EE20047FD55 /* NightscoutPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutPreferences.swift; sourceTree = ""; }; FE66D16A291F74F8005D6F77 /* Bundle+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Extensions.swift"; sourceTree = ""; }; FEFFA7A12929FE49007B8193 /* UIDevice+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Extensions.swift"; sourceTree = ""; }; + 8AAA3132E8B54247814312BF /* SettingsAuditStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAuditStorage.swift; sourceTree = ""; }; + 1E99596B1BB84920872C57F3 /* SettingsMetadataRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsMetadataRegistry.swift; sourceTree = ""; }; + EAD0682F55104692AB78F945 /* SettingsChangeStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsChangeStored+CoreDataClass.swift"; sourceTree = ""; }; + 0E890DBA44C94C3EBFB05B4F /* SettingsChangeStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsChangeStored+CoreDataProperties.swift"; sourceTree = ""; }; + D2D9D82C45974BABB46050FE /* SettingsAuditLogDataFlow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAuditLogDataFlow.swift; sourceTree = ""; }; + 0761A354F4004A5690B74369 /* SettingsAuditLogProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAuditLogProvider.swift; sourceTree = ""; }; + 45D942BE6567401B80F6F0C4 /* SettingsAuditLogStateModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAuditLogStateModel.swift; sourceTree = ""; }; + CE96FC62A4B242278697EBBA /* SettingsAuditLogRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAuditLogRootView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -1951,6 +1967,7 @@ C2C98283C436DB934D7E7994 /* Treatments */, 190EBCC229FF134900BA767D /* UserInterfaceSettings */, CE94597C29E9E1CD0047C9C6 /* WatchConfig */, + 711F34EA2FAD42E58BB8DCF6 /* SettingsAuditLog */, ); path = Modules; sourceTree = ""; @@ -2526,6 +2543,7 @@ BDC2EA442C3043B000E5BBD0 /* OverrideStorage.swift */, 5864E8582C42CFAE00294306 /* DeterminationStorage.swift */, BD4D73A12D15A4220052227B /* TDDStorage.swift */, + 8AAA3132E8B54247814312BF /* SettingsAuditStorage.swift */, ); path = Storage; sourceTree = ""; @@ -2547,6 +2565,7 @@ isa = PBXGroup; children = ( 38AEE75125F022080013F05B /* SettingsManager.swift */, + 1E99596B1BB84920872C57F3 /* SettingsMetadataRegistry.swift */, ); path = SettingsManager; sourceTree = ""; @@ -3669,6 +3688,8 @@ 491D6FBA2D56741C00C49F67 /* TempTargetRunStored+CoreDataProperties.swift */, 491D6FBB2D56741C00C49F67 /* TempTargetStored+CoreDataClass.swift */, 491D6FBC2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift */, + EAD0682F55104692AB78F945 /* SettingsChangeStored+CoreDataClass.swift */, + 0E890DBA44C94C3EBFB05B4F /* SettingsChangeStored+CoreDataProperties.swift */, ); path = "Classes+Properties"; sourceTree = ""; @@ -3833,6 +3854,25 @@ path = View; sourceTree = ""; }; + 2E4621F96353486780553A79 /* View */ = { + isa = PBXGroup; + children = ( + CE96FC62A4B242278697EBBA /* SettingsAuditLogRootView.swift */, + ); + path = View; + sourceTree = ""; + }; + 711F34EA2FAD42E58BB8DCF6 /* SettingsAuditLog */ = { + isa = PBXGroup; + children = ( + D2D9D82C45974BABB46050FE /* SettingsAuditLogDataFlow.swift */, + 0761A354F4004A5690B74369 /* SettingsAuditLogProvider.swift */, + 45D942BE6567401B80F6F0C4 /* SettingsAuditLogStateModel.swift */, + 2E4621F96353486780553A79 /* View */, + ); + path = SettingsAuditLog; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -4792,6 +4832,14 @@ 8194B80890CDD6A3C13B0FEE /* SnoozeStateModel.swift in Sources */, BDA25EE42D260CD500035F34 /* AppleWatchManager.swift in Sources */, 0437CE46C12535A56504EC19 /* SnoozeRootView.swift in Sources */, + FBCB3699E2A64154804513CB /* SettingsAuditStorage.swift in Sources */, + 48CA13DF0276417A91EC645F /* SettingsMetadataRegistry.swift in Sources */, + 7653F937FB444D939244C3F4 /* SettingsChangeStored+CoreDataClass.swift in Sources */, + 265022BEAF494CF3A17FBE3B /* SettingsChangeStored+CoreDataProperties.swift in Sources */, + FA78C14237D94C23A43D7364 /* SettingsAuditLogDataFlow.swift in Sources */, + 6134855D5A744E32B610E66D /* SettingsAuditLogProvider.swift in Sources */, + 80ACD53EB1C24FAC88CD980F /* SettingsAuditLogStateModel.swift in Sources */, + D21CE159AA2F40C099B3F5CC /* SettingsAuditLogRootView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Trio/Sources/APS/DeviceDataManager.swift b/Trio/Sources/APS/DeviceDataManager.swift index a4e3bb0f45c..8abd8e0254d 100644 --- a/Trio/Sources/APS/DeviceDataManager.swift +++ b/Trio/Sources/APS/DeviceDataManager.swift @@ -64,6 +64,7 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { @Injected() private var glucoseStorage: GlucoseStorage! @Injected() private var settingsManager: SettingsManager! @Injected() private var bluetoothProvider: BluetoothStateManager! + @Injected() private var auditStorage: SettingsAuditStorage! @Persisted(key: "BaseDeviceDataManager.lastEventDate") var lastEventDate: Date? = nil @SyncAccess(lock: accessLock) @Persisted(key: "BaseDeviceDataManager.lastHeartBeatTime") var lastHeartBeatTime: Date = @@ -85,6 +86,23 @@ final class BaseDeviceDataManager: DeviceDataManager, Injectable { var pumpManager: PumpManagerUI? { didSet { + // Audit log pump changes + let oldName = oldValue?.localizedTitle + let newName = pumpManager?.localizedTitle + if oldName != newName { + auditStorage?.logChange( + category: "Devices", + subcategory: "Pump", + settingName: "Pump", + settingKey: "devices.pump", + oldValue: oldName ?? "(none)", + newValue: newName ?? "(disconnected)", + unit: nil, + note: nil, + source: "manual" + ) + } + if let pumpManager = pumpManager { pumpManager.pumpManagerDelegate = self pumpManager.delegateQueue = processQueue diff --git a/Trio/Sources/APS/Storage/SettingsAuditStorage.swift b/Trio/Sources/APS/Storage/SettingsAuditStorage.swift new file mode 100644 index 00000000000..6603fdfb0ec --- /dev/null +++ b/Trio/Sources/APS/Storage/SettingsAuditStorage.swift @@ -0,0 +1,236 @@ +import CoreData +import Foundation +import Swinject + +protocol SettingsAuditStorage: AnyObject { + func logChange( + category: String, + subcategory: String, + settingName: String, + settingKey: String, + oldValue: String, + newValue: String, + unit: String?, + note: String?, + source: String + ) + func fetchHistory(for settingKey: String?, limit: Int, offset: Int) -> [SettingsChangeStored] + func fetchHistory(category: String?, since: Date?, limit: Int) -> [SettingsChangeStored] + /// Fetches history and converts managed objects to value types atomically inside performAndWait, + /// preventing use-after-free when Core Data merges background changes. + func fetchChangeEntries(category: String?, since: Date?, limit: Int) -> [SettingsAuditLog.ChangeEntry] + func updateNote(forGroup groupId: UUID, note: String) + func deleteOldEntries(olderThan date: Date) + func deleteAllEntries() + + /// Convenience for logging therapy profile changes (Basal, ISF, CR, BG Targets). + /// Formats old/new arrays into summary strings and delegates to `logChange`. + func logTherapyProfileChange( + subcategory: String, + settingName: String, + settingKey: String, + oldEntries: [String], + newEntries: [String], + unit: String + ) +} + +extension SettingsAuditStorage { + func logTherapyProfileChange( + subcategory: String, + settingName: String, + settingKey: String, + oldEntries: [String], + newEntries: [String], + unit: String + ) { + let oldStr = oldEntries.joined(separator: ", ") + let newStr = newEntries.joined(separator: ", ") + guard oldStr != newStr else { return } + logChange( + category: "Therapy", + subcategory: subcategory, + settingName: settingName, + settingKey: settingKey, + oldValue: oldStr.isEmpty ? "(empty)" : oldStr, + newValue: newStr.isEmpty ? "(empty)" : newStr, + unit: unit, + note: nil, + source: "manual" + ) + } +} + +final class BaseSettingsAuditStorage: SettingsAuditStorage, Injectable { + private let viewContext = CoreDataStack.shared.persistentContainer.viewContext + private let backgroundContext = CoreDataStack.shared.newTaskContext() + + /// 10-minute grouping window in seconds. + private static let groupingWindow: TimeInterval = 10 * 60 + + /// Tracks the current group: (groupId, groupStartDate). + /// Changes logged within `groupingWindow` of `groupStartDate` share the same `groupId`. + private var currentGroup: (id: UUID, start: Date)? + + /// Serial queue protecting `currentGroup` from concurrent access. + private let groupLock = NSLock() + + init(resolver: Resolver) { + injectServices(resolver) + viewContext.automaticallyMergesChangesFromParent = true + viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy + } + + /// Returns the group ID to use for a new entry. If the most recent group is still within + /// the 10-minute window, reuses that group; otherwise creates a new one. + private func resolveGroupId(now: Date = Date()) -> UUID { + groupLock.lock() + defer { groupLock.unlock() } + if let group = currentGroup, now.timeIntervalSince(group.start) < Self.groupingWindow { + return group.id + } + let newId = UUID() + currentGroup = (id: newId, start: now) + return newId + } + + func logChange( + category: String, + subcategory: String, + settingName: String, + settingKey: String, + oldValue: String, + newValue: String, + unit: String? = nil, + note: String? = nil, + source: String = "manual" + ) { + guard oldValue != newValue else { return } + + let groupId = resolveGroupId() + + backgroundContext.perform { [weak self] in + guard let self else { return } + let entry = SettingsChangeStored(context: self.backgroundContext) + entry.id = UUID() + entry.date = Date() + entry.category = category + entry.subcategory = subcategory + entry.settingName = settingName + entry.settingKey = settingKey + entry.oldValue = oldValue + entry.newValue = newValue + entry.unit = unit + entry.note = note + entry.source = source + entry.groupId = groupId + + do { + try self.backgroundContext.save() + } catch { + debug(.default, "SettingsAuditStorage: Failed to save change log entry: \(error)") + } + } + } + + func fetchHistory(for settingKey: String?, limit: Int = 100, offset: Int = 0) -> [SettingsChangeStored] { + var result: [SettingsChangeStored] = [] + viewContext.performAndWait { + let request = SettingsChangeStored.fetchRequest() + request.sortDescriptors = [NSSortDescriptor(keyPath: \SettingsChangeStored.date, ascending: false)] + if let key = settingKey { + request.predicate = NSPredicate(format: "settingKey == %@", key) + } + request.fetchLimit = limit + request.fetchOffset = offset + result = (try? viewContext.fetch(request)) ?? [] + } + return result + } + + func fetchHistory(category: String?, since: Date?, limit: Int = 200) -> [SettingsChangeStored] { + var result: [SettingsChangeStored] = [] + viewContext.performAndWait { + let request = SettingsChangeStored.fetchRequest() + request.sortDescriptors = [NSSortDescriptor(keyPath: \SettingsChangeStored.date, ascending: false)] + + var predicates: [NSPredicate] = [] + if let cat = category { + predicates.append(NSPredicate(format: "category == %@", cat)) + } + if let since = since { + predicates.append(NSPredicate(format: "date >= %@", since as NSDate)) + } + if !predicates.isEmpty { + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + } + request.fetchLimit = limit + result = (try? viewContext.fetch(request)) ?? [] + } + return result + } + + func fetchChangeEntries( + category: String?, + since: Date?, + limit: Int = 200 + ) -> [SettingsAuditLog.ChangeEntry] { + var result: [SettingsAuditLog.ChangeEntry] = [] + viewContext.performAndWait { + let request = SettingsChangeStored.fetchRequest() + request.sortDescriptors = [NSSortDescriptor(keyPath: \SettingsChangeStored.date, ascending: false)] + + var predicates: [NSPredicate] = [] + if let cat = category { + predicates.append(NSPredicate(format: "category == %@", cat)) + } + if let since = since { + predicates.append(NSPredicate(format: "date >= %@", since as NSDate)) + } + if !predicates.isEmpty { + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + } + request.fetchLimit = limit + let stored = (try? viewContext.fetch(request)) ?? [] + // Convert managed objects → value types inside performAndWait so the + // managed objects cannot be faulted/invalidated between fetch and access + result = stored.map { SettingsAuditLog.ChangeEntry(from: $0) } + } + return result + } + + func updateNote(forGroup groupId: UUID, note: String) { + backgroundContext.perform { [weak self] in + guard let self else { return } + let request = SettingsChangeStored.fetchRequest() + request.predicate = NSPredicate(format: "groupId == %@", groupId as CVarArg) + if let entries = try? self.backgroundContext.fetch(request) { + for entry in entries { + entry.note = note + } + try? self.backgroundContext.save() + } + } + } + + func deleteOldEntries(olderThan date: Date) { + backgroundContext.perform { [weak self] in + guard let self else { return } + let request = NSFetchRequest(entityName: "SettingsChangeStored") + request.predicate = NSPredicate(format: "date < %@", date as NSDate) + let deleteRequest = NSBatchDeleteRequest(fetchRequest: request) + try? self.backgroundContext.execute(deleteRequest) + try? self.backgroundContext.save() + } + } + + func deleteAllEntries() { + backgroundContext.perform { [weak self] in + guard let self else { return } + let request = NSFetchRequest(entityName: "SettingsChangeStored") + let deleteRequest = NSBatchDeleteRequest(fetchRequest: request) + try? self.backgroundContext.execute(deleteRequest) + try? self.backgroundContext.save() + } + } +} diff --git a/Trio/Sources/Assemblies/StorageAssembly.swift b/Trio/Sources/Assemblies/StorageAssembly.swift index 9431a0bd777..938f4582457 100644 --- a/Trio/Sources/Assemblies/StorageAssembly.swift +++ b/Trio/Sources/Assemblies/StorageAssembly.swift @@ -18,5 +18,6 @@ final class StorageAssembly: Assembly { container.register(SettingsManager.self) { r in BaseSettingsManager(resolver: r) } container.register(Keychain.self) { _ in BaseKeychain() } container.register(AlertHistoryStorage.self) { r in BaseAlertHistoryStorage(resolver: r) } + container.register(SettingsAuditStorage.self) { r in BaseSettingsAuditStorage(resolver: r) } } } diff --git a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Overrides.swift b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Overrides.swift index b7948904656..3f06f09e367 100644 --- a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Overrides.swift +++ b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Overrides.swift @@ -22,6 +22,32 @@ extension Adjustments.StateModel { guard viewContext.hasChanges else { return } try viewContext.save() + // Audit log the override activation + let name = overrideToEnact.name ?? "Override" + let pct = overrideToEnact.percentage + var details = "\(name): \(Int(pct))%" + if let targetVal = overrideToEnact.target?.decimalValue, targetVal > 0 { + let targetUnit = units == .mmolL ? targetVal.formattedAsMmolL : "\(targetVal)" + let unitLabel = units == .mmolL ? "mmol/L" : "mg/dL" + details += ", target \(targetUnit) \(unitLabel)" + } + if overrideToEnact.indefinite { + details += ", indefinite" + } else if let dur = overrideToEnact.duration?.decimalValue, dur > 0 { + details += ", \(dur) min" + } + auditStorage.logChange( + category: "Adjustments", + subcategory: "Overrides", + settingName: "Override", + settingKey: "adjustments.override", + oldValue: "(none)", + newValue: details, + unit: nil, + note: nil, + source: "manual" + ) + updateLatestOverrideConfiguration() } catch { debugPrint("\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to enact Override Preset") @@ -60,6 +86,21 @@ extension Adjustments.StateModel { ) newOverrideRunStored.override = canceledOverride newOverrideRunStored.isUploadedToNS = false + + // Audit log the override cancellation + let name = canceledOverride.name ?? "Override" + let pct = canceledOverride.percentage + self.auditStorage.logChange( + category: "Adjustments", + subcategory: "Overrides", + settingName: "Override", + settingKey: "adjustments.override", + oldValue: "\(name): \(Int(pct))%", + newValue: "(cancelled)", + unit: nil, + note: nil, + source: "manual" + ) } } @@ -116,6 +157,31 @@ extension Adjustments.StateModel { // Then save and activate a new custom Override try await overrideStorage.storeOverride(override: override) + // Audit log the custom override activation + let name = overrideName.isEmpty ? "Custom Override" : overrideName + var details = "\(name): \(Int(overridePercentage))%" + if shouldOverrideTarget { + let targetUnit = units == .mmolL ? target.formattedAsMmolL : "\(target)" + let unitLabel = units == .mmolL ? "mmol/L" : "mg/dL" + details += ", target \(targetUnit) \(unitLabel)" + } + if indefinite { + details += ", indefinite" + } else if overrideDuration > 0 { + details += ", \(overrideDuration) min" + } + auditStorage.logChange( + category: "Adjustments", + subcategory: "Overrides", + settingName: "Override", + settingKey: "adjustments.override", + oldValue: "(none)", + newValue: details, + unit: nil, + note: nil, + source: "manual" + ) + // Reset State variables await resetStateVariables() diff --git a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+TempTargets.swift b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+TempTargets.swift index 6fdefeda107..770e4b3f491 100644 --- a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+TempTargets.swift +++ b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+TempTargets.swift @@ -228,6 +228,23 @@ extension Adjustments.StateModel { ) try await tempTargetStorage.storeTempTarget(tempTarget: tempTarget) tempTargetStorage.saveTempTargetsToStorage([tempTarget]) + + // Audit log the custom TT activation + let targetUnit = units == .mmolL ? tempTargetTarget.formattedAsMmolL : "\(tempTargetTarget)" + let unitLabel = units == .mmolL ? "mmol/L" : "mg/dL" + let name = tempTargetName.isEmpty ? "Custom Temp Target" : tempTargetName + auditStorage.logChange( + category: "Adjustments", + subcategory: "Temp Targets", + settingName: "Temp Target", + settingKey: "adjustments.tempTarget", + oldValue: "(none)", + newValue: "\(name): \(targetUnit) \(unitLabel), \(tempTargetDuration) min", + unit: nil, + note: nil, + source: "manual" + ) + await resetTempTargetState() isTempTargetEnabled = true updateLatestTempTargetConfiguration() @@ -273,6 +290,24 @@ extension Adjustments.StateModel { try viewContext.save() } + // Audit log the TT activation + let name = tempTargetToEnact.name ?? "Temp Target" + let targetVal = tempTargetToEnact.target?.decimalValue ?? 0 + let durationVal = tempTargetToEnact.duration?.decimalValue ?? 0 + let targetUnit = units == .mmolL ? targetVal.formattedAsMmolL : "\(targetVal)" + let unitLabel = units == .mmolL ? "mmol/L" : "mg/dL" + auditStorage.logChange( + category: "Adjustments", + subcategory: "Temp Targets", + settingName: "Temp Target", + settingKey: "adjustments.tempTarget", + oldValue: "(none)", + newValue: "\(name): \(targetUnit) \(unitLabel), \(durationVal) min", + unit: nil, + note: nil, + source: "manual" + ) + updateLatestTempTargetConfiguration() let tempTarget = TempTarget( @@ -323,6 +358,23 @@ extension Adjustments.StateModel { newTempTargetRunStored.target = canceledTempTarget.target ?? 0 newTempTargetRunStored.tempTarget = canceledTempTarget newTempTargetRunStored.isUploadedToNS = false + + // Audit log the TT cancellation + let name = canceledTempTarget.name ?? "Temp Target" + let targetVal = canceledTempTarget.target?.decimalValue ?? 0 + let targetUnit = self.units == .mmolL ? targetVal.formattedAsMmolL : "\(targetVal)" + let unitLabel = self.units == .mmolL ? "mmol/L" : "mg/dL" + self.auditStorage.logChange( + category: "Adjustments", + subcategory: "Temp Targets", + settingName: "Temp Target", + settingKey: "adjustments.tempTarget", + oldValue: "\(name): \(targetUnit) \(unitLabel)", + newValue: "(cancelled)", + unit: nil, + note: nil, + source: "manual" + ) } } diff --git a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel.swift b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel.swift index 44681020efb..e12c23c95af 100644 --- a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel.swift +++ b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel.swift @@ -12,6 +12,7 @@ extension Adjustments { @ObservationIgnored @Injected() var apsManager: APSManager! @ObservationIgnored @Injected() var overrideStorage: OverrideStorage! @ObservationIgnored @Injected() var nightscoutManager: NightscoutManager! + @ObservationIgnored @Injected() var auditStorage: SettingsAuditStorage! // MARK: - Override and Temp Target Properties diff --git a/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift b/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift index d5154202eff..ead2484856f 100644 --- a/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift +++ b/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift @@ -1,9 +1,12 @@ import Combine import Foundation import LoopKit +import Swinject extension BasalProfileEditor { final class Provider: BaseProvider, BasalProfileEditorProvider { + @Injected() private var auditStorage: SettingsAuditStorage! + private let processQueue = DispatchQueue(label: "BasalProfileEditorProvider.processQueue") var profile: [BasalProfileEntry] { @@ -22,6 +25,7 @@ extension BasalProfileEditor { return Fail(error: NSError()).eraseToAnyPublisher() } + let oldProfile = self.profile let syncValues = profile.map { RepeatingScheduleValue(startTime: TimeInterval($0.minutes * 60), value: Double($0.rate)) } @@ -31,6 +35,14 @@ extension BasalProfileEditor { switch result { case .success: self.storage.save(profile, as: OpenAPS.Settings.basalProfile) + self.auditStorage.logTherapyProfileChange( + subcategory: "Basal Rates", + settingName: "Basal Profile", + settingKey: "therapy.basalProfile", + oldEntries: oldProfile.map { "\($0.start): \($0.rate) U/hr" }, + newEntries: profile.map { "\($0.start): \($0.rate) U/hr" }, + unit: "U/hr" + ) promise(.success(())) case let .failure(error): promise(.failure(error)) diff --git a/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorProvider.swift b/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorProvider.swift index a5991cc437b..1767c8a9773 100644 --- a/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorProvider.swift +++ b/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorProvider.swift @@ -1,7 +1,10 @@ import Combine +import Swinject extension CarbRatioEditor { final class Provider: BaseProvider, CarbRatioEditorProvider { + @Injected() private var auditStorage: SettingsAuditStorage! + var profile: CarbRatios { storage.retrieve(OpenAPS.Settings.carbRatios, as: CarbRatios.self) ?? CarbRatios(from: OpenAPS.defaults(for: OpenAPS.Settings.carbRatios)) @@ -9,7 +12,17 @@ extension CarbRatioEditor { } func saveProfile(_ profile: CarbRatios) { + let old = storage.retrieve(OpenAPS.Settings.carbRatios, as: CarbRatios.self) + ?? CarbRatios(units: .grams, schedule: []) storage.save(profile, as: OpenAPS.Settings.carbRatios) + auditStorage.logTherapyProfileChange( + subcategory: "Carb Ratio", + settingName: "Carb Ratio Profile", + settingKey: "therapy.carbRatios", + oldEntries: old.schedule.map { "\($0.start): \($0.ratio) g/U" }, + newEntries: profile.schedule.map { "\($0.start): \($0.ratio) g/U" }, + unit: "g/U" + ) } } } diff --git a/Trio/Sources/Modules/ISFEditor/ISFEditorProvider.swift b/Trio/Sources/Modules/ISFEditor/ISFEditorProvider.swift index 6a4a7b6bc5d..670f3b0d4f0 100644 --- a/Trio/Sources/Modules/ISFEditor/ISFEditorProvider.swift +++ b/Trio/Sources/Modules/ISFEditor/ISFEditorProvider.swift @@ -1,7 +1,10 @@ import Foundation +import Swinject extension ISFEditor { final class Provider: BaseProvider, ISFEditorProvider { + @Injected() private var auditStorage: SettingsAuditStorage! + var profile: InsulinSensitivities { var retrievedSensitivities = storage.retrieve(OpenAPS.Settings.insulinSensitivities, as: InsulinSensitivities.self) ?? InsulinSensitivities(from: OpenAPS.defaults(for: OpenAPS.Settings.insulinSensitivities)) @@ -32,7 +35,17 @@ extension ISFEditor { } func saveProfile(_ profile: InsulinSensitivities) { + let old = storage.retrieve(OpenAPS.Settings.insulinSensitivities, as: InsulinSensitivities.self) + ?? InsulinSensitivities(units: .mgdL, userPreferredUnits: .mgdL, sensitivities: []) storage.save(profile, as: OpenAPS.Settings.insulinSensitivities) + auditStorage.logTherapyProfileChange( + subcategory: "Insulin Sensitivity Factor", + settingName: "ISF Profile", + settingKey: "therapy.insulinSensitivities", + oldEntries: old.sensitivities.map { "\($0.start): \($0.sensitivity) mg/dL/U" }, + newEntries: profile.sensitivities.map { "\($0.start): \($0.sensitivity) mg/dL/U" }, + unit: "mg/dL/U" + ) } } } diff --git a/Trio/Sources/Modules/Settings/View/SettingsRootView.swift b/Trio/Sources/Modules/Settings/View/SettingsRootView.swift index 8eaf2f65c12..5a8698c741d 100644 --- a/Trio/Sources/Modules/Settings/View/SettingsRootView.swift +++ b/Trio/Sources/Modules/Settings/View/SettingsRootView.swift @@ -278,6 +278,14 @@ extension Settings { } ).listRowBackground(Color.chart) + Section( + header: Text("Settings History"), + content: { + Text("Settings Change Log") + .navigationLink(to: .settingsAuditLog, from: self) + } + ).listRowBackground(Color.chart) + } else { Section( header: Text("Search Results"), diff --git a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogDataFlow.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogDataFlow.swift new file mode 100644 index 00000000000..f9621a620e3 --- /dev/null +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogDataFlow.swift @@ -0,0 +1,5 @@ +enum SettingsAuditLog { + enum Config {} +} + +protocol SettingsAuditLogProvider: Provider {} diff --git a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogProvider.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogProvider.swift new file mode 100644 index 00000000000..9dcaae0658c --- /dev/null +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogProvider.swift @@ -0,0 +1,8 @@ +import Foundation +import Swinject + +extension SettingsAuditLog { + final class Provider: BaseProvider, SettingsAuditLogProvider { + @Injected() var auditStorage: SettingsAuditStorage! + } +} diff --git a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift new file mode 100644 index 00000000000..76a11504041 --- /dev/null +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift @@ -0,0 +1,416 @@ +import Foundation +import Observation +import SwiftUI + +extension SettingsAuditLog { + /// Plain-value snapshot of a single `SettingsChangeStored` managed object. + /// Capturing values eagerly avoids Core Data threading / faulting crashes. + struct ChangeEntry: Identifiable, Sendable { + let id: UUID + let date: Date + let category: String + let subcategory: String + let settingName: String + let settingKey: String + let oldValue: String + let newValue: String + let unit: String? + let note: String + let source: String + let groupId: UUID + + init( + id: UUID, + date: Date, + category: String, + subcategory: String, + settingName: String, + settingKey: String, + oldValue: String, + newValue: String, + unit: String?, + note: String, + source: String, + groupId: UUID + ) { + self.id = id + self.date = date + self.category = category + self.subcategory = subcategory + self.settingName = settingName + self.settingKey = settingKey + self.oldValue = oldValue + self.newValue = newValue + self.unit = unit + self.note = note + self.source = source + self.groupId = groupId + } + + /// Force-copies every String so the ChangeEntry owns its own storage + /// and does not share NSString backing with Core Data managed objects. + init(from stored: SettingsChangeStored) { + id = stored.id ?? UUID() + date = stored.date ?? .distantPast + category = String(stored.category ?? "") + subcategory = String(stored.subcategory ?? "") + settingName = String(stored.settingName ?? "") + settingKey = String(stored.settingKey ?? "") + oldValue = String(stored.oldValue ?? "") + newValue = String(stored.newValue ?? "") + unit = stored.unit.map { String($0) } + note = String(stored.note ?? "") + source = String(stored.source ?? "manual") + groupId = stored.groupId ?? stored.id ?? UUID() + } + + /// Returns the display string for a value, stripping seconds from time prefixes + /// and converting mg/dL → mmol/L when needed. + func displayValue(_ raw: String, units: GlucoseUnits) -> String { + let cleaned = Self.stripTimeSeconds(raw) + guard units == .mmolL, let u = unit, Self.glucoseConvertibleUnits.contains(u) else { return cleaned } + return Self.convertGlucoseString(cleaned, to: units) + } + + /// Regex to match time prefixes with seconds: "HH:mm:ss: " → captures "HH:mm" and ": " + private static let timeSecondsRegex = try! NSRegularExpression(pattern: #"(\d{1,2}:\d{2}):\d{2}(: )"#) + + /// Strips seconds from time prefixes in therapy profile value strings. + /// e.g. "06:00:00: 1.0 U/hr" → "06:00: 1.0 U/hr" + private static func stripTimeSeconds(_ raw: String) -> String { + timeSecondsRegex.stringByReplacingMatches( + in: raw, + range: NSRange(raw.startIndex..., in: raw), + withTemplate: "$1$2" + ) + } + + /// Returns the daily basal total string (e.g. "18.4 U") for basal profile entries, + /// or nil if the entry is not a basal profile change. + func dailyBasalTotal(from raw: String) -> String? { + guard settingKey == "therapy.basalProfile" else { return nil } + guard let total = Self.calculateDailyBasalTotal(from: raw) else { return nil } + let nf = NumberFormatter() + nf.minimumFractionDigits = 1 + nf.maximumFractionDigits = 2 + return (nf.string(from: total as NSDecimalNumber) ?? "\(total)") + " U" + } + + /// Parses a basal profile string like "0:00: 0.8 U/hr, 06:00: 1.0 U/hr" and + /// calculates the 24h total insulin delivery. + private static func calculateDailyBasalTotal(from raw: String) -> Decimal? { + let segments = raw.components(separatedBy: ", ") + var entries: [(minuteStart: Int, rate: Decimal)] = [] + + for segment in segments { + let trimmed = segment.trimmingCharacters(in: .whitespaces) + // Expected format: "HH:mm: X.X U/hr" or "HH:mm:ss: X.X U/hr" + guard let colonSpaceRange = trimmed.range(of: ": ") else { continue } + let timeStr = String(trimmed[trimmed.startIndex ..< colonSpaceRange.lowerBound]) + let valueStr = String(trimmed[colonSpaceRange.upperBound...]) + .replacingOccurrences(of: " U/hr", with: "") + .trimmingCharacters(in: .whitespaces) + + // Parse time "HH:mm" or "HH:mm:ss" → minutes since midnight + let timeParts = timeStr.components(separatedBy: ":") + guard timeParts.count >= 2, + let hours = Int(timeParts[0].trimmingCharacters(in: .whitespaces)), + let mins = Int(timeParts[1].trimmingCharacters(in: .whitespaces)) + else { continue } + + guard let rate = Decimal(string: valueStr) else { continue } + entries.append((minuteStart: hours * 60 + mins, rate: rate)) + } + + guard !entries.isEmpty else { return nil } + + // Sort by start time + entries.sort { $0.minuteStart < $1.minuteStart } + + var total: Decimal = 0 + for (index, entry) in entries.enumerated() { + let nextStart: Int + if index + 1 < entries.count { + nextStart = entries[index + 1].minuteStart + } else { + nextStart = 24 * 60 // end of day + } + let durationMinutes = nextStart - entry.minuteStart + guard durationMinutes > 0 else { continue } + let durationHours = Decimal(durationMinutes) / Decimal(60) + total += entry.rate * durationHours + } + + return total + } + + /// The display unit label, adjusted for the user's preferred glucose unit. + func displayUnit(units: GlucoseUnits) -> String? { + guard let u = unit, !u.isEmpty else { return nil } + if units == .mmolL, Self.glucoseConvertibleUnits.contains(u) { + return u.replacingOccurrences(of: "mg/dL", with: units.rawValue) + } + return u + } + + /// Units whose numeric values are stored in mg/dL and should be converted for display. + private static let glucoseConvertibleUnits: Set = ["mg/dL", "mg/dL/U"] + + /// Converts a string that may contain one or more mg/dL numeric values to the target unit. + /// Handles both single values ("120"), ranges ("100-120"), and comma-separated lists + /// like "08:00: 100, 12:00: 90" or "08:00: 100-120 mg/dL, 12:00: 90-110 mg/dL". + private static func convertGlucoseString(_ raw: String, to units: GlucoseUnits) -> String { + guard units == .mmolL else { return raw } + + // Handle comma-separated therapy profile entries + if raw.contains(",") { + let parts = raw.components(separatedBy: ", ") + let converted = parts.map { convertSingleSegment($0, to: units) } + return converted.joined(separator: ", ") + } + return convertSingleSegment(raw, to: units) + } + + /// Converts a single segment like "120", "100-120", "08:00: 100" or "08:00: 100-120 mg/dL" + /// from mg/dL to mmol/L. + private static func convertSingleSegment(_ segment: String, to units: GlucoseUnits) -> String { + let trimmed = segment.trimmingCharacters(in: .whitespaces) + + // Pattern: optional time prefix "HH:mm: " followed by the value part + let prefix: String + let valuePart: String + if let colonRange = trimmed.range(of: ": ", options: .backwards) { + prefix = String(trimmed[trimmed.startIndex ..< colonRange.upperBound]) + valuePart = String(trimmed[colonRange.upperBound...]).trimmingCharacters(in: .whitespaces) + } else { + prefix = "" + valuePart = trimmed + } + + // Strip trailing unit label (e.g. " mg/dL" or " mg/dL/U") for parsing + let stripped = valuePart + .replacingOccurrences(of: " mg/dL/U", with: "") + .replacingOccurrences(of: " mg/dL", with: "") + .trimmingCharacters(in: .whitespaces) + + // Handle range format "100-120" + if stripped.contains("-") { + let rangeParts = stripped.components(separatedBy: "-") + if rangeParts.count == 2, + let low = Decimal(string: rangeParts[0].trimmingCharacters(in: .whitespaces)), + let high = Decimal(string: rangeParts[1].trimmingCharacters(in: .whitespaces)) + { + return prefix + low.formatted(for: units) + "-" + high.formatted(for: units) + } + } + + // Plain number + if let decimal = Decimal(string: stripped) { + return prefix + decimal.formatted(for: units) + } + return segment + } + } + + /// A single change event that groups all individual setting changes sharing the same `groupId`. + struct ChangeEvent: Identifiable { + let id: UUID // groupId + let date: Date + let entries: [ChangeEntry] + let note: String + + /// Summary label, e.g. "3 settings changed" or the single setting name. + var summaryLabel: String { + if entries.count == 1 { + return entries.first?.settingName ?? "1 setting changed" + } + return "\(entries.count) settings changed" + } + + /// Distinct categories across all entries. + var categories: [String] { + let cats = Set(entries.map(\.category)).filter { !$0.isEmpty } + return cats.sorted() + } + } + + @Observable final class StateModel: BaseStateModel { + var searchText: String = "" { + didSet { recomputeDerivedState() } + } + + var selectedCategory: String? = nil + var units: GlucoseUnits = .mgdL + + /// Pre-computed stored properties — never iterate `entries` during body rendering. + var allCategories: [String] = ["All"] + var filteredEntries: [ChangeEntry] = [] + var groupedEvents: [(String, [ChangeEvent])] = [] + + /// Raw entries — private to prevent direct access during rendering. + private var entries: [ChangeEntry] = [] + + private static let groupingCalendar: Calendar = .current + + private static let groupingFormatter: DateFormatter = { + let df = DateFormatter() + df.dateStyle = .medium + df.timeStyle = .none + return df + }() + + override func subscribe() { + units = settingsManager.settings.units + loadEntries() + } + + func loadEntries() { + entries = provider.auditStorage.fetchChangeEntries( + category: selectedCategory, + since: nil, + limit: 500 + ) + recomputeDerivedState() + } + + func updateNote(forGroup groupId: UUID, note: String) { + provider.auditStorage.updateNote(forGroup: groupId, note: note) + // Update in-memory entries immediately without refetching from Core Data + // to avoid race conditions with the background context save + entries = entries.map { entry in + guard entry.groupId == groupId else { return entry } + return ChangeEntry( + id: entry.id, + date: entry.date, + category: entry.category, + subcategory: entry.subcategory, + settingName: entry.settingName, + settingKey: entry.settingKey, + oldValue: entry.oldValue, + newValue: entry.newValue, + unit: entry.unit, + note: note, + source: entry.source, + groupId: entry.groupId + ) + } + recomputeDerivedState() + } + + /// Recompute all derived state from the current `entries` snapshot. + /// This avoids iterating `entries` in computed properties during SwiftUI body rendering, + /// which can crash if the @Observable registrar triggers a re-render mid-read. + private func recomputeDerivedState() { + // 1. Categories + var cats = Set() + for entry in entries { + if !entry.category.isEmpty { cats.insert(entry.category) } + } + allCategories = ["All"] + cats.sorted() + + // 2. Filtered entries + if searchText.isEmpty { + filteredEntries = entries + } else { + let lower = searchText.lowercased() + filteredEntries = entries.filter { + $0.settingName.lowercased().contains(lower) || + $0.category.lowercased().contains(lower) || + $0.oldValue.lowercased().contains(lower) || + $0.newValue.lowercased().contains(lower) || + $0.note.lowercased().contains(lower) + } + } + + // 3. Grouped events + let cal = Self.groupingCalendar + let df = Self.groupingFormatter + + let byGroup = Dictionary(grouping: filteredEntries, by: \.groupId) + let events: [ChangeEvent] = byGroup.map { groupId, groupEntries in + let sorted = groupEntries.sorted { $0.date > $1.date } + let date = sorted.first?.date ?? .distantPast + let note = sorted.first?.note ?? "" + return ChangeEvent(id: groupId, date: date, entries: sorted, note: note) + } + + let byDay = Dictionary(grouping: events) { event -> DateComponents in + cal.dateComponents([.year, .month, .day], from: event.date) + } + groupedEvents = byDay + .sorted { a, b in + let aDate = cal.date(from: a.key) ?? .distantPast + let bDate = cal.date(from: b.key) ?? .distantPast + return aDate > bDate + } + .map { components, dayEvents in + let label: String + if let date = cal.date(from: components) { + label = df.string(from: date) + } else { + label = "Unknown" + } + let sorted = dayEvents.sorted { $0.date > $1.date } + return (label, sorted) + } + } + + // MARK: - Delete all entries + + func deleteAllEntries() { + provider.auditStorage.deleteAllEntries() + entries = [] + } + + // MARK: - CSV Export + + private static let csvDateFormatter: DateFormatter = { + let df = DateFormatter() + df.dateFormat = "yyyy-MM-dd HH:mm:ss" + return df + }() + + /// Generates a CSV string of all filtered entries grouped by time. + func generateCSV() -> String { + var csv = "Date,Category,Subcategory,Setting,Old Value,New Value,Unit,Note\n" + for entry in filteredEntries { + let date = Self.csvDateFormatter.string(from: entry.date) + let oldVal = entry.displayValue(entry.oldValue, units: units) + let newVal = entry.displayValue(entry.newValue, units: units) + let unit = entry.displayUnit(units: units) ?? "" + csv += "\(csvEscape(date)),\(csvEscape(entry.category)),\(csvEscape(entry.subcategory))," + csv += "\(csvEscape(entry.settingName)),\(csvEscape(oldVal)),\(csvEscape(newVal))," + csv += "\(csvEscape(unit)),\(csvEscape(entry.note))\n" + } + return csv + } + + /// Generates a CSV string of all filtered entries grouped by change event. + func generateGroupedCSV() -> String { + var csv = "Group Date,Group ID,Setting,Category,Subcategory,Old Value,New Value,Unit,Note\n" + for (_, dayEvents) in groupedEvents { + for event in dayEvents { + for entry in event.entries { + let date = Self.csvDateFormatter.string(from: entry.date) + let oldVal = entry.displayValue(entry.oldValue, units: units) + let newVal = entry.displayValue(entry.newValue, units: units) + let unit = entry.displayUnit(units: units) ?? "" + csv += "\(csvEscape(date)),\(csvEscape(event.id.uuidString))," + csv += "\(csvEscape(entry.settingName)),\(csvEscape(entry.category))," + csv += "\(csvEscape(entry.subcategory)),\(csvEscape(oldVal))," + csv += "\(csvEscape(newVal)),\(csvEscape(unit)),\(csvEscape(event.note))\n" + } + } + } + return csv + } + + private func csvEscape(_ value: String) -> String { + let needsQuoting = value.contains(",") || value.contains("\"") || value.contains("\n") + if needsQuoting { + return "\"" + value.replacingOccurrences(of: "\"", with: "\"\"") + "\"" + } + return value + } + } +} diff --git a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift new file mode 100644 index 00000000000..ea2247f6220 --- /dev/null +++ b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift @@ -0,0 +1,383 @@ +import SwiftUI +import Swinject + +extension SettingsAuditLog { + /// Discriminant for the single `.sheet(item:)` used by RootView. + /// Consolidating sheets avoids the SwiftUI crash that can occur when + /// two `.sheet` modifiers compete on the same view hierarchy. + private enum ActiveSheet: Identifiable { + case eventDetail(ChangeEvent) + case export(URL) + + var id: String { + switch self { + case .eventDetail(let event): return "detail-\(event.id)" + case .export(let url): return "export-\(url.absoluteString)" + } + } + } + + struct RootView: BaseView { + let resolver: Resolver + @State var state = StateModel() + + @Environment(\.colorScheme) var colorScheme + @Environment(AppState.self) var appState + + /// Tracks the currently viewed event for note-editing across sheet lifecycle. + @State private var selectedEvent: ChangeEvent? + @State private var noteText = "" + @State private var debounceTask: Task? + @State private var showDeleteConfirmation = false + /// Single active sheet – only one sheet is presented at a time. + @State private var activeSheet: ActiveSheet? + + var body: some View { + List { + categoryPicker + + if state.groupedEvents.isEmpty { + Section { + Text("No settings changes recorded yet.") + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding() + } + .listRowBackground(Color.chart) + } + + ForEach(state.groupedEvents, id: \.0) { day, dayEvents in + Section(header: Text(day)) { + ForEach(dayEvents) { event in + EventRow(event: event, units: state.units) + .contentShape(Rectangle()) + .onTapGesture { + noteText = event.note + selectedEvent = event + activeSheet = .eventDetail(event) + } + } + } + .listRowBackground(Color.chart) + } + } + .scrollContentBackground(.hidden) + .background(appState.trioBackgroundColor(for: colorScheme)) + .navigationTitle("Settings History") + .navigationBarTitleDisplayMode(.automatic) + .searchable(text: $state.searchText, placement: .navigationBarDrawer(displayMode: .automatic)) + .onAppear(perform: configureView) + .onDisappear { + // Flush any pending note save immediately before teardown + if let task = debounceTask, let event = selectedEvent { + task.cancel() + debounceTask = nil + state.updateNote(forGroup: event.id, note: noteText) + } + } + .sheet(item: $activeSheet, onDismiss: { + // Flush any pending note save when the detail sheet is dismissed. + if let task = debounceTask, let event = selectedEvent { + task.cancel() + debounceTask = nil + state.updateNote(forGroup: event.id, note: noteText) + } + }) { sheet in + switch sheet { + case .eventDetail(let event): + NavigationView { + EventDetailView(event: event, units: state.units, noteText: $noteText) + } + case .export(let url): + ShareSheet(activityItems: [url]) + } + } + .onChange(of: noteText) { _, newValue in + guard let event = selectedEvent else { return } + debounceTask?.cancel() + debounceTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s debounce + guard !Task.isCancelled else { return } + state.updateNote(forGroup: event.id, note: newValue) + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button { + exportCSV(grouped: false) + } label: { + Label("Export by Time", systemImage: "clock") + } + Button { + exportCSV(grouped: true) + } label: { + Label("Export by Group", systemImage: "rectangle.stack") + } + Divider() + Button(role: .destructive) { + showDeleteConfirmation = true + } label: { + Label("Delete All Entries", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .alert("Delete All Entries?", isPresented: $showDeleteConfirmation) { + Button("Delete", role: .destructive) { + state.deleteAllEntries() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will permanently delete all settings history entries. This action cannot be undone.") + } + } + + private func exportCSV(grouped: Bool) { + let csv = grouped ? state.generateGroupedCSV() : state.generateCSV() + let fileName = "SettingsHistory_\(Self.filenameDateFormatter.string(from: Date())).csv" + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) + do { + try csv.write(to: tempURL, atomically: true, encoding: .utf8) + activeSheet = .export(tempURL) + } catch { + debug(.default, "Failed to write CSV: \(error)") + } + } + + private static let filenameDateFormatter: DateFormatter = { + let df = DateFormatter() + df.dateFormat = "yyyyMMdd_HHmmss" + return df + }() + + @ViewBuilder + private var categoryPicker: some View { + Section { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(state.allCategories, id: \.self) { cat in + let isSelected = (state.selectedCategory ?? "All") == cat + Button { + state.selectedCategory = cat == "All" ? nil : cat + state.loadEntries() + } label: { + Text(cat) + .font(.caption) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(isSelected ? Color.accentColor : Color.secondary.opacity(0.2)) + .foregroundColor(isSelected ? .white : .primary) + .cornerRadius(12) + } + } + } + .padding(.vertical, 4) + } + } + .listRowBackground(Color.chart) + } + } +} + +// MARK: - EventRow + +private struct EventRow: View { + let event: SettingsAuditLog.ChangeEvent + let units: GlucoseUnits + + private static let timeFormatter: DateFormatter = { + let df = DateFormatter() + df.timeStyle = .short + return df + }() + + private var timeString: String { + Self.timeFormatter.string(from: event.date) + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(event.summaryLabel) + .font(.subheadline) + .fontWeight(.medium) + Text(event.categories.joined(separator: ", ")) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text(timeString) + .font(.caption) + .foregroundColor(.secondary) + if !event.note.isEmpty { + Image(systemName: "note.text") + .font(.caption) + .foregroundColor(.accentColor) + } + } + } + + ForEach(event.entries) { entry in + VStack(spacing: 2) { + HStack(spacing: 4) { + Text(entry.settingName) + .font(.caption2) + .foregroundColor(.primary) + .lineLimit(1) + Spacer() + Text(entry.displayValue(entry.oldValue, units: units)) + .font(.caption2) + .foregroundColor(.red) + .lineLimit(1) + Image(systemName: "arrow.right") + .font(.system(size: 8)) + .foregroundColor(.secondary) + Text(entry.displayValue(entry.newValue, units: units)) + .font(.caption2) + .foregroundColor(.green) + .lineLimit(1) + if let displayUnit = entry.displayUnit(units: units) { + Text(displayUnit) + .font(.system(size: 9)) + .foregroundColor(.secondary) + } + } + if let oldTotal = entry.dailyBasalTotal(from: entry.oldValue), + let newTotal = entry.dailyBasalTotal(from: entry.newValue) + { + HStack(spacing: 4) { + Spacer() + Text("Daily total:") + .font(.system(size: 9)) + .foregroundColor(.secondary) + Text(oldTotal) + .font(.system(size: 9)) + .foregroundColor(.red) + Image(systemName: "arrow.right") + .font(.system(size: 7)) + .foregroundColor(.secondary) + Text(newTotal) + .font(.system(size: 9)) + .foregroundColor(.green) + } + } + } + } + + // Show note text on overview when present + if !event.note.isEmpty { + Text(event.note) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(2) + .padding(.top, 2) + } + } + .padding(.vertical, 2) + } +} + +// MARK: - EventDetailView + +private struct EventDetailView: View { + let event: SettingsAuditLog.ChangeEvent + let units: GlucoseUnits + @Binding var noteText: String + + @Environment(\.dismiss) var dismiss + + private static let detailFormatter: DateFormatter = { + let df = DateFormatter() + df.dateStyle = .medium + df.timeStyle = .short + return df + }() + + private var formattedDate: String { + Self.detailFormatter.string(from: event.date) + } + + var body: some View { + Form { + Section("Event") { + LabeledContent("Date", value: formattedDate) + LabeledContent("Changes", value: "\(event.entries.count)") + LabeledContent("Categories", value: event.categories.joined(separator: ", ")) + } + Section("Changes") { + ForEach(event.entries) { entry in + VStack(alignment: .leading, spacing: 2) { + Text(entry.settingName) + .font(.subheadline) + .fontWeight(.medium) + HStack(spacing: 4) { + Text(entry.displayValue(entry.oldValue, units: units)) + .font(.caption) + .foregroundColor(.red) + .lineLimit(1) + Image(systemName: "arrow.right") + .font(.caption2) + .foregroundColor(.secondary) + Text(entry.displayValue(entry.newValue, units: units)) + .font(.caption) + .foregroundColor(.green) + .lineLimit(1) + if let displayUnit = entry.displayUnit(units: units) { + Text(displayUnit) + .font(.caption2) + .foregroundColor(.secondary) + } + } + if let oldTotal = entry.dailyBasalTotal(from: entry.oldValue), + let newTotal = entry.dailyBasalTotal(from: entry.newValue) + { + HStack(spacing: 4) { + Text("Daily total:") + .font(.caption2) + .foregroundColor(.secondary) + Text(oldTotal) + .font(.caption2) + .foregroundColor(.red) + Image(systemName: "arrow.right") + .font(.system(size: 8)) + .foregroundColor(.secondary) + Text(newTotal) + .font(.caption2) + .foregroundColor(.green) + } + } + if !entry.subcategory.isEmpty { + Text(entry.subcategory) + .font(.caption2) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 2) + } + } + Section("Note") { + TextEditor(text: $noteText) + .frame(minHeight: 80) + if !noteText.isEmpty { + Text("Notes are saved automatically") + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + .navigationTitle("Change Event") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } +} + + diff --git a/Trio/Sources/Modules/TargetsEditor/TargetsEditorProvider.swift b/Trio/Sources/Modules/TargetsEditor/TargetsEditorProvider.swift index d8499302795..6af8c63b04e 100644 --- a/Trio/Sources/Modules/TargetsEditor/TargetsEditorProvider.swift +++ b/Trio/Sources/Modules/TargetsEditor/TargetsEditorProvider.swift @@ -1,7 +1,10 @@ import Foundation +import Swinject extension TargetsEditor { final class Provider: BaseProvider, TargetsEditorProvider { + @Injected() private var auditStorage: SettingsAuditStorage! + var profile: BGTargets { var retrievedTargets = storage.retrieve(OpenAPS.Settings.bgTargets, as: BGTargets.self) ?? BGTargets(from: OpenAPS.defaults(for: OpenAPS.Settings.bgTargets)) @@ -25,7 +28,17 @@ extension TargetsEditor { } func saveProfile(_ profile: BGTargets) { + let old = storage.retrieve(OpenAPS.Settings.bgTargets, as: BGTargets.self) + ?? BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: []) storage.save(profile, as: OpenAPS.Settings.bgTargets) + auditStorage.logTherapyProfileChange( + subcategory: "BG Targets", + settingName: "BG Target Profile", + settingKey: "therapy.bgTargets", + oldEntries: old.targets.map { "\($0.start): \($0.low)-\($0.high) mg/dL" }, + newEntries: profile.targets.map { "\($0.start): \($0.low)-\($0.high) mg/dL" }, + unit: "mg/dL" + ) } } } diff --git a/Trio/Sources/Router/Screen.swift b/Trio/Sources/Router/Screen.swift index 1bfc27857f1..cc68c319b84 100644 --- a/Trio/Sources/Router/Screen.swift +++ b/Trio/Sources/Router/Screen.swift @@ -50,6 +50,7 @@ enum Screen: Identifiable, Hashable { case unitsAndLimits case appDiagnostics case settingsExport + case settingsAuditLog var id: Int { String(reflecting: self).hashValue } } @@ -165,6 +166,8 @@ extension Screen { AppDiagnostics.RootView(resolver: resolver) case .settingsExport: SettingsExport.RootView(resolver: resolver) + case .settingsAuditLog: + SettingsAuditLog.RootView(resolver: resolver) } } diff --git a/Trio/Sources/Services/SettingsManager/SettingsManager.swift b/Trio/Sources/Services/SettingsManager/SettingsManager.swift index c2d3779cf98..bc6105728d2 100644 --- a/Trio/Sources/Services/SettingsManager/SettingsManager.swift +++ b/Trio/Sources/Services/SettingsManager/SettingsManager.swift @@ -20,10 +20,12 @@ protocol PreferencesObserver { final class BaseSettingsManager: SettingsManager, Injectable { @Injected() var broadcaster: Broadcaster! @Injected() var storage: FileStorage! + @Injected() var auditStorage: SettingsAuditStorage! @SyncAccess var settings: TrioSettings { didSet { if oldValue != settings { + logSettingsChanges(old: oldValue, new: settings) saveSettings() DispatchQueue.main.async { self.broadcaster.notify(SettingsObserver.self, on: .main) { @@ -37,6 +39,7 @@ final class BaseSettingsManager: SettingsManager, Injectable { @SyncAccess var preferences: Preferences { didSet { if oldValue != preferences { + logPreferencesChanges(old: oldValue, new: preferences) savePreferences() DispatchQueue.main.async { self.broadcaster.notify(PreferencesObserver.self, on: .main) { @@ -94,4 +97,62 @@ final class BaseSettingsManager: SettingsManager, Injectable { preferences = prefs savePreferences() } + + // MARK: - Change capture helpers + + private func logMirrorChanges( + old: T, + new: T, + registryMap: [String: SettingMetadata], + keyPrefix: String, + fallbackCategory: String + ) { + let mirror = Mirror(reflecting: new) + let oldMirror = Mirror(reflecting: old) + + let oldDict = Dictionary(uniqueKeysWithValues: oldMirror.children.compactMap { child -> (String, String)? in + guard let label = child.label else { return nil } + return (label, "\(child.value)") + }) + + for child in mirror.children { + guard let label = child.label else { continue } + let newVal = "\(child.value)" + let oldVal = oldDict[label] ?? "" + guard oldVal != newVal else { continue } + + let meta = registryMap[label] + auditStorage.logChange( + category: meta?.category ?? fallbackCategory, + subcategory: meta?.subcategory ?? "General", + settingName: meta?.name ?? label, + settingKey: "\(keyPrefix).\(label)", + oldValue: oldVal, + newValue: newVal, + unit: meta?.unit, + note: nil, + source: "manual" + ) + } + } + + private func logSettingsChanges(old: TrioSettings, new: TrioSettings) { + logMirrorChanges( + old: old, + new: new, + registryMap: SettingsMetadataRegistry.trioSettingsMap, + keyPrefix: "settings", + fallbackCategory: "Settings" + ) + } + + private func logPreferencesChanges(old: Preferences, new: Preferences) { + logMirrorChanges( + old: old, + new: new, + registryMap: SettingsMetadataRegistry.preferencesMap, + keyPrefix: "preferences", + fallbackCategory: "Algorithm" + ) + } } diff --git a/Trio/Sources/Services/SettingsManager/SettingsMetadataRegistry.swift b/Trio/Sources/Services/SettingsManager/SettingsMetadataRegistry.swift new file mode 100644 index 00000000000..54e0eddd956 --- /dev/null +++ b/Trio/Sources/Services/SettingsManager/SettingsMetadataRegistry.swift @@ -0,0 +1,170 @@ +import Foundation + +/// Metadata for a single setting field, used by the audit log +struct SettingMetadata { + let key: String + let name: String + let category: String + let subcategory: String + let unit: String? +} + +/// Registry mapping TrioSettings and Preferences field names to human-readable metadata +enum SettingsMetadataRegistry { + // MARK: - TrioSettings metadata + + static let trioSettingsMap: [String: SettingMetadata] = { + var map: [String: SettingMetadata] = [:] + for m in trioSettingsMetadata { map[m.key] = m } + return map + }() + + static let preferencesMap: [String: SettingMetadata] = { + var map: [String: SettingMetadata] = [:] + for m in preferencesMetadata { map[m.key] = m } + return map + }() + + private static let trioSettingsMetadata: [SettingMetadata] = [ + // Units & Basics + .init(key: "units", name: "Glucose Units", category: "Therapy", subcategory: "Units & Limits", unit: nil), + .init(key: "closedLoop", name: "Closed Loop", category: "Features", subcategory: "Automated Insulin Delivery", unit: nil), + .init(key: "debugOptions", name: "Debug Options", category: "Features", subcategory: "Developer", unit: nil), + // CGM + .init(key: "cgm", name: "CGM Type", category: "Devices", subcategory: "CGM", unit: nil), + .init(key: "cgmPluginIdentifier", name: "CGM Plugin", category: "Devices", subcategory: "CGM", unit: nil), + .init(key: "smoothGlucose", name: "Smooth Glucose", category: "Devices", subcategory: "CGM", unit: nil), + .init(key: "uploadGlucose", name: "Upload Glucose", category: "Services", subcategory: "Nightscout", unit: nil), + // Nightscout + .init(key: "isUploadEnabled", name: "Upload Enabled", category: "Services", subcategory: "Nightscout", unit: nil), + .init(key: "isDownloadEnabled", name: "Download Enabled", category: "Services", subcategory: "Nightscout", unit: nil), + // Notifications + .init(key: "notificationsPump", name: "Pump Notifications", category: "Notifications", subcategory: "General", unit: nil), + .init(key: "notificationsCgm", name: "CGM Notifications", category: "Notifications", subcategory: "General", unit: nil), + .init(key: "notificationsCarb", name: "Carb Notifications", category: "Notifications", subcategory: "General", unit: nil), + .init(key: "notificationsAlgorithm", name: "Algorithm Notifications", category: "Notifications", subcategory: "General", unit: nil), + .init(key: "glucoseNotificationsOption", name: "Glucose Notification Option", category: "Notifications", subcategory: "Glucose", unit: nil), + .init(key: "addSourceInfoToGlucoseNotifications", name: "Add Source Info to Notifications", category: "Notifications", subcategory: "Glucose", unit: nil), + // Glucose Thresholds + .init(key: "lowGlucose", name: "Low Glucose Alert", category: "Notifications", subcategory: "Glucose Alerts", unit: "mg/dL"), + .init(key: "highGlucose", name: "High Glucose Alert", category: "Notifications", subcategory: "Glucose Alerts", unit: "mg/dL"), + .init(key: "carbsRequiredThreshold", name: "Carbs Required Threshold", category: "Algorithm", subcategory: "Carbs", unit: "g"), + .init(key: "showCarbsRequiredBadge", name: "Show Carbs Required Badge", category: "Features", subcategory: "Display", unit: nil), + // Display + .init(key: "high", name: "High Glucose Display", category: "Features", subcategory: "Display", unit: "mg/dL"), + .init(key: "low", name: "Low Glucose Display", category: "Features", subcategory: "Display", unit: "mg/dL"), + .init(key: "glucoseColorScheme", name: "Glucose Color Scheme", category: "Features", subcategory: "Display", unit: nil), + .init(key: "xGridLines", name: "X Grid Lines", category: "Features", subcategory: "Display", unit: nil), + .init(key: "yGridLines", name: "Y Grid Lines", category: "Features", subcategory: "Display", unit: nil), + .init(key: "bolusDisplayThreshold", name: "Bolus Display Threshold", category: "Features", subcategory: "Display", unit: "U"), + .init(key: "forecastDisplayType", name: "Forecast Display Type", category: "Features", subcategory: "Display", unit: nil), + .init(key: "showCobIobChart", name: "Show COB/IOB Chart", category: "Features", subcategory: "Display", unit: nil), + .init(key: "rulerMarks", name: "Ruler Marks", category: "Features", subcategory: "Display", unit: nil), + .init(key: "hideInsulinBadge", name: "Hide Insulin Badge", category: "Features", subcategory: "Display", unit: nil), + // Insulin Concentration + .init(key: "allowDilution", name: "Allow Dilution", category: "Therapy", subcategory: "Units & Limits", unit: nil), + .init(key: "insulinConcentration", name: "Insulin Concentration", category: "Therapy", subcategory: "Units & Limits", unit: nil), + // Bolus + .init(key: "confirmBolus", name: "Confirm Bolus", category: "Features", subcategory: "Bolus", unit: nil), + .init(key: "confirmBolusFaster", name: "Confirm Bolus Faster", category: "Features", subcategory: "Bolus", unit: nil), + // Meal / FPU + .init(key: "useFPUconversion", name: "Use FPU Conversion", category: "Features", subcategory: "Meals", unit: nil), + .init(key: "fattyMeals", name: "Fatty Meals", category: "Features", subcategory: "Meals", unit: nil), + .init(key: "fattyMealFactor", name: "Fatty Meal Factor", category: "Features", subcategory: "Meals", unit: nil), + .init(key: "sweetMeals", name: "Sweet Meals", category: "Features", subcategory: "Meals", unit: nil), + .init(key: "sweetMealFactor", name: "Sweet Meal Factor", category: "Features", subcategory: "Meals", unit: nil), + .init(key: "maxCarbs", name: "Max Carbs", category: "Features", subcategory: "Meals", unit: "g"), + .init(key: "maxFat", name: "Max Fat", category: "Features", subcategory: "Meals", unit: "g"), + .init(key: "maxProtein", name: "Max Protein", category: "Features", subcategory: "Meals", unit: "g"), + .init(key: "individualAdjustmentFactor", name: "Individual Adjustment Factor", category: "Features", subcategory: "Meals", unit: nil), + .init(key: "minuteInterval", name: "FPU Minute Interval", category: "Features", subcategory: "Meals", unit: "min"), + .init(key: "delay", name: "FPU Delay", category: "Features", subcategory: "Meals", unit: "min"), + // Health & Calendar + .init(key: "useAppleHealth", name: "Use Apple Health", category: "Services", subcategory: "Apple Health", unit: nil), + .init(key: "useCalendar", name: "Use Calendar", category: "Features", subcategory: "Calendar", unit: nil), + .init(key: "displayCalendarIOBandCOB", name: "Calendar IOB and COB", category: "Features", subcategory: "Calendar", unit: nil), + .init(key: "displayCalendarEmojis", name: "Calendar Emojis", category: "Features", subcategory: "Calendar", unit: nil), + // Live Activity / Watch + .init(key: "useLiveActivity", name: "Use Live Activity", category: "Features", subcategory: "Live Activity", unit: nil), + .init(key: "lockScreenView", name: "Lock Screen View", category: "Features", subcategory: "Live Activity", unit: nil), + .init(key: "smartStackView", name: "Smart Stack View", category: "Features", subcategory: "Live Activity", unit: nil), + .init(key: "bolusShortcut", name: "Bolus Shortcut", category: "Features", subcategory: "Watch", unit: nil), + // UI + .init(key: "eA1cDisplayUnit", name: "eA1c Display Unit", category: "Features", subcategory: "Display", unit: nil), + .init(key: "glucoseBadge", name: "Glucose Badge", category: "Features", subcategory: "Display", unit: nil), + .init(key: "displayPresets", name: "Display Presets", category: "Features", subcategory: "Display", unit: nil), + .init(key: "overrideFactor", name: "Override Factor", category: "Algorithm", subcategory: "Overrides", unit: nil), + .init(key: "timeInRangeType", name: "Time in Range Type", category: "Features", subcategory: "Display", unit: nil), + // Garmin + .init(key: "garminWatchface", name: "Garmin Watchface", category: "Devices", subcategory: "Garmin", unit: nil), + .init(key: "garminDatafield", name: "Garmin Datafield", category: "Devices", subcategory: "Garmin", unit: nil), + .init(key: "primaryAttributeChoice", name: "Garmin Primary Attribute", category: "Devices", subcategory: "Garmin", unit: nil), + .init(key: "secondaryAttributeChoice", name: "Garmin Secondary Attribute", category: "Devices", subcategory: "Garmin", unit: nil), + .init(key: "isWatchfaceDataEnabled", name: "Garmin Data Enabled", category: "Devices", subcategory: "Garmin", unit: nil), + // Glucose Source + .init(key: "useLocalGlucoseSource", name: "Use Local Glucose Source", category: "Devices", subcategory: "CGM", unit: nil), + .init(key: "localGlucosePort", name: "Local Glucose Port", category: "Devices", subcategory: "CGM", unit: nil), + ] + + private static let preferencesMetadata: [SettingMetadata] = [ + // IOB / Safety + .init(key: "maxIOB", name: "Max IOB", category: "Algorithm", subcategory: "Safety", unit: "U"), + .init(key: "maxDailySafetyMultiplier", name: "Max Daily Safety Multiplier", category: "Algorithm", subcategory: "Safety", unit: nil), + .init(key: "currentBasalSafetyMultiplier", name: "Current Basal Safety Multiplier", category: "Algorithm", subcategory: "Safety", unit: nil), + // Autosens + .init(key: "autosensMax", name: "Autosens Max", category: "Algorithm", subcategory: "Autosens", unit: nil), + .init(key: "autosensMin", name: "Autosens Min", category: "Algorithm", subcategory: "Autosens", unit: nil), + // SMB + .init(key: "smbDeliveryRatio", name: "SMB Delivery Ratio", category: "Algorithm", subcategory: "SMB", unit: nil), + .init(key: "enableSMBWithCOB", name: "Enable SMB with COB", category: "Algorithm", subcategory: "SMB", unit: nil), + .init(key: "enableSMBWithTemptarget", name: "Enable SMB with Temp Target", category: "Algorithm", subcategory: "SMB", unit: nil), + .init(key: "enableSMBAlways", name: "Enable SMB Always", category: "Algorithm", subcategory: "SMB", unit: nil), + .init(key: "enableSMBAfterCarbs", name: "Enable SMB After Carbs", category: "Algorithm", subcategory: "SMB", unit: nil), + .init(key: "allowSMBWithHighTemptarget", name: "Allow SMB with High Temp Target", category: "Algorithm", subcategory: "SMB", unit: nil), + .init(key: "maxSMBBasalMinutes", name: "Max SMB Basal Minutes", category: "Algorithm", subcategory: "SMB", unit: "min"), + .init(key: "maxUAMSMBBasalMinutes", name: "Max UAM SMB Basal Minutes", category: "Algorithm", subcategory: "SMB", unit: "min"), + .init(key: "smbInterval", name: "SMB Interval", category: "Algorithm", subcategory: "SMB", unit: "min"), + .init(key: "enableSMB_high_bg", name: "Enable SMB at High BG", category: "Algorithm", subcategory: "SMB", unit: nil), + .init(key: "enableSMB_high_bg_target", name: "SMB High BG Target", category: "Algorithm", subcategory: "SMB", unit: "mg/dL"), + // Targets + .init(key: "highTemptargetRaisesSensitivity", name: "High Temp Target Raises Sensitivity", category: "Algorithm", subcategory: "Target Behavior", unit: nil), + .init(key: "lowTemptargetLowersSensitivity", name: "Low Temp Target Lowers Sensitivity", category: "Algorithm", subcategory: "Target Behavior", unit: nil), + .init(key: "sensitivityRaisesTarget", name: "Sensitivity Raises Target", category: "Algorithm", subcategory: "Target Behavior", unit: nil), + .init(key: "resistanceLowersTarget", name: "Resistance Lowers Target", category: "Algorithm", subcategory: "Target Behavior", unit: nil), + .init(key: "advTargetAdjustments", name: "Advanced Target Adjustments", category: "Algorithm", subcategory: "Target Behavior", unit: nil), + .init(key: "halfBasalExerciseTarget", name: "Half Basal Exercise Target", category: "Algorithm", subcategory: "Target Behavior", unit: "mg/dL"), + .init(key: "exerciseMode", name: "Exercise Mode", category: "Algorithm", subcategory: "Target Behavior", unit: nil), + .init(key: "wideBGTargetRange", name: "Wide BG Target Range", category: "Algorithm", subcategory: "Target Behavior", unit: nil), + // Carbs + .init(key: "maxCOB", name: "Max COB", category: "Algorithm", subcategory: "Carbs", unit: "g"), + .init(key: "maxMealAbsorptionTime", name: "Max Meal Absorption Time", category: "Algorithm", subcategory: "Carbs", unit: "h"), + .init(key: "min5mCarbimpact", name: "Min 5m Carb Impact", category: "Algorithm", subcategory: "Carbs", unit: nil), + .init(key: "remainingCarbsFraction", name: "Remaining Carbs Fraction", category: "Algorithm", subcategory: "Carbs", unit: nil), + .init(key: "remainingCarbsCap", name: "Remaining Carbs Cap", category: "Algorithm", subcategory: "Carbs", unit: "g"), + .init(key: "carbsReqThreshold", name: "Carbs Required Threshold", category: "Algorithm", subcategory: "Carbs", unit: "g"), + // Insulin + .init(key: "curve", name: "Insulin Curve", category: "Algorithm", subcategory: "Insulin", unit: nil), + .init(key: "useCustomPeakTime", name: "Use Custom Peak Time", category: "Algorithm", subcategory: "Insulin", unit: nil), + .init(key: "insulinPeakTime", name: "Insulin Peak Time", category: "Algorithm", subcategory: "Insulin", unit: "min"), + .init(key: "bolusIncrement", name: "Bolus Increment", category: "Algorithm", subcategory: "Insulin", unit: "U"), + // Dynamic ISF + .init(key: "sigmoid", name: "Sigmoid", category: "Algorithm", subcategory: "Dynamic ISF", unit: nil), + .init(key: "useNewFormula", name: "Use New Formula", category: "Algorithm", subcategory: "Dynamic ISF", unit: nil), + .init(key: "useWeightedAverage", name: "Use Weighted Average", category: "Algorithm", subcategory: "Dynamic ISF", unit: nil), + .init(key: "weightPercentage", name: "Weight Percentage", category: "Algorithm", subcategory: "Dynamic ISF", unit: nil), + .init(key: "tddAdjBasal", name: "TDD Adjust Basal", category: "Algorithm", subcategory: "Dynamic ISF", unit: nil), + .init(key: "adjustmentFactor", name: "Adjustment Factor", category: "Algorithm", subcategory: "Dynamic ISF", unit: nil), + .init(key: "adjustmentFactorSigmoid", name: "Adjustment Factor Sigmoid", category: "Algorithm", subcategory: "Dynamic ISF", unit: nil), + // Other + .init(key: "rewindResetsAutosens", name: "Rewind Resets Autosens", category: "Algorithm", subcategory: "Autosens", unit: nil), + .init(key: "enableUAM", name: "Enable UAM", category: "Algorithm", subcategory: "SMB", unit: nil), + .init(key: "a52RiskEnable", name: "A52 Risk Enable", category: "Algorithm", subcategory: "Safety", unit: nil), + .init(key: "noisyCGMTargetMultiplier", name: "Noisy CGM Target Multiplier", category: "Algorithm", subcategory: "Safety", unit: nil), + .init(key: "suspendZerosIOB", name: "Suspend Zeros IOB", category: "Algorithm", subcategory: "Safety", unit: nil), + .init(key: "skipNeutralTemps", name: "Skip Neutral Temps", category: "Algorithm", subcategory: "Basal", unit: nil), + .init(key: "unsuspendIfNoTemp", name: "Unsuspend If No Temp", category: "Algorithm", subcategory: "Basal", unit: nil), + .init(key: "maxDeltaBGthreshold", name: "Max Delta BG Threshold", category: "Algorithm", subcategory: "Safety", unit: nil), + .init(key: "threshold_setting", name: "Threshold Setting", category: "Algorithm", subcategory: "Safety", unit: "mg/dL"), + .init(key: "updateInterval", name: "Update Interval", category: "Algorithm", subcategory: "General", unit: "min"), + ] +}