From aac7da2b6ac619421c90428a87a5745fa101e969 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 08:57:00 +0000 Subject: [PATCH 01/29] Add Settings Audit Log feature - Add SettingsChangeStored Core Data entity with fetch indexes - Add SettingsChangeStored CoreData class and properties files - Add SettingsAuditStorage protocol and BaseSettingsAuditStorage implementation - Register SettingsAuditStorage in StorageAssembly - Add SettingsMetadataRegistry mapping setting keys to human-readable metadata - Integrate audit logging in BaseSettingsManager for TrioSettings and Preferences changes - Add audit logging to BasalProfileEditor, ISFEditor, CarbRatioEditor, TargetsEditor providers - Add SettingsAuditLog module (DataFlow, Provider, StateModel, RootView) - Add settingsAuditLog screen case to Router - Add 'Settings Change Log' entry point in SettingsRootView Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- .../SettingsChangeStored+CoreDataClass.swift | 4 + ...tingsChangeStored+CoreDataProperties.swift | 22 ++ .../contents | 22 ++ .../APS/Storage/SettingsAuditStorage.swift | 127 +++++++++++ Trio/Sources/Assemblies/StorageAssembly.swift | 1 + .../BasalProfileEditorProvider.swift | 22 ++ .../CarbRatioEditorProvider.swift | 22 ++ .../Modules/ISFEditor/ISFEditorProvider.swift | 22 ++ .../Settings/View/SettingsRootView.swift | 8 + .../SettingsAuditLogDataFlow.swift | 5 + .../SettingsAuditLogProvider.swift | 8 + .../SettingsAuditLogStateModel.swift | 87 +++++++ .../View/SettingsAuditLogRootView.swift | 212 ++++++++++++++++++ .../TargetsEditor/TargetsEditorProvider.swift | 22 ++ Trio/Sources/Router/Screen.swift | 4 +- .../SettingsManager/SettingsManager.swift | 65 ++++++ .../SettingsMetadataRegistry.swift | 169 ++++++++++++++ 17 files changed, 821 insertions(+), 1 deletion(-) create mode 100644 Model/Classes+Properties/SettingsChangeStored+CoreDataClass.swift create mode 100644 Model/Classes+Properties/SettingsChangeStored+CoreDataProperties.swift create mode 100644 Trio/Sources/APS/Storage/SettingsAuditStorage.swift create mode 100644 Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogDataFlow.swift create mode 100644 Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogProvider.swift create mode 100644 Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift create mode 100644 Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift create mode 100644 Trio/Sources/Services/SettingsManager/SettingsMetadataRegistry.swift 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..f0864e1ad01 --- /dev/null +++ b/Model/Classes+Properties/SettingsChangeStored+CoreDataProperties.swift @@ -0,0 +1,22 @@ +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? +} + +extension SettingsChangeStored: Identifiable {} diff --git a/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents b/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents index 332c3373a55..1c94674cd9c 100644 --- a/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents +++ b/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents @@ -247,6 +247,28 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Trio/Sources/APS/Storage/SettingsAuditStorage.swift b/Trio/Sources/APS/Storage/SettingsAuditStorage.swift new file mode 100644 index 00000000000..b3aa314c376 --- /dev/null +++ b/Trio/Sources/APS/Storage/SettingsAuditStorage.swift @@ -0,0 +1,127 @@ +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] + func updateNote(for entryId: UUID, note: String) + func deleteOldEntries(olderThan date: Date) +} + +final class BaseSettingsAuditStorage: SettingsAuditStorage, Injectable { + private let viewContext = CoreDataStack.shared.persistentContainer.viewContext + private let backgroundContext = CoreDataStack.shared.newTaskContext() + + init(resolver: Resolver) { + injectServices(resolver) + } + + 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 } + + 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 + + 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 updateNote(for entryId: UUID, note: String) { + backgroundContext.perform { [weak self] in + guard let self else { return } + let request = SettingsChangeStored.fetchRequest() + request.predicate = NSPredicate(format: "id == %@", entryId as CVarArg) + request.fetchLimit = 1 + if let entry = (try? self.backgroundContext.fetch(request))?.first { + 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() + } + } +} 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/BasalProfileEditor/BasalProfileEditorProvider.swift b/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift index d5154202eff..5725a5ad8e2 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,7 @@ extension BasalProfileEditor { switch result { case .success: self.storage.save(profile, as: OpenAPS.Settings.basalProfile) + self.logBasalChange(old: oldProfile, new: profile) promise(.success(())) case let .failure(error): promise(.failure(error)) @@ -38,5 +43,22 @@ extension BasalProfileEditor { } }.eraseToAnyPublisher() } + + private func logBasalChange(old: [BasalProfileEntry], new: [BasalProfileEntry]) { + let oldStr = old.map { "\($0.start): \($0.rate) U/hr" }.joined(separator: ", ") + let newStr = new.map { "\($0.start): \($0.rate) U/hr" }.joined(separator: ", ") + guard oldStr != newStr else { return } + auditStorage.logChange( + category: "Therapy", + subcategory: "Basal Rates", + settingName: "Basal Profile", + settingKey: "therapy.basalProfile", + oldValue: oldStr.isEmpty ? "(empty)" : oldStr, + newValue: newStr.isEmpty ? "(empty)" : newStr, + unit: "U/hr", + note: nil, + source: "manual" + ) + } } } diff --git a/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorProvider.swift b/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorProvider.swift index a5991cc437b..0a51c580bfb 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,26 @@ extension CarbRatioEditor { } func saveProfile(_ profile: CarbRatios) { + let old = self.profile storage.save(profile, as: OpenAPS.Settings.carbRatios) + logCRChange(old: old, new: profile) + } + + private func logCRChange(old: CarbRatios, new: CarbRatios) { + let oldStr = old.schedule.map { "\($0.start): \($0.ratio) g/U" }.joined(separator: ", ") + let newStr = new.schedule.map { "\($0.start): \($0.ratio) g/U" }.joined(separator: ", ") + guard oldStr != newStr else { return } + auditStorage.logChange( + category: "Therapy", + subcategory: "Carb Ratio", + settingName: "Carb Ratio Profile", + settingKey: "therapy.carbRatios", + oldValue: oldStr.isEmpty ? "(empty)" : oldStr, + newValue: newStr.isEmpty ? "(empty)" : newStr, + unit: "g/U", + note: nil, + source: "manual" + ) } } } diff --git a/Trio/Sources/Modules/ISFEditor/ISFEditorProvider.swift b/Trio/Sources/Modules/ISFEditor/ISFEditorProvider.swift index 6a4a7b6bc5d..b321e8911f9 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,26 @@ extension ISFEditor { } func saveProfile(_ profile: InsulinSensitivities) { + let old = self.profile storage.save(profile, as: OpenAPS.Settings.insulinSensitivities) + logISFChange(old: old, new: profile) + } + + private func logISFChange(old: InsulinSensitivities, new: InsulinSensitivities) { + let oldStr = old.sensitivities.map { "\($0.start): \($0.sensitivity) mg/dL/U" }.joined(separator: ", ") + let newStr = new.sensitivities.map { "\($0.start): \($0.sensitivity) mg/dL/U" }.joined(separator: ", ") + guard oldStr != newStr else { return } + auditStorage.logChange( + category: "Therapy", + subcategory: "Insulin Sensitivity Factor", + settingName: "ISF Profile", + settingKey: "therapy.insulinSensitivities", + oldValue: oldStr.isEmpty ? "(empty)" : oldStr, + newValue: newStr.isEmpty ? "(empty)" : newStr, + unit: "mg/dL/U", + note: nil, + source: "manual" + ) } } } 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..7011a5fb2c3 --- /dev/null +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift @@ -0,0 +1,87 @@ +import CoreData +import Foundation +import Observation +import SwiftUI + +extension SettingsAuditLog { + @Observable final class StateModel: BaseStateModel { + var searchText: String = "" + var selectedCategory: String? = nil + var entries: [SettingsChangeStored] = [] + var isLoadingMore: Bool = false + var hasMore: Bool = true + + private let pageSize = 50 + private var currentOffset = 0 + + let viewContext = CoreDataStack.shared.persistentContainer.viewContext + + var allCategories: [String] { + var cats = Set() + for entry in entries { + if let cat = entry.category { cats.insert(cat) } + } + return ["All"] + cats.sorted() + } + + override func subscribe() { + loadInitial() + } + + func loadInitial() { + currentOffset = 0 + hasMore = true + entries = [] + loadMore() + } + + func loadMore() { + guard hasMore, !isLoadingMore else { return } + isLoadingMore = true + let loaded = provider.auditStorage.fetchHistory( + category: selectedCategory == "All" ? nil : selectedCategory, + since: nil, + limit: pageSize + currentOffset + ) + entries = loaded + hasMore = loaded.count >= pageSize + currentOffset + currentOffset += pageSize + isLoadingMore = false + } + + func updateNote(for entry: SettingsChangeStored, note: String) { + guard let id = entry.id else { return } + provider.auditStorage.updateNote(for: id, note: note) + loadInitial() + } + + var filteredEntries: [SettingsChangeStored] { + guard !searchText.isEmpty else { return entries } + let lower = searchText.lowercased() + return entries.filter { + ($0.settingName?.lowercased().contains(lower) ?? false) || + ($0.category?.lowercased().contains(lower) ?? false) || + ($0.oldValue?.lowercased().contains(lower) ?? false) || + ($0.newValue?.lowercased().contains(lower) ?? false) || + ($0.note?.lowercased().contains(lower) ?? false) + } + } + + var groupedEntries: [(String, [SettingsChangeStored])] { + let df = DateFormatter() + df.dateStyle = .medium + df.timeStyle = .none + let grouped = Dictionary(grouping: filteredEntries) { entry -> String in + guard let date = entry.date else { return "Unknown" } + return df.string(from: date) + } + return grouped.sorted { a, b in + let dfParse = DateFormatter() + dfParse.dateStyle = .medium + let dateA = dfParse.date(from: a.key) ?? .distantPast + let dateB = dfParse.date(from: b.key) ?? .distantPast + return dateA > dateB + } + } + } +} diff --git a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift new file mode 100644 index 00000000000..52cd94bf2ce --- /dev/null +++ b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift @@ -0,0 +1,212 @@ +import CoreData +import SwiftUI +import Swinject + +extension SettingsAuditLog { + struct RootView: BaseView { + let resolver: Resolver + @State var state = StateModel() + + @Environment(\.colorScheme) var colorScheme + @Environment(AppState.self) var appState + + @State private var selectedEntry: SettingsChangeStored? + @State private var showNoteEditor = false + @State private var noteText = "" + + var body: some View { + List { + categoryPicker + + if state.groupedEntries.isEmpty { + Section { + Text("No settings changes recorded yet.") + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding() + } + .listRowBackground(Color.chart) + } + + ForEach(state.groupedEntries, id: \.0) { day, dayEntries in + Section(header: Text(day)) { + ForEach(dayEntries, id: \.objectID) { entry in + EntryRow(entry: entry) + .contentShape(Rectangle()) + .onTapGesture { + selectedEntry = entry + noteText = entry.note ?? "" + showNoteEditor = true + } + } + } + .listRowBackground(Color.chart) + } + + if state.hasMore { + Section { + Button("Load More") { + state.loadMore() + } + .frame(maxWidth: .infinity, alignment: .center) + } + .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) + .sheet(isPresented: $showNoteEditor) { + noteEditorSheet + } + } + + @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.loadInitial() + } 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) + } + + @ViewBuilder + private var noteEditorSheet: some View { + if let entry = selectedEntry { + NavigationView { + EntryDetailView(entry: entry, noteText: $noteText) { + state.updateNote(for: entry, note: noteText) + showNoteEditor = false + } + } + } + } + } +} + +// MARK: - EntryRow + +private struct EntryRow: View { + let entry: SettingsChangeStored + + private var timeString: String { + guard let date = entry.date else { return "" } + let df = DateFormatter() + df.timeStyle = .short + return df.string(from: date) + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(entry.settingName ?? "Unknown Setting") + .font(.subheadline) + .fontWeight(.medium) + Text(entry.subcategory ?? entry.category ?? "") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text(timeString) + .font(.caption) + .foregroundColor(.secondary) + if entry.note?.isEmpty == false { + Image(systemName: "note.text") + .font(.caption) + .foregroundColor(.accentColor) + } + } + } + HStack(spacing: 4) { + Text(entry.oldValue ?? "—") + .font(.caption) + .foregroundColor(.red) + .lineLimit(1) + Image(systemName: "arrow.right") + .font(.caption2) + .foregroundColor(.secondary) + Text(entry.newValue ?? "—") + .font(.caption) + .foregroundColor(.green) + .lineLimit(1) + if let unit = entry.unit, !unit.isEmpty { + Text(unit) + .font(.caption2) + .foregroundColor(.secondary) + } + } + } + .padding(.vertical, 2) + } +} + +// MARK: - EntryDetailView + +private struct EntryDetailView: View { + let entry: SettingsChangeStored + @Binding var noteText: String + let onSave: () -> Void + + @Environment(\.dismiss) var dismiss + + private var formattedDate: String { + guard let date = entry.date else { return "—" } + let df = DateFormatter() + df.dateStyle = .medium + df.timeStyle = .short + return df.string(from: date) + } + + var body: some View { + Form { + Section("Setting") { + LabeledContent("Name", value: entry.settingName ?? "—") + LabeledContent("Category", value: entry.category ?? "—") + LabeledContent("Subcategory", value: entry.subcategory ?? "—") + LabeledContent("Date", value: formattedDate) + LabeledContent("Source", value: entry.source ?? "—") + } + Section("Change") { + LabeledContent("Old Value", value: "\(entry.oldValue ?? "—")\(entry.unit.map { " \($0)" } ?? "")") + LabeledContent("New Value", value: "\(entry.newValue ?? "—")\(entry.unit.map { " \($0)" } ?? "")") + } + Section("Note") { + TextEditor(text: $noteText) + .frame(minHeight: 80) + } + } + .navigationTitle("Change Details") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Save") { onSave() } + } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } +} diff --git a/Trio/Sources/Modules/TargetsEditor/TargetsEditorProvider.swift b/Trio/Sources/Modules/TargetsEditor/TargetsEditorProvider.swift index d8499302795..a267c823644 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,26 @@ extension TargetsEditor { } func saveProfile(_ profile: BGTargets) { + let old = self.profile storage.save(profile, as: OpenAPS.Settings.bgTargets) + logTargetsChange(old: old, new: profile) + } + + private func logTargetsChange(old: BGTargets, new: BGTargets) { + let oldStr = old.targets.map { "\($0.start): \($0.low)-\($0.high) mg/dL" }.joined(separator: ", ") + let newStr = new.targets.map { "\($0.start): \($0.low)-\($0.high) mg/dL" }.joined(separator: ", ") + guard oldStr != newStr else { return } + auditStorage.logChange( + category: "Therapy", + subcategory: "BG Targets", + settingName: "BG Target Profile", + settingKey: "therapy.bgTargets", + oldValue: oldStr.isEmpty ? "(empty)" : oldStr, + newValue: newStr.isEmpty ? "(empty)" : newStr, + unit: "mg/dL", + note: nil, + source: "manual" + ) } } } diff --git a/Trio/Sources/Router/Screen.swift b/Trio/Sources/Router/Screen.swift index 1bfc27857f1..ce79c0b9e0d 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,8 +166,9 @@ extension Screen { AppDiagnostics.RootView(resolver: resolver) case .settingsExport: SettingsExport.RootView(resolver: resolver) + case .settingsAuditLog: + SettingsAuditLog.RootView(resolver: resolver) } - } func modal(resolver: Resolver) -> Main.Modal { .init(screen: self, view: view(resolver: resolver).asAny()) diff --git a/Trio/Sources/Services/SettingsManager/SettingsManager.swift b/Trio/Sources/Services/SettingsManager/SettingsManager.swift index c2d3779cf98..bee9a532145 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,66 @@ final class BaseSettingsManager: SettingsManager, Injectable { preferences = prefs savePreferences() } + + // MARK: - Change capture helpers + + private func logSettingsChanges(old: TrioSettings, new: TrioSettings) { + 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 = SettingsMetadataRegistry.trioSettingsMap[label] + auditStorage.logChange( + category: meta?.category ?? "Settings", + subcategory: meta?.subcategory ?? "General", + settingName: meta?.name ?? label, + settingKey: "settings.\(label)", + oldValue: oldVal, + newValue: newVal, + unit: meta?.unit, + note: nil, + source: "manual" + ) + } + } + + private func logPreferencesChanges(old: Preferences, new: Preferences) { + 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 = SettingsMetadataRegistry.preferencesMap[label] + auditStorage.logChange( + category: meta?.category ?? "Algorithm", + subcategory: meta?.subcategory ?? "General", + settingName: meta?.name ?? label, + settingKey: "preferences.\(label)", + oldValue: oldVal, + newValue: newVal, + unit: meta?.unit, + note: nil, + source: "manual" + ) + } + } } diff --git a/Trio/Sources/Services/SettingsManager/SettingsMetadataRegistry.swift b/Trio/Sources/Services/SettingsManager/SettingsMetadataRegistry.swift new file mode 100644 index 00000000000..d65ba42cce0 --- /dev/null +++ b/Trio/Sources/Services/SettingsManager/SettingsMetadataRegistry.swift @@ -0,0 +1,169 @@ +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: "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"), + ] +} From 31ceba3fd1547635e5521b5b74d8664e836462af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 08:58:00 +0000 Subject: [PATCH 02/29] Fix DateFormatter reuse in groupedEntries Use a single DateFormatter for both grouping and sorting to avoid repeated instantiation inside the sort closure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- .../SettingsAuditLog/SettingsAuditLogStateModel.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift index 7011a5fb2c3..406c16f8ef0 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift @@ -71,15 +71,15 @@ extension SettingsAuditLog { let df = DateFormatter() df.dateStyle = .medium df.timeStyle = .none + let grouped = Dictionary(grouping: filteredEntries) { entry -> String in guard let date = entry.date else { return "Unknown" } return df.string(from: date) } + return grouped.sorted { a, b in - let dfParse = DateFormatter() - dfParse.dateStyle = .medium - let dateA = dfParse.date(from: a.key) ?? .distantPast - let dateB = dfParse.date(from: b.key) ?? .distantPast + let dateA = df.date(from: a.key) ?? .distantPast + let dateB = df.date(from: b.key) ?? .distantPast return dateA > dateB } } From 90dfa9640399f27bacf883e4ed681141b28c7222 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 08:59:13 +0000 Subject: [PATCH 03/29] Use static DateFormatters to avoid repeated expensive initialization All DateFormatter instances in SettingsAuditLog views and StateModel are now static properties, created once and reused across calls and rows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- .../SettingsAuditLogStateModel.swift | 13 +++++++----- .../View/SettingsAuditLogRootView.swift | 20 +++++++++++++------ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift index 406c16f8ef0..2aee544bb5c 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift @@ -16,6 +16,13 @@ extension SettingsAuditLog { let viewContext = CoreDataStack.shared.persistentContainer.viewContext + private static let groupingFormatter: DateFormatter = { + let df = DateFormatter() + df.dateStyle = .medium + df.timeStyle = .none + return df + }() + var allCategories: [String] { var cats = Set() for entry in entries { @@ -68,15 +75,11 @@ extension SettingsAuditLog { } var groupedEntries: [(String, [SettingsChangeStored])] { - let df = DateFormatter() - df.dateStyle = .medium - df.timeStyle = .none - + let df = Self.groupingFormatter let grouped = Dictionary(grouping: filteredEntries) { entry -> String in guard let date = entry.date else { return "Unknown" } return df.string(from: date) } - return grouped.sorted { a, b in let dateA = df.date(from: a.key) ?? .distantPast let dateB = df.date(from: b.key) ?? .distantPast diff --git a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift index 52cd94bf2ce..27489e885cd 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift @@ -110,11 +110,15 @@ extension SettingsAuditLog { private struct EntryRow: View { let entry: SettingsChangeStored - private var timeString: String { - guard let date = entry.date else { return "" } + private static let timeFormatter: DateFormatter = { let df = DateFormatter() df.timeStyle = .short - return df.string(from: date) + return df + }() + + private var timeString: String { + guard let date = entry.date else { return "" } + return Self.timeFormatter.string(from: date) } var body: some View { @@ -172,12 +176,16 @@ private struct EntryDetailView: View { @Environment(\.dismiss) var dismiss - private var formattedDate: String { - guard let date = entry.date else { return "—" } + private static let detailFormatter: DateFormatter = { let df = DateFormatter() df.dateStyle = .medium df.timeStyle = .short - return df.string(from: date) + return df + }() + + private var formattedDate: String { + guard let date = entry.date else { return "—" } + return Self.detailFormatter.string(from: date) } var body: some View { From 2e6b7b99e24895047de2bc3c570d2ba2022123f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:05:00 +0000 Subject: [PATCH 04/29] fix: correct provider recursion and simplify pagination in settings audit log Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/83fd0a12-2bfd-499b-a3d6-7c04a4b43529 Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- .../CarbRatioEditorProvider.swift | 3 +- .../Modules/ISFEditor/ISFEditorProvider.swift | 3 +- .../SettingsAuditLogStateModel.swift | 60 +++++++++---------- .../View/SettingsAuditLogRootView.swift | 11 +--- .../TargetsEditor/TargetsEditorProvider.swift | 3 +- 5 files changed, 35 insertions(+), 45 deletions(-) diff --git a/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorProvider.swift b/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorProvider.swift index 0a51c580bfb..58eb9de8646 100644 --- a/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorProvider.swift +++ b/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorProvider.swift @@ -12,7 +12,8 @@ extension CarbRatioEditor { } func saveProfile(_ profile: CarbRatios) { - let old = self.profile + let old = storage.retrieve(OpenAPS.Settings.carbRatios, as: CarbRatios.self) + ?? CarbRatios(units: .grams, schedule: []) storage.save(profile, as: OpenAPS.Settings.carbRatios) logCRChange(old: old, new: profile) } diff --git a/Trio/Sources/Modules/ISFEditor/ISFEditorProvider.swift b/Trio/Sources/Modules/ISFEditor/ISFEditorProvider.swift index b321e8911f9..35beda6ce04 100644 --- a/Trio/Sources/Modules/ISFEditor/ISFEditorProvider.swift +++ b/Trio/Sources/Modules/ISFEditor/ISFEditorProvider.swift @@ -35,7 +35,8 @@ extension ISFEditor { } func saveProfile(_ profile: InsulinSensitivities) { - let old = self.profile + let old = storage.retrieve(OpenAPS.Settings.insulinSensitivities, as: InsulinSensitivities.self) + ?? InsulinSensitivities(units: .mgdL, userPreferredUnits: .mgdL, sensitivities: []) storage.save(profile, as: OpenAPS.Settings.insulinSensitivities) logISFChange(old: old, new: profile) } diff --git a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift index 2aee544bb5c..2ec1c4bc3ba 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift @@ -8,13 +8,8 @@ extension SettingsAuditLog { var searchText: String = "" var selectedCategory: String? = nil var entries: [SettingsChangeStored] = [] - var isLoadingMore: Bool = false - var hasMore: Bool = true - private let pageSize = 50 - private var currentOffset = 0 - - let viewContext = CoreDataStack.shared.persistentContainer.viewContext + private static let groupingCalendar: Calendar = .current private static let groupingFormatter: DateFormatter = { let df = DateFormatter() @@ -23,6 +18,7 @@ extension SettingsAuditLog { return df }() + /// Distinct categories from loaded entries. Recomputed only when entries change. var allCategories: [String] { var cats = Set() for entry in entries { @@ -32,34 +28,21 @@ extension SettingsAuditLog { } override func subscribe() { - loadInitial() - } - - func loadInitial() { - currentOffset = 0 - hasMore = true - entries = [] - loadMore() + loadEntries() } - func loadMore() { - guard hasMore, !isLoadingMore else { return } - isLoadingMore = true - let loaded = provider.auditStorage.fetchHistory( + func loadEntries() { + entries = provider.auditStorage.fetchHistory( category: selectedCategory == "All" ? nil : selectedCategory, since: nil, - limit: pageSize + currentOffset + limit: 500 ) - entries = loaded - hasMore = loaded.count >= pageSize + currentOffset - currentOffset += pageSize - isLoadingMore = false } func updateNote(for entry: SettingsChangeStored, note: String) { guard let id = entry.id else { return } provider.auditStorage.updateNote(for: id, note: note) - loadInitial() + loadEntries() } var filteredEntries: [SettingsChangeStored] { @@ -75,16 +58,29 @@ extension SettingsAuditLog { } var groupedEntries: [(String, [SettingsChangeStored])] { + let cal = Self.groupingCalendar let df = Self.groupingFormatter - let grouped = Dictionary(grouping: filteredEntries) { entry -> String in - guard let date = entry.date else { return "Unknown" } - return df.string(from: date) - } - return grouped.sorted { a, b in - let dateA = df.date(from: a.key) ?? .distantPast - let dateB = df.date(from: b.key) ?? .distantPast - return dateA > dateB + // Group by (year, month, day) components to avoid parsing formatted strings for sorting + let grouped = Dictionary(grouping: filteredEntries) { entry -> DateComponents in + guard let date = entry.date else { return DateComponents() } + return cal.dateComponents([.year, .month, .day], from: date) } + return grouped + .sorted { a, b in + // Sort descending by date components + let aDate = cal.date(from: a.key) ?? .distantPast + let bDate = cal.date(from: b.key) ?? .distantPast + return aDate > bDate + } + .map { components, dayEntries in + let label: String + if let date = cal.date(from: components) { + label = df.string(from: date) + } else { + label = "Unknown" + } + return (label, dayEntries) + } } } } diff --git a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift index 27489e885cd..2b491e06a40 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift @@ -43,15 +43,6 @@ extension SettingsAuditLog { .listRowBackground(Color.chart) } - if state.hasMore { - Section { - Button("Load More") { - state.loadMore() - } - .frame(maxWidth: .infinity, alignment: .center) - } - .listRowBackground(Color.chart) - } } .scrollContentBackground(.hidden) .background(appState.trioBackgroundColor(for: colorScheme)) @@ -73,7 +64,7 @@ extension SettingsAuditLog { let isSelected = (state.selectedCategory ?? "All") == cat Button { state.selectedCategory = cat == "All" ? nil : cat - state.loadInitial() + state.loadEntries() } label: { Text(cat) .font(.caption) diff --git a/Trio/Sources/Modules/TargetsEditor/TargetsEditorProvider.swift b/Trio/Sources/Modules/TargetsEditor/TargetsEditorProvider.swift index a267c823644..76a99ffa9ad 100644 --- a/Trio/Sources/Modules/TargetsEditor/TargetsEditorProvider.swift +++ b/Trio/Sources/Modules/TargetsEditor/TargetsEditorProvider.swift @@ -28,7 +28,8 @@ extension TargetsEditor { } func saveProfile(_ profile: BGTargets) { - let old = self.profile + let old = storage.retrieve(OpenAPS.Settings.bgTargets, as: BGTargets.self) + ?? BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: []) storage.save(profile, as: OpenAPS.Settings.bgTargets) logTargetsChange(old: old, new: profile) } From 4ddcf4d6c7cbbfdb4dd1d9dac970e184f395d564 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:19:37 +0000 Subject: [PATCH 05/29] fix: add missing closing brace for view(resolver:) in Screen.swift Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/bc9ab337-4348-4e19-9c72-5532cfb86585 Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- Trio/Sources/Router/Screen.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Trio/Sources/Router/Screen.swift b/Trio/Sources/Router/Screen.swift index ce79c0b9e0d..cc68c319b84 100644 --- a/Trio/Sources/Router/Screen.swift +++ b/Trio/Sources/Router/Screen.swift @@ -169,6 +169,7 @@ extension Screen { case .settingsAuditLog: SettingsAuditLog.RootView(resolver: resolver) } + } func modal(resolver: Resolver) -> Main.Modal { .init(screen: self, view: view(resolver: resolver).asAny()) From 86573b234219630c3033b4bcc329b72c514f9f13 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:34:01 +0000 Subject: [PATCH 06/29] fix: add missing settings audit log files to Xcode project Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/42190838-9695-4873-8f22-2930c04fcc88 Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- Trio.xcodeproj/project.pbxproj | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 5d96561bc8c..1643f11f491 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 */ @@ -4067,6 +4107,15 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 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 */, + 8A91342C2D63D9A2007F8874 /* InfoPlist.xcstrings in Resources */, CE1F6DE72BAF1A180064EB8D /* BuildDetails.plist in Resources */, 3B3B57C92DA07B3400849D16 /* GoogleService-Info.plist in Resources */, From 9dcdf8a37d2bab0391320fcf2855406c87b49228 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:53:32 +0000 Subject: [PATCH 07/29] fix: move source files from PBXResourcesBuildPhase to PBXSourcesBuildPhase and fix indentation Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/3ec37fd3-d9a8-454c-a053-287c2b5da009 Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- Trio.xcodeproj/project.pbxproj | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 1643f11f491..2a0b24e3bb0 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -1967,7 +1967,7 @@ C2C98283C436DB934D7E7994 /* Treatments */, 190EBCC229FF134900BA767D /* UserInterfaceSettings */, CE94597C29E9E1CD0047C9C6 /* WatchConfig */, - 711F34EA2FAD42E58BB8DCF6 /* SettingsAuditLog */, + 711F34EA2FAD42E58BB8DCF6 /* SettingsAuditLog */, ); path = Modules; sourceTree = ""; @@ -2543,7 +2543,7 @@ BDC2EA442C3043B000E5BBD0 /* OverrideStorage.swift */, 5864E8582C42CFAE00294306 /* DeterminationStorage.swift */, BD4D73A12D15A4220052227B /* TDDStorage.swift */, - 8AAA3132E8B54247814312BF /* SettingsAuditStorage.swift */, + 8AAA3132E8B54247814312BF /* SettingsAuditStorage.swift */, ); path = Storage; sourceTree = ""; @@ -2565,7 +2565,7 @@ isa = PBXGroup; children = ( 38AEE75125F022080013F05B /* SettingsManager.swift */, - 1E99596B1BB84920872C57F3 /* SettingsMetadataRegistry.swift */, + 1E99596B1BB84920872C57F3 /* SettingsMetadataRegistry.swift */, ); path = SettingsManager; sourceTree = ""; @@ -3688,8 +3688,8 @@ 491D6FBA2D56741C00C49F67 /* TempTargetRunStored+CoreDataProperties.swift */, 491D6FBB2D56741C00C49F67 /* TempTargetStored+CoreDataClass.swift */, 491D6FBC2D56741C00C49F67 /* TempTargetStored+CoreDataProperties.swift */, - EAD0682F55104692AB78F945 /* SettingsChangeStored+CoreDataClass.swift */, - 0E890DBA44C94C3EBFB05B4F /* SettingsChangeStored+CoreDataProperties.swift */, + EAD0682F55104692AB78F945 /* SettingsChangeStored+CoreDataClass.swift */, + 0E890DBA44C94C3EBFB05B4F /* SettingsChangeStored+CoreDataProperties.swift */, ); path = "Classes+Properties"; sourceTree = ""; @@ -4107,15 +4107,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 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 */, - 8A91342C2D63D9A2007F8874 /* InfoPlist.xcstrings in Resources */, CE1F6DE72BAF1A180064EB8D /* BuildDetails.plist in Resources */, 3B3B57C92DA07B3400849D16 /* GoogleService-Info.plist in Resources */, @@ -4841,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; }; From 5e5648abe150a6b776c44c974faa0ff8013ef1e3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:55:40 +0000 Subject: [PATCH 08/29] refactor: DRY - extract shared therapy audit logging and generic Mirror-based diff Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/3ec37fd3-d9a8-454c-a053-287c2b5da009 Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- .../APS/Storage/SettingsAuditStorage.swift | 37 ++++++++++++ .../BasalProfileEditorProvider.swift | 26 +++------ .../CarbRatioEditorProvider.swift | 18 ++---- .../Modules/ISFEditor/ISFEditorProvider.swift | 18 ++---- .../TargetsEditor/TargetsEditorProvider.swift | 18 ++---- .../SettingsManager/SettingsManager.swift | 58 +++++++++---------- 6 files changed, 84 insertions(+), 91 deletions(-) diff --git a/Trio/Sources/APS/Storage/SettingsAuditStorage.swift b/Trio/Sources/APS/Storage/SettingsAuditStorage.swift index b3aa314c376..86a7e062a86 100644 --- a/Trio/Sources/APS/Storage/SettingsAuditStorage.swift +++ b/Trio/Sources/APS/Storage/SettingsAuditStorage.swift @@ -18,6 +18,43 @@ protocol SettingsAuditStorage: AnyObject { func fetchHistory(category: String?, since: Date?, limit: Int) -> [SettingsChangeStored] func updateNote(for entryId: UUID, note: String) func deleteOldEntries(olderThan date: Date) + + /// 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 { diff --git a/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift b/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift index 5725a5ad8e2..ead2484856f 100644 --- a/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift +++ b/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorProvider.swift @@ -35,7 +35,14 @@ extension BasalProfileEditor { switch result { case .success: self.storage.save(profile, as: OpenAPS.Settings.basalProfile) - self.logBasalChange(old: oldProfile, new: profile) + 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)) @@ -43,22 +50,5 @@ extension BasalProfileEditor { } }.eraseToAnyPublisher() } - - private func logBasalChange(old: [BasalProfileEntry], new: [BasalProfileEntry]) { - let oldStr = old.map { "\($0.start): \($0.rate) U/hr" }.joined(separator: ", ") - let newStr = new.map { "\($0.start): \($0.rate) U/hr" }.joined(separator: ", ") - guard oldStr != newStr else { return } - auditStorage.logChange( - category: "Therapy", - subcategory: "Basal Rates", - settingName: "Basal Profile", - settingKey: "therapy.basalProfile", - oldValue: oldStr.isEmpty ? "(empty)" : oldStr, - newValue: newStr.isEmpty ? "(empty)" : newStr, - unit: "U/hr", - note: nil, - source: "manual" - ) - } } } diff --git a/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorProvider.swift b/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorProvider.swift index 58eb9de8646..1767c8a9773 100644 --- a/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorProvider.swift +++ b/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorProvider.swift @@ -15,23 +15,13 @@ extension CarbRatioEditor { let old = storage.retrieve(OpenAPS.Settings.carbRatios, as: CarbRatios.self) ?? CarbRatios(units: .grams, schedule: []) storage.save(profile, as: OpenAPS.Settings.carbRatios) - logCRChange(old: old, new: profile) - } - - private func logCRChange(old: CarbRatios, new: CarbRatios) { - let oldStr = old.schedule.map { "\($0.start): \($0.ratio) g/U" }.joined(separator: ", ") - let newStr = new.schedule.map { "\($0.start): \($0.ratio) g/U" }.joined(separator: ", ") - guard oldStr != newStr else { return } - auditStorage.logChange( - category: "Therapy", + auditStorage.logTherapyProfileChange( subcategory: "Carb Ratio", settingName: "Carb Ratio Profile", settingKey: "therapy.carbRatios", - oldValue: oldStr.isEmpty ? "(empty)" : oldStr, - newValue: newStr.isEmpty ? "(empty)" : newStr, - unit: "g/U", - note: nil, - source: "manual" + 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 35beda6ce04..670f3b0d4f0 100644 --- a/Trio/Sources/Modules/ISFEditor/ISFEditorProvider.swift +++ b/Trio/Sources/Modules/ISFEditor/ISFEditorProvider.swift @@ -38,23 +38,13 @@ extension ISFEditor { let old = storage.retrieve(OpenAPS.Settings.insulinSensitivities, as: InsulinSensitivities.self) ?? InsulinSensitivities(units: .mgdL, userPreferredUnits: .mgdL, sensitivities: []) storage.save(profile, as: OpenAPS.Settings.insulinSensitivities) - logISFChange(old: old, new: profile) - } - - private func logISFChange(old: InsulinSensitivities, new: InsulinSensitivities) { - let oldStr = old.sensitivities.map { "\($0.start): \($0.sensitivity) mg/dL/U" }.joined(separator: ", ") - let newStr = new.sensitivities.map { "\($0.start): \($0.sensitivity) mg/dL/U" }.joined(separator: ", ") - guard oldStr != newStr else { return } - auditStorage.logChange( - category: "Therapy", + auditStorage.logTherapyProfileChange( subcategory: "Insulin Sensitivity Factor", settingName: "ISF Profile", settingKey: "therapy.insulinSensitivities", - oldValue: oldStr.isEmpty ? "(empty)" : oldStr, - newValue: newStr.isEmpty ? "(empty)" : newStr, - unit: "mg/dL/U", - note: nil, - source: "manual" + 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/TargetsEditor/TargetsEditorProvider.swift b/Trio/Sources/Modules/TargetsEditor/TargetsEditorProvider.swift index 76a99ffa9ad..6af8c63b04e 100644 --- a/Trio/Sources/Modules/TargetsEditor/TargetsEditorProvider.swift +++ b/Trio/Sources/Modules/TargetsEditor/TargetsEditorProvider.swift @@ -31,23 +31,13 @@ extension TargetsEditor { let old = storage.retrieve(OpenAPS.Settings.bgTargets, as: BGTargets.self) ?? BGTargets(units: .mgdL, userPreferredUnits: .mgdL, targets: []) storage.save(profile, as: OpenAPS.Settings.bgTargets) - logTargetsChange(old: old, new: profile) - } - - private func logTargetsChange(old: BGTargets, new: BGTargets) { - let oldStr = old.targets.map { "\($0.start): \($0.low)-\($0.high) mg/dL" }.joined(separator: ", ") - let newStr = new.targets.map { "\($0.start): \($0.low)-\($0.high) mg/dL" }.joined(separator: ", ") - guard oldStr != newStr else { return } - auditStorage.logChange( - category: "Therapy", + auditStorage.logTherapyProfileChange( subcategory: "BG Targets", settingName: "BG Target Profile", settingKey: "therapy.bgTargets", - oldValue: oldStr.isEmpty ? "(empty)" : oldStr, - newValue: newStr.isEmpty ? "(empty)" : newStr, - unit: "mg/dL", - note: nil, - source: "manual" + 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/Services/SettingsManager/SettingsManager.swift b/Trio/Sources/Services/SettingsManager/SettingsManager.swift index bee9a532145..bc6105728d2 100644 --- a/Trio/Sources/Services/SettingsManager/SettingsManager.swift +++ b/Trio/Sources/Services/SettingsManager/SettingsManager.swift @@ -100,7 +100,13 @@ final class BaseSettingsManager: SettingsManager, Injectable { // MARK: - Change capture helpers - private func logSettingsChanges(old: TrioSettings, new: TrioSettings) { + private func logMirrorChanges( + old: T, + new: T, + registryMap: [String: SettingMetadata], + keyPrefix: String, + fallbackCategory: String + ) { let mirror = Mirror(reflecting: new) let oldMirror = Mirror(reflecting: old) @@ -115,12 +121,12 @@ final class BaseSettingsManager: SettingsManager, Injectable { let oldVal = oldDict[label] ?? "" guard oldVal != newVal else { continue } - let meta = SettingsMetadataRegistry.trioSettingsMap[label] + let meta = registryMap[label] auditStorage.logChange( - category: meta?.category ?? "Settings", + category: meta?.category ?? fallbackCategory, subcategory: meta?.subcategory ?? "General", settingName: meta?.name ?? label, - settingKey: "settings.\(label)", + settingKey: "\(keyPrefix).\(label)", oldValue: oldVal, newValue: newVal, unit: meta?.unit, @@ -130,33 +136,23 @@ final class BaseSettingsManager: SettingsManager, Injectable { } } - private func logPreferencesChanges(old: Preferences, new: Preferences) { - 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 } + private func logSettingsChanges(old: TrioSettings, new: TrioSettings) { + logMirrorChanges( + old: old, + new: new, + registryMap: SettingsMetadataRegistry.trioSettingsMap, + keyPrefix: "settings", + fallbackCategory: "Settings" + ) + } - let meta = SettingsMetadataRegistry.preferencesMap[label] - auditStorage.logChange( - category: meta?.category ?? "Algorithm", - subcategory: meta?.subcategory ?? "General", - settingName: meta?.name ?? label, - settingKey: "preferences.\(label)", - oldValue: oldVal, - newValue: newVal, - unit: meta?.unit, - note: nil, - source: "manual" - ) - } + private func logPreferencesChanges(old: Preferences, new: Preferences) { + logMirrorChanges( + old: old, + new: new, + registryMap: SettingsMetadataRegistry.preferencesMap, + keyPrefix: "preferences", + fallbackCategory: "Algorithm" + ) } } From 503b8cbab837fe2694ead2680cb1eb3291144216 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:56:49 +0000 Subject: [PATCH 09/29] fix: remove dead selectedCategory == "All" check in loadEntries Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/3ec37fd3-d9a8-454c-a053-287c2b5da009 Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- .../Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift index 2ec1c4bc3ba..df2542ccbaf 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift @@ -33,7 +33,7 @@ extension SettingsAuditLog { func loadEntries() { entries = provider.auditStorage.fetchHistory( - category: selectedCategory == "All" ? nil : selectedCategory, + category: selectedCategory, since: nil, limit: 500 ) From 066be2bab9e46fd6449dc9f9b6149662b8806758 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:11:01 +0000 Subject: [PATCH 10/29] fix: quote paths containing + in PBXFileReference entries for SettingsChangeStored files Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/8e054e2e-c2c5-4e1e-81c1-bd02b42a2f15 Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- Trio.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 2a0b24e3bb0..5d6303f1f48 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -1594,8 +1594,8 @@ 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 = ""; }; + 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 = ""; }; From de6793e62fb40a2daa343c02710f29635ccc8405 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:38:05 +0000 Subject: [PATCH 11/29] feat: 10-minute grouping for settings change events with shared notes and track cgmPluginIdentifier Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/74b2250e-7323-4364-b102-bfa47164b7cf Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- ...tingsChangeStored+CoreDataProperties.swift | 1 + .../contents | 4 + .../APS/Storage/SettingsAuditStorage.swift | 34 ++++- .../SettingsAuditLogStateModel.swift | 57 ++++++-- .../View/SettingsAuditLogRootView.swift | 126 +++++++++++------- .../SettingsMetadataRegistry.swift | 1 + 6 files changed, 158 insertions(+), 65 deletions(-) diff --git a/Model/Classes+Properties/SettingsChangeStored+CoreDataProperties.swift b/Model/Classes+Properties/SettingsChangeStored+CoreDataProperties.swift index f0864e1ad01..e035e559b22 100644 --- a/Model/Classes+Properties/SettingsChangeStored+CoreDataProperties.swift +++ b/Model/Classes+Properties/SettingsChangeStored+CoreDataProperties.swift @@ -17,6 +17,7 @@ public extension SettingsChangeStored { @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 1c94674cd9c..a2a2929a15c 100644 --- a/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents +++ b/Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents @@ -259,6 +259,7 @@ + @@ -268,6 +269,9 @@ + + + diff --git a/Trio/Sources/APS/Storage/SettingsAuditStorage.swift b/Trio/Sources/APS/Storage/SettingsAuditStorage.swift index 86a7e062a86..9a17a3c1952 100644 --- a/Trio/Sources/APS/Storage/SettingsAuditStorage.swift +++ b/Trio/Sources/APS/Storage/SettingsAuditStorage.swift @@ -16,7 +16,7 @@ protocol SettingsAuditStorage: AnyObject { ) func fetchHistory(for settingKey: String?, limit: Int, offset: Int) -> [SettingsChangeStored] func fetchHistory(category: String?, since: Date?, limit: Int) -> [SettingsChangeStored] - func updateNote(for entryId: UUID, note: String) + func updateNote(forGroup groupId: UUID, note: String) func deleteOldEntries(olderThan date: Date) /// Convenience for logging therapy profile changes (Basal, ISF, CR, BG Targets). @@ -61,10 +61,28 @@ 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)? + init(resolver: Resolver) { injectServices(resolver) } + /// 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 { + 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, @@ -78,6 +96,8 @@ final class BaseSettingsAuditStorage: SettingsAuditStorage, Injectable { ) { guard oldValue != newValue else { return } + let groupId = resolveGroupId() + backgroundContext.perform { [weak self] in guard let self else { return } let entry = SettingsChangeStored(context: self.backgroundContext) @@ -92,6 +112,7 @@ final class BaseSettingsAuditStorage: SettingsAuditStorage, Injectable { entry.unit = unit entry.note = note entry.source = source + entry.groupId = groupId do { try self.backgroundContext.save() @@ -138,14 +159,15 @@ final class BaseSettingsAuditStorage: SettingsAuditStorage, Injectable { return result } - func updateNote(for entryId: UUID, note: String) { + 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: "id == %@", entryId as CVarArg) - request.fetchLimit = 1 - if let entry = (try? self.backgroundContext.fetch(request))?.first { - entry.note = note + 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() } } diff --git a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift index df2542ccbaf..6f80bea4f12 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift @@ -4,6 +4,28 @@ import Observation import SwiftUI extension SettingsAuditLog { + /// 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: [SettingsChangeStored] + 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.compactMap(\.category)) + return cats.sorted() + } + } + @Observable final class StateModel: BaseStateModel { var searchText: String = "" var selectedCategory: String? = nil @@ -39,9 +61,8 @@ extension SettingsAuditLog { ) } - func updateNote(for entry: SettingsChangeStored, note: String) { - guard let id = entry.id else { return } - provider.auditStorage.updateNote(for: id, note: note) + func updateNote(forGroup groupId: UUID, note: String) { + provider.auditStorage.updateNote(forGroup: groupId, note: note) loadEntries() } @@ -57,29 +78,41 @@ extension SettingsAuditLog { } } - var groupedEntries: [(String, [SettingsChangeStored])] { + /// Groups filtered entries by `groupId` into `ChangeEvent`s, then groups those by day. + var groupedEvents: [(String, [ChangeEvent])] { let cal = Self.groupingCalendar let df = Self.groupingFormatter - // Group by (year, month, day) components to avoid parsing formatted strings for sorting - let grouped = Dictionary(grouping: filteredEntries) { entry -> DateComponents in - guard let date = entry.date else { return DateComponents() } - return cal.dateComponents([.year, .month, .day], from: date) + + // Build ChangeEvents from groupId + let byGroup = Dictionary(grouping: filteredEntries) { entry -> UUID in + entry.groupId ?? (entry.id ?? UUID()) + } + let events: [ChangeEvent] = byGroup.map { groupId, groupEntries in + let sorted = groupEntries.sorted { ($0.date ?? .distantPast) > ($1.date ?? .distantPast) } + let date = sorted.first?.date ?? .distantPast + let note = sorted.first?.note ?? "" + return ChangeEvent(id: groupId, date: date, entries: sorted, note: note) + } + + // Group events by day + let byDay = Dictionary(grouping: events) { event -> DateComponents in + cal.dateComponents([.year, .month, .day], from: event.date) } - return grouped + return byDay .sorted { a, b in - // Sort descending by date components let aDate = cal.date(from: a.key) ?? .distantPast let bDate = cal.date(from: b.key) ?? .distantPast return aDate > bDate } - .map { components, dayEntries in + .map { components, dayEvents in let label: String if let date = cal.date(from: components) { label = df.string(from: date) } else { label = "Unknown" } - return (label, dayEntries) + let sorted = dayEvents.sorted { $0.date > $1.date } + return (label, sorted) } } } diff --git a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift index 2b491e06a40..1b420a1bf32 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift @@ -10,7 +10,7 @@ extension SettingsAuditLog { @Environment(\.colorScheme) var colorScheme @Environment(AppState.self) var appState - @State private var selectedEntry: SettingsChangeStored? + @State private var selectedEvent: ChangeEvent? @State private var showNoteEditor = false @State private var noteText = "" @@ -18,7 +18,7 @@ extension SettingsAuditLog { List { categoryPicker - if state.groupedEntries.isEmpty { + if state.groupedEvents.isEmpty { Section { Text("No settings changes recorded yet.") .foregroundColor(.secondary) @@ -28,21 +28,20 @@ extension SettingsAuditLog { .listRowBackground(Color.chart) } - ForEach(state.groupedEntries, id: \.0) { day, dayEntries in + ForEach(state.groupedEvents, id: \.0) { day, dayEvents in Section(header: Text(day)) { - ForEach(dayEntries, id: \.objectID) { entry in - EntryRow(entry: entry) + ForEach(dayEvents) { event in + EventRow(event: event) .contentShape(Rectangle()) .onTapGesture { - selectedEntry = entry - noteText = entry.note ?? "" + selectedEvent = event + noteText = event.note showNoteEditor = true } } } .listRowBackground(Color.chart) } - } .scrollContentBackground(.hidden) .background(appState.trioBackgroundColor(for: colorScheme)) @@ -84,10 +83,10 @@ extension SettingsAuditLog { @ViewBuilder private var noteEditorSheet: some View { - if let entry = selectedEntry { + if let event = selectedEvent { NavigationView { - EntryDetailView(entry: entry, noteText: $noteText) { - state.updateNote(for: entry, note: noteText) + EventDetailView(event: event, noteText: $noteText) { + state.updateNote(forGroup: event.id, note: noteText) showNoteEditor = false } } @@ -96,10 +95,10 @@ extension SettingsAuditLog { } } -// MARK: - EntryRow +// MARK: - EventRow -private struct EntryRow: View { - let entry: SettingsChangeStored +private struct EventRow: View { + let event: SettingsAuditLog.ChangeEvent private static let timeFormatter: DateFormatter = { let df = DateFormatter() @@ -108,18 +107,17 @@ private struct EntryRow: View { }() private var timeString: String { - guard let date = entry.date else { return "" } - return Self.timeFormatter.string(from: date) + Self.timeFormatter.string(from: event.date) } var body: some View { VStack(alignment: .leading, spacing: 4) { HStack { VStack(alignment: .leading, spacing: 2) { - Text(entry.settingName ?? "Unknown Setting") + Text(event.summaryLabel) .font(.subheadline) .fontWeight(.medium) - Text(entry.subcategory ?? entry.category ?? "") + Text(event.categories.joined(separator: ", ")) .font(.caption) .foregroundColor(.secondary) } @@ -128,29 +126,37 @@ private struct EntryRow: View { Text(timeString) .font(.caption) .foregroundColor(.secondary) - if entry.note?.isEmpty == false { + if !event.note.isEmpty { Image(systemName: "note.text") .font(.caption) .foregroundColor(.accentColor) } } } - HStack(spacing: 4) { - Text(entry.oldValue ?? "—") - .font(.caption) - .foregroundColor(.red) - .lineLimit(1) - Image(systemName: "arrow.right") - .font(.caption2) - .foregroundColor(.secondary) - Text(entry.newValue ?? "—") - .font(.caption) - .foregroundColor(.green) - .lineLimit(1) - if let unit = entry.unit, !unit.isEmpty { - Text(unit) + + ForEach(event.entries, id: \.objectID) { entry in + HStack(spacing: 4) { + Text(entry.settingName ?? "—") + .font(.caption2) + .foregroundColor(.primary) + .lineLimit(1) + Spacer() + Text(entry.oldValue ?? "—") .font(.caption2) + .foregroundColor(.red) + .lineLimit(1) + Image(systemName: "arrow.right") + .font(.system(size: 8)) .foregroundColor(.secondary) + Text(entry.newValue ?? "—") + .font(.caption2) + .foregroundColor(.green) + .lineLimit(1) + if let unit = entry.unit, !unit.isEmpty { + Text(unit) + .font(.system(size: 9)) + .foregroundColor(.secondary) + } } } } @@ -158,10 +164,10 @@ private struct EntryRow: View { } } -// MARK: - EntryDetailView +// MARK: - EventDetailView -private struct EntryDetailView: View { - let entry: SettingsChangeStored +private struct EventDetailView: View { + let event: SettingsAuditLog.ChangeEvent @Binding var noteText: String let onSave: () -> Void @@ -175,29 +181,55 @@ private struct EntryDetailView: View { }() private var formattedDate: String { - guard let date = entry.date else { return "—" } - return Self.detailFormatter.string(from: date) + Self.detailFormatter.string(from: event.date) } var body: some View { Form { - Section("Setting") { - LabeledContent("Name", value: entry.settingName ?? "—") - LabeledContent("Category", value: entry.category ?? "—") - LabeledContent("Subcategory", value: entry.subcategory ?? "—") + Section("Event") { LabeledContent("Date", value: formattedDate) - LabeledContent("Source", value: entry.source ?? "—") + LabeledContent("Changes", value: "\(event.entries.count)") + LabeledContent("Categories", value: event.categories.joined(separator: ", ")) } - Section("Change") { - LabeledContent("Old Value", value: "\(entry.oldValue ?? "—")\(entry.unit.map { " \($0)" } ?? "")") - LabeledContent("New Value", value: "\(entry.newValue ?? "—")\(entry.unit.map { " \($0)" } ?? "")") + Section("Changes") { + ForEach(event.entries, id: \.objectID) { entry in + VStack(alignment: .leading, spacing: 2) { + Text(entry.settingName ?? "—") + .font(.subheadline) + .fontWeight(.medium) + HStack(spacing: 4) { + Text(entry.oldValue ?? "—") + .font(.caption) + .foregroundColor(.red) + .lineLimit(1) + Image(systemName: "arrow.right") + .font(.caption2) + .foregroundColor(.secondary) + Text(entry.newValue ?? "—") + .font(.caption) + .foregroundColor(.green) + .lineLimit(1) + if let unit = entry.unit, !unit.isEmpty { + Text(unit) + .font(.caption2) + .foregroundColor(.secondary) + } + } + if let subcategory = entry.subcategory { + Text(subcategory) + .font(.caption2) + .foregroundColor(.secondary) + } + } + .padding(.vertical, 2) + } } Section("Note") { TextEditor(text: $noteText) .frame(minHeight: 80) } } - .navigationTitle("Change Details") + .navigationTitle("Change Event") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { diff --git a/Trio/Sources/Services/SettingsManager/SettingsMetadataRegistry.swift b/Trio/Sources/Services/SettingsManager/SettingsMetadataRegistry.swift index d65ba42cce0..54e0eddd956 100644 --- a/Trio/Sources/Services/SettingsManager/SettingsMetadataRegistry.swift +++ b/Trio/Sources/Services/SettingsManager/SettingsMetadataRegistry.swift @@ -32,6 +32,7 @@ enum SettingsMetadataRegistry { .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 From ecabc8bdbbe7dc56836b1a73124f80b52fe3b16c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:39:30 +0000 Subject: [PATCH 12/29] fix: thread safety for group resolution and stable UUID fallback Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/74b2250e-7323-4364-b102-bfa47164b7cf Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- Trio/Sources/APS/Storage/SettingsAuditStorage.swift | 5 +++++ .../SettingsAuditLog/SettingsAuditLogStateModel.swift | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Trio/Sources/APS/Storage/SettingsAuditStorage.swift b/Trio/Sources/APS/Storage/SettingsAuditStorage.swift index 9a17a3c1952..0e3cabcbe76 100644 --- a/Trio/Sources/APS/Storage/SettingsAuditStorage.swift +++ b/Trio/Sources/APS/Storage/SettingsAuditStorage.swift @@ -68,6 +68,9 @@ final class BaseSettingsAuditStorage: SettingsAuditStorage, Injectable { /// 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) } @@ -75,6 +78,8 @@ final class BaseSettingsAuditStorage: SettingsAuditStorage, Injectable { /// 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 } diff --git a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift index 6f80bea4f12..3511812ef32 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift @@ -85,7 +85,7 @@ extension SettingsAuditLog { // Build ChangeEvents from groupId let byGroup = Dictionary(grouping: filteredEntries) { entry -> UUID in - entry.groupId ?? (entry.id ?? UUID()) + entry.groupId ?? entry.id ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")! } let events: [ChangeEvent] = byGroup.map { groupId, groupEntries in let sorted = groupEntries.sorted { ($0.date ?? .distantPast) > ($1.date ?? .distantPast) } From 3cebf5806bfd2c8e90d92ee2ab1d2baa947e5690 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:13:21 +0000 Subject: [PATCH 13/29] fix: crash when opening settings audit log - use plain structs instead of Core Data managed objects in UI Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/de91fd5c-b81a-418a-8fd6-cd718d2c177d Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- .../SettingsAuditLogStateModel.swift | 65 ++++++++++++++----- .../View/SettingsAuditLogRootView.swift | 21 +++--- 2 files changed, 58 insertions(+), 28 deletions(-) diff --git a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift index 3511812ef32..7d8b457c0c6 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift @@ -1,14 +1,45 @@ -import CoreData 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 { + 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(from stored: SettingsChangeStored) { + id = stored.id ?? UUID() + date = stored.date ?? .distantPast + category = stored.category ?? "" + subcategory = stored.subcategory ?? "" + settingName = stored.settingName ?? "" + settingKey = stored.settingKey ?? "" + oldValue = stored.oldValue ?? "" + newValue = stored.newValue ?? "" + unit = stored.unit + note = stored.note ?? "" + source = stored.source ?? "manual" + groupId = stored.groupId ?? stored.id ?? UUID() + } + } + /// 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: [SettingsChangeStored] + let entries: [ChangeEntry] let note: String /// Summary label, e.g. "3 settings changed" or the single setting name. @@ -21,7 +52,7 @@ extension SettingsAuditLog { /// Distinct categories across all entries. var categories: [String] { - let cats = Set(entries.compactMap(\.category)) + let cats = Set(entries.map(\.category)).filter { !$0.isEmpty } return cats.sorted() } } @@ -29,7 +60,7 @@ extension SettingsAuditLog { @Observable final class StateModel: BaseStateModel { var searchText: String = "" var selectedCategory: String? = nil - var entries: [SettingsChangeStored] = [] + var entries: [ChangeEntry] = [] private static let groupingCalendar: Calendar = .current @@ -40,11 +71,11 @@ extension SettingsAuditLog { return df }() - /// Distinct categories from loaded entries. Recomputed only when entries change. + /// Distinct categories from loaded entries. var allCategories: [String] { var cats = Set() for entry in entries { - if let cat = entry.category { cats.insert(cat) } + if !entry.category.isEmpty { cats.insert(entry.category) } } return ["All"] + cats.sorted() } @@ -54,11 +85,13 @@ extension SettingsAuditLog { } func loadEntries() { - entries = provider.auditStorage.fetchHistory( + let stored = provider.auditStorage.fetchHistory( category: selectedCategory, since: nil, limit: 500 ) + // Convert managed objects → value types immediately, inside the fetch context + entries = stored.map { ChangeEntry(from: $0) } } func updateNote(forGroup groupId: UUID, note: String) { @@ -66,15 +99,15 @@ extension SettingsAuditLog { loadEntries() } - var filteredEntries: [SettingsChangeStored] { + var filteredEntries: [ChangeEntry] { guard !searchText.isEmpty else { return entries } let lower = searchText.lowercased() return entries.filter { - ($0.settingName?.lowercased().contains(lower) ?? false) || - ($0.category?.lowercased().contains(lower) ?? false) || - ($0.oldValue?.lowercased().contains(lower) ?? false) || - ($0.newValue?.lowercased().contains(lower) ?? false) || - ($0.note?.lowercased().contains(lower) ?? false) + $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) } } @@ -84,11 +117,9 @@ extension SettingsAuditLog { let df = Self.groupingFormatter // Build ChangeEvents from groupId - let byGroup = Dictionary(grouping: filteredEntries) { entry -> UUID in - entry.groupId ?? entry.id ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")! - } + let byGroup = Dictionary(grouping: filteredEntries, by: \.groupId) let events: [ChangeEvent] = byGroup.map { groupId, groupEntries in - let sorted = groupEntries.sorted { ($0.date ?? .distantPast) > ($1.date ?? .distantPast) } + 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) diff --git a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift index 1b420a1bf32..21b0e68f337 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift @@ -1,4 +1,3 @@ -import CoreData import SwiftUI import Swinject @@ -134,21 +133,21 @@ private struct EventRow: View { } } - ForEach(event.entries, id: \.objectID) { entry in + ForEach(event.entries) { entry in HStack(spacing: 4) { - Text(entry.settingName ?? "—") + Text(entry.settingName) .font(.caption2) .foregroundColor(.primary) .lineLimit(1) Spacer() - Text(entry.oldValue ?? "—") + Text(entry.oldValue) .font(.caption2) .foregroundColor(.red) .lineLimit(1) Image(systemName: "arrow.right") .font(.system(size: 8)) .foregroundColor(.secondary) - Text(entry.newValue ?? "—") + Text(entry.newValue) .font(.caption2) .foregroundColor(.green) .lineLimit(1) @@ -192,20 +191,20 @@ private struct EventDetailView: View { LabeledContent("Categories", value: event.categories.joined(separator: ", ")) } Section("Changes") { - ForEach(event.entries, id: \.objectID) { entry in + ForEach(event.entries) { entry in VStack(alignment: .leading, spacing: 2) { - Text(entry.settingName ?? "—") + Text(entry.settingName) .font(.subheadline) .fontWeight(.medium) HStack(spacing: 4) { - Text(entry.oldValue ?? "—") + Text(entry.oldValue) .font(.caption) .foregroundColor(.red) .lineLimit(1) Image(systemName: "arrow.right") .font(.caption2) .foregroundColor(.secondary) - Text(entry.newValue ?? "—") + Text(entry.newValue) .font(.caption) .foregroundColor(.green) .lineLimit(1) @@ -215,8 +214,8 @@ private struct EventDetailView: View { .foregroundColor(.secondary) } } - if let subcategory = entry.subcategory { - Text(subcategory) + if !entry.subcategory.isEmpty { + Text(entry.subcategory) .font(.caption2) .foregroundColor(.secondary) } From d6fb851b9baad292155a5aac5e58e7693f4746aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:14:48 +0000 Subject: [PATCH 14/29] fix: clarify comment about managed object conversion timing Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/de91fd5c-b81a-418a-8fd6-cd718d2c177d Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- .../Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift index 7d8b457c0c6..d2e211bf38f 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift @@ -90,7 +90,7 @@ extension SettingsAuditLog { since: nil, limit: 500 ) - // Convert managed objects → value types immediately, inside the fetch context + // Convert managed objects → value types immediately while they are still valid entries = stored.map { ChangeEntry(from: $0) } } From 44fd15fcf136059c50f8b3cd6ac64859edb63203 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:26:06 +0000 Subject: [PATCH 15/29] fix: empty detail page on tap and add glucose unit conversion in audit log display Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/1d527b1b-5544-4a4b-93ac-abe105731cae Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- .../SettingsAuditLogStateModel.swift | 51 +++++++++++++++++++ .../View/SettingsAuditLogRootView.swift | 45 +++++++--------- 2 files changed, 70 insertions(+), 26 deletions(-) diff --git a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift index d2e211bf38f..09750ee145b 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift @@ -33,6 +33,55 @@ extension SettingsAuditLog { source = stored.source ?? "manual" groupId = stored.groupId ?? stored.id ?? UUID() } + + /// Returns the display string for a value, converting mg/dL → mmol/L when needed. + func displayValue(_ raw: String, units: GlucoseUnits) -> String { + guard unit == "mg/dL", units == .mmolL else { return raw } + return Self.convertGlucoseString(raw, to: units) + } + + /// 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 u == "mg/dL" { return units.rawValue } + return u + } + + /// Converts a string that may contain one or more mg/dL numeric values to the target unit. + /// Handles both single values ("120") and comma-separated lists ("08:00: 100, 12:00: 90"). + private static func convertGlucoseString(_ raw: String, to units: GlucoseUnits) -> String { + guard units == .mmolL else { return raw } + + // Handle comma-separated therapy profile entries (e.g. "08:00: 100, 12:00: 90") + 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" or "08:00: 100" from mg/dL to mmol/L. + private static func convertSingleSegment(_ segment: String, to units: GlucoseUnits) -> String { + // Try to find a numeric portion at the end (possibly after "HH:mm: ") + let trimmed = segment.trimmingCharacters(in: .whitespaces) + + // Pattern: optional time prefix "HH:mm: " followed by a number + if let colonRange = trimmed.range(of: ": ", options: .backwards) { + let prefix = String(trimmed[trimmed.startIndex ..< colonRange.upperBound]) + let numStr = String(trimmed[colonRange.upperBound...]).trimmingCharacters(in: .whitespaces) + if let decimal = Decimal(string: numStr) { + return prefix + decimal.formatted(for: units) + } + return segment + } + + // Plain number + if let decimal = Decimal(string: trimmed) { + return decimal.formatted(for: units) + } + return segment + } } /// A single change event that groups all individual setting changes sharing the same `groupId`. @@ -61,6 +110,7 @@ extension SettingsAuditLog { var searchText: String = "" var selectedCategory: String? = nil var entries: [ChangeEntry] = [] + var units: GlucoseUnits = .mgdL private static let groupingCalendar: Calendar = .current @@ -81,6 +131,7 @@ extension SettingsAuditLog { } override func subscribe() { + units = settingsManager.settings.units loadEntries() } diff --git a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift index 21b0e68f337..668bf320e80 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift @@ -10,7 +10,6 @@ extension SettingsAuditLog { @Environment(AppState.self) var appState @State private var selectedEvent: ChangeEvent? - @State private var showNoteEditor = false @State private var noteText = "" var body: some View { @@ -30,12 +29,11 @@ extension SettingsAuditLog { ForEach(state.groupedEvents, id: \.0) { day, dayEvents in Section(header: Text(day)) { ForEach(dayEvents) { event in - EventRow(event: event) + EventRow(event: event, units: state.units) .contentShape(Rectangle()) .onTapGesture { - selectedEvent = event noteText = event.note - showNoteEditor = true + selectedEvent = event } } } @@ -48,8 +46,13 @@ extension SettingsAuditLog { .navigationBarTitleDisplayMode(.automatic) .searchable(text: $state.searchText, placement: .navigationBarDrawer(displayMode: .automatic)) .onAppear(perform: configureView) - .sheet(isPresented: $showNoteEditor) { - noteEditorSheet + .sheet(item: $selectedEvent) { event in + NavigationView { + EventDetailView(event: event, units: state.units, noteText: $noteText) { + state.updateNote(forGroup: event.id, note: noteText) + selectedEvent = nil + } + } } } @@ -79,18 +82,6 @@ extension SettingsAuditLog { } .listRowBackground(Color.chart) } - - @ViewBuilder - private var noteEditorSheet: some View { - if let event = selectedEvent { - NavigationView { - EventDetailView(event: event, noteText: $noteText) { - state.updateNote(forGroup: event.id, note: noteText) - showNoteEditor = false - } - } - } - } } } @@ -98,6 +89,7 @@ extension SettingsAuditLog { private struct EventRow: View { let event: SettingsAuditLog.ChangeEvent + let units: GlucoseUnits private static let timeFormatter: DateFormatter = { let df = DateFormatter() @@ -140,19 +132,19 @@ private struct EventRow: View { .foregroundColor(.primary) .lineLimit(1) Spacer() - Text(entry.oldValue) + 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.newValue) + Text(entry.displayValue(entry.newValue, units: units)) .font(.caption2) .foregroundColor(.green) .lineLimit(1) - if let unit = entry.unit, !unit.isEmpty { - Text(unit) + if let displayUnit = entry.displayUnit(units: units) { + Text(displayUnit) .font(.system(size: 9)) .foregroundColor(.secondary) } @@ -167,6 +159,7 @@ private struct EventRow: View { private struct EventDetailView: View { let event: SettingsAuditLog.ChangeEvent + let units: GlucoseUnits @Binding var noteText: String let onSave: () -> Void @@ -197,19 +190,19 @@ private struct EventDetailView: View { .font(.subheadline) .fontWeight(.medium) HStack(spacing: 4) { - Text(entry.oldValue) + Text(entry.displayValue(entry.oldValue, units: units)) .font(.caption) .foregroundColor(.red) .lineLimit(1) Image(systemName: "arrow.right") .font(.caption2) .foregroundColor(.secondary) - Text(entry.newValue) + Text(entry.displayValue(entry.newValue, units: units)) .font(.caption) .foregroundColor(.green) .lineLimit(1) - if let unit = entry.unit, !unit.isEmpty { - Text(unit) + if let displayUnit = entry.displayUnit(units: units) { + Text(displayUnit) .font(.caption2) .foregroundColor(.secondary) } From df76ec5ab82cfba1b858cb214f77390ae63e596c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:28:43 +0000 Subject: [PATCH 16/29] fix: handle range formats and mg/dL/U unit in glucose conversion for audit log Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/1d527b1b-5544-4a4b-93ac-abe105731cae Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- .../SettingsAuditLogStateModel.swift | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift index 09750ee145b..ad92c3be6fc 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift @@ -36,23 +36,29 @@ extension SettingsAuditLog { /// Returns the display string for a value, converting mg/dL → mmol/L when needed. func displayValue(_ raw: String, units: GlucoseUnits) -> String { - guard unit == "mg/dL", units == .mmolL else { return raw } + guard units == .mmolL, let u = unit, Self.glucoseConvertibleUnits.contains(u) else { return raw } return Self.convertGlucoseString(raw, to: units) } /// 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 u == "mg/dL" { return units.rawValue } + 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") and comma-separated lists ("08:00: 100, 12:00: 90"). + /// 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 (e.g. "08:00: 100, 12:00: 90") + // Handle comma-separated therapy profile entries if raw.contains(",") { let parts = raw.components(separatedBy: ", ") let converted = parts.map { convertSingleSegment($0, to: units) } @@ -61,24 +67,42 @@ extension SettingsAuditLog { return convertSingleSegment(raw, to: units) } - /// Converts a single segment like "120" or "08:00: 100" from mg/dL to mmol/L. + /// 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 { - // Try to find a numeric portion at the end (possibly after "HH:mm: ") let trimmed = segment.trimmingCharacters(in: .whitespaces) - // Pattern: optional time prefix "HH:mm: " followed by a number + // 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) { - let prefix = String(trimmed[trimmed.startIndex ..< colonRange.upperBound]) - let numStr = String(trimmed[colonRange.upperBound...]).trimmingCharacters(in: .whitespaces) - if let decimal = Decimal(string: numStr) { - return prefix + decimal.formatted(for: units) + 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) } - return segment } // Plain number - if let decimal = Decimal(string: trimmed) { - return decimal.formatted(for: units) + if let decimal = Decimal(string: stripped) { + return prefix + decimal.formatted(for: units) } return segment } From cd57ceeb72772d1b69bdc0a806c299f059305ad4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:41:35 +0000 Subject: [PATCH 17/29] feat: show notes on overview, auto-save notes, fix crash, show daily basal total Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/a962aa11-adf2-4c0c-b304-46964ea50f72 Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- .../APS/Storage/SettingsAuditStorage.swift | 2 + .../SettingsAuditLogStateModel.swift | 107 +++++++++++++++- .../View/SettingsAuditLogRootView.swift | 114 +++++++++++++----- 3 files changed, 193 insertions(+), 30 deletions(-) diff --git a/Trio/Sources/APS/Storage/SettingsAuditStorage.swift b/Trio/Sources/APS/Storage/SettingsAuditStorage.swift index 0e3cabcbe76..fdf3e32c6d2 100644 --- a/Trio/Sources/APS/Storage/SettingsAuditStorage.swift +++ b/Trio/Sources/APS/Storage/SettingsAuditStorage.swift @@ -73,6 +73,8 @@ final class BaseSettingsAuditStorage: SettingsAuditStorage, Injectable { 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 diff --git a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift index ad92c3be6fc..924e3bff276 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift @@ -19,6 +19,34 @@ extension SettingsAuditLog { 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 + } + init(from stored: SettingsChangeStored) { id = stored.id ?? UUID() date = stored.date ?? .distantPast @@ -40,6 +68,65 @@ extension SettingsAuditLog { return Self.convertGlucoseString(raw, to: units) } + /// 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" || (unit == "U/hr" && raw.contains(":")) 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: X.X" + guard let colonSpaceRange = trimmed.range(of: ": ") else { continue } + let timeStr = String(trimmed[trimmed.startIndex ..< colonSpaceRange.lowerBound]) + var valueStr = String(trimmed[colonSpaceRange.upperBound...]) + .replacingOccurrences(of: " U/hr", with: "") + .trimmingCharacters(in: .whitespaces) + + // Parse time "HH:mm" → 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 } @@ -171,7 +258,25 @@ extension SettingsAuditLog { func updateNote(forGroup groupId: UUID, note: String) { provider.auditStorage.updateNote(forGroup: groupId, note: note) - loadEntries() + // 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 + ) + } } var filteredEntries: [ChangeEntry] { diff --git a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift index 668bf320e80..06d763aea0c 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift @@ -11,6 +11,7 @@ extension SettingsAuditLog { @State private var selectedEvent: ChangeEvent? @State private var noteText = "" + @State private var debounceTask: Task? var body: some View { List { @@ -48,10 +49,16 @@ extension SettingsAuditLog { .onAppear(perform: configureView) .sheet(item: $selectedEvent) { event in NavigationView { - EventDetailView(event: event, units: state.units, noteText: $noteText) { - state.updateNote(forGroup: event.id, note: noteText) - selectedEvent = nil - } + EventDetailView(event: event, units: state.units, noteText: $noteText) + } + } + .onChange(of: noteText) { _, newValue in + guard let event = selectedEvent else { return } + debounceTask?.cancel() + debounceTask = Task { + try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s debounce + guard !Task.isCancelled else { return } + state.updateNote(forGroup: event.id, note: newValue) } } } @@ -126,30 +133,60 @@ private struct EventRow: View { } ForEach(event.entries) { entry in - 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)) + 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) } @@ -161,7 +198,6 @@ private struct EventDetailView: View { let event: SettingsAuditLog.ChangeEvent let units: GlucoseUnits @Binding var noteText: String - let onSave: () -> Void @Environment(\.dismiss) var dismiss @@ -207,6 +243,24 @@ private struct EventDetailView: View { .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) @@ -219,16 +273,18 @@ private struct EventDetailView: View { 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("Save") { onSave() } - } - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { dismiss() } + Button("Done") { dismiss() } } } } From 8ce3ef83ecc44edb464fa478a9ffb31cf6880659 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:44:31 +0000 Subject: [PATCH 18/29] feat: audit log TT/Override activations and pump changes Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/a962aa11-adf2-4c0c-b304-46964ea50f72 Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- Trio/Sources/APS/DeviceDataManager.swift | 18 +++++ .../AdjustmentsStateModel+Overrides.swift | 66 +++++++++++++++++++ .../AdjustmentsStateModel+TempTargets.swift | 52 +++++++++++++++ .../Adjustments/AdjustmentsStateModel.swift | 1 + 4 files changed, 137 insertions(+) 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/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Overrides.swift b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Overrides.swift index b7948904656..86f7361e869 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 overrideToEnact.overrideTarget, let targetVal = overrideToEnact.target?.decimalValue { + 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 From f607a31f183e818e706cb9f0a0794dd5520a307a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:46:36 +0000 Subject: [PATCH 19/29] fix: restrict daily basal total to therapy.basalProfile key only Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/a962aa11-adf2-4c0c-b304-46964ea50f72 Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- .../Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift index 924e3bff276..e4ae9347efe 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift @@ -71,7 +71,7 @@ extension SettingsAuditLog { /// 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" || (unit == "U/hr" && raw.contains(":")) else { return nil } + guard settingKey == "therapy.basalProfile" else { return nil } guard let total = Self.calculateDailyBasalTotal(from: raw) else { return nil } let nf = NumberFormatter() nf.minimumFractionDigits = 1 @@ -90,7 +90,7 @@ extension SettingsAuditLog { // Expected format: "HH:mm: X.X U/hr" or "HH:mm: X.X" guard let colonSpaceRange = trimmed.range(of: ": ") else { continue } let timeStr = String(trimmed[trimmed.startIndex ..< colonSpaceRange.lowerBound]) - var valueStr = String(trimmed[colonSpaceRange.upperBound...]) + let valueStr = String(trimmed[colonSpaceRange.upperBound...]) .replacingOccurrences(of: " U/hr", with: "") .trimmingCharacters(in: .whitespaces) From 71383ec85576f798e843a39de076a417a97cf9af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:55:05 +0000 Subject: [PATCH 20/29] fix: replace overrideTarget with target nil/zero check on OverrideStored Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/4987cad5-903b-4ede-8d18-7b8bfe883694 Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- .../AdjustmentsStateModel+Overrides.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Overrides.swift b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Overrides.swift index 86f7361e869..3f06f09e367 100644 --- a/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Overrides.swift +++ b/Trio/Sources/Modules/Adjustments/AdjustmentsStateModel+Extensions/AdjustmentsStateModel+Overrides.swift @@ -26,7 +26,7 @@ extension Adjustments.StateModel { let name = overrideToEnact.name ?? "Override" let pct = overrideToEnact.percentage var details = "\(name): \(Int(pct))%" - if overrideToEnact.overrideTarget, let targetVal = overrideToEnact.target?.decimalValue { + 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)" From 81bbca384b916e3fec46f67869d3e86a29723047 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:07:24 +0000 Subject: [PATCH 21/29] fix: prevent use-after-free crash in SettingsAuditLog by cancelling debounceTask on disappear and adding @MainActor to mutation methods Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/76049541-2cf7-4f68-9669-ffce49338803 Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- .../Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift | 4 ++-- .../SettingsAuditLog/View/SettingsAuditLogRootView.swift | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift index e4ae9347efe..09f945ff753 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift @@ -246,7 +246,7 @@ extension SettingsAuditLog { loadEntries() } - func loadEntries() { + @MainActor func loadEntries() { let stored = provider.auditStorage.fetchHistory( category: selectedCategory, since: nil, @@ -256,7 +256,7 @@ extension SettingsAuditLog { entries = stored.map { ChangeEntry(from: $0) } } - func updateNote(forGroup groupId: UUID, note: String) { + @MainActor 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 diff --git a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift index 06d763aea0c..b9e3204a220 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift @@ -47,6 +47,10 @@ extension SettingsAuditLog { .navigationBarTitleDisplayMode(.automatic) .searchable(text: $state.searchText, placement: .navigationBarDrawer(displayMode: .automatic)) .onAppear(perform: configureView) + .onDisappear { + debounceTask?.cancel() + debounceTask = nil + } .sheet(item: $selectedEvent) { event in NavigationView { EventDetailView(event: event, units: state.units, noteText: $noteText) From 0551260859162286932829063717b57a241ff5c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:19:33 +0000 Subject: [PATCH 22/29] fix: remove @MainActor from loadEntries/updateNote to fix compiler error while keeping .onDisappear crash fix Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/ff21c582-932d-42b2-918b-adef0fa9d374 Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- .../Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift index 09f945ff753..e4ae9347efe 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift @@ -246,7 +246,7 @@ extension SettingsAuditLog { loadEntries() } - @MainActor func loadEntries() { + func loadEntries() { let stored = provider.auditStorage.fetchHistory( category: selectedCategory, since: nil, @@ -256,7 +256,7 @@ extension SettingsAuditLog { entries = stored.map { ChangeEntry(from: $0) } } - @MainActor func updateNote(forGroup groupId: UUID, note: String) { + 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 From 04119ffb0ab769b1985ae9ebd58bf1115d851dcb Mon Sep 17 00:00:00 2001 From: Sjoerd Bozon Date: Tue, 7 Apr 2026 14:51:52 +0200 Subject: [PATCH 23/29] fix: prevent use-after-free crash in settingsauditlog statemodel deinit - convert core data managed objects to value types atomically inside performAndWait via new fetchChangeEntries method, preventing faulting/invalidation between fetch and property access - add explicit @MainActor isolation to debounce task to prevent concurrent entries array mutation during view teardown - flush pending note save synchronously on disappear instead of just cancelling the debounce task - add Sendable conformance to ChangeEntry for compile-time safety --- .../APS/Storage/SettingsAuditStorage.swift | 32 +++++++++++++++++++ .../SettingsAuditLogStateModel.swift | 6 ++-- .../View/SettingsAuditLogRootView.swift | 10 ++++-- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/Trio/Sources/APS/Storage/SettingsAuditStorage.swift b/Trio/Sources/APS/Storage/SettingsAuditStorage.swift index fdf3e32c6d2..8f214fc2a36 100644 --- a/Trio/Sources/APS/Storage/SettingsAuditStorage.swift +++ b/Trio/Sources/APS/Storage/SettingsAuditStorage.swift @@ -16,6 +16,9 @@ protocol SettingsAuditStorage: AnyObject { ) 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) @@ -166,6 +169,35 @@ final class BaseSettingsAuditStorage: SettingsAuditStorage, Injectable { 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 } diff --git a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift index e4ae9347efe..108009f7625 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift @@ -5,7 +5,7 @@ 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 { + struct ChangeEntry: Identifiable, Sendable { let id: UUID let date: Date let category: String @@ -247,13 +247,11 @@ extension SettingsAuditLog { } func loadEntries() { - let stored = provider.auditStorage.fetchHistory( + entries = provider.auditStorage.fetchChangeEntries( category: selectedCategory, since: nil, limit: 500 ) - // Convert managed objects → value types immediately while they are still valid - entries = stored.map { ChangeEntry(from: $0) } } func updateNote(forGroup groupId: UUID, note: String) { diff --git a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift index b9e3204a220..b0edadd4a26 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift @@ -48,8 +48,12 @@ extension SettingsAuditLog { .searchable(text: $state.searchText, placement: .navigationBarDrawer(displayMode: .automatic)) .onAppear(perform: configureView) .onDisappear { - debounceTask?.cancel() - debounceTask = nil + // 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: $selectedEvent) { event in NavigationView { @@ -59,7 +63,7 @@ extension SettingsAuditLog { .onChange(of: noteText) { _, newValue in guard let event = selectedEvent else { return } debounceTask?.cancel() - debounceTask = Task { + 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) From b26f6e5af28ae133a0c5a743962e9d6f5c89ad3a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:05:47 +0000 Subject: [PATCH 24/29] feat: add CSV export, time format cleanup (HH:mm), and delete all entries to audit log Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/059f9b5c-000a-4508-a563-4f71ab4f7082 Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- .../APS/Storage/SettingsAuditStorage.swift | 11 +++ .../SettingsAuditLogStateModel.swift | 82 +++++++++++++++++-- .../View/SettingsAuditLogRootView.swift | 72 ++++++++++++++++ 3 files changed, 159 insertions(+), 6 deletions(-) diff --git a/Trio/Sources/APS/Storage/SettingsAuditStorage.swift b/Trio/Sources/APS/Storage/SettingsAuditStorage.swift index 8f214fc2a36..6603fdfb0ec 100644 --- a/Trio/Sources/APS/Storage/SettingsAuditStorage.swift +++ b/Trio/Sources/APS/Storage/SettingsAuditStorage.swift @@ -21,6 +21,7 @@ protocol SettingsAuditStorage: AnyObject { 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`. @@ -222,4 +223,14 @@ final class BaseSettingsAuditStorage: SettingsAuditStorage, Injectable { 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/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift index 108009f7625..a2efb691f77 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift @@ -62,10 +62,22 @@ extension SettingsAuditLog { groupId = stored.groupId ?? stored.id ?? UUID() } - /// Returns the display string for a value, converting mg/dL → mmol/L when needed. + /// 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 { - guard units == .mmolL, let u = unit, Self.glucoseConvertibleUnits.contains(u) else { return raw } - return Self.convertGlucoseString(raw, to: units) + let cleaned = Self.stripTimeSeconds(raw) + guard units == .mmolL, let u = unit, Self.glucoseConvertibleUnits.contains(u) else { return cleaned } + return Self.convertGlucoseString(cleaned, to: units) + } + + /// 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 { + raw.replacingOccurrences( + of: #"(\d{1,2}:\d{2}):\d{2}(: )"#, + with: "$1$2", + options: .regularExpression + ) } /// Returns the daily basal total string (e.g. "18.4 U") for basal profile entries, @@ -87,16 +99,16 @@ extension SettingsAuditLog { for segment in segments { let trimmed = segment.trimmingCharacters(in: .whitespaces) - // Expected format: "HH:mm: X.X U/hr" or "HH:mm: X.X" + // 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" → minutes since midnight + // Parse time "HH:mm" or "HH:mm:ss" → minutes since midnight let timeParts = timeStr.components(separatedBy: ":") - guard timeParts.count == 2, + guard timeParts.count >= 2, let hours = Int(timeParts[0].trimmingCharacters(in: .whitespaces)), let mins = Int(timeParts[1].trimmingCharacters(in: .whitespaces)) else { continue } @@ -324,5 +336,63 @@ extension SettingsAuditLog { 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 index b0edadd4a26..0156dc99fb3 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift @@ -1,5 +1,6 @@ import SwiftUI import Swinject +import UIKit extension SettingsAuditLog { struct RootView: BaseView { @@ -12,6 +13,9 @@ extension SettingsAuditLog { @State private var selectedEvent: ChangeEvent? @State private var noteText = "" @State private var debounceTask: Task? + @State private var showDeleteConfirmation = false + @State private var showExportSheet = false + @State private var csvFileURL: URL? var body: some View { List { @@ -60,6 +64,11 @@ extension SettingsAuditLog { EventDetailView(event: event, units: state.units, noteText: $noteText) } } + .sheet(isPresented: $showExportSheet) { + if let url = csvFileURL { + ShareSheet(activityItems: [url]) + } + } .onChange(of: noteText) { _, newValue in guard let event = selectedEvent else { return } debounceTask?.cancel() @@ -69,8 +78,59 @@ extension SettingsAuditLog { 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) + csvFileURL = tempURL + showExportSheet = true + } 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 { @@ -297,3 +357,15 @@ private struct EventDetailView: View { } } } + +// MARK: - ShareSheet + +private struct ShareSheet: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context _: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + + func updateUIViewController(_: UIActivityViewController, context _: Context) {} +} From 4a96e8fa45fa55940917543749ccb2bcc241f24f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:07:23 +0000 Subject: [PATCH 25/29] perf: make time-seconds regex a static constant to avoid recompilation Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/059f9b5c-000a-4508-a563-4f71ab4f7082 Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- .../SettingsAuditLog/SettingsAuditLogStateModel.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift index a2efb691f77..2c1af186813 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift @@ -70,13 +70,16 @@ extension SettingsAuditLog { 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 { - raw.replacingOccurrences( - of: #"(\d{1,2}:\d{2}):\d{2}(: )"#, - with: "$1$2", - options: .regularExpression + timeSecondsRegex.stringByReplacingMatches( + in: raw, + range: NSRange(raw.startIndex..., in: raw), + withTemplate: "$1$2" ) } From a5136598cc32840da11b1ae8d397888c2fc80a58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:16:09 +0000 Subject: [PATCH 26/29] fix: remove duplicate ShareSheet declaration causing compiler error Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/2376796f-1de8-4a61-aec6-e19d00bd555c Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- .../View/SettingsAuditLogRootView.swift | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift index 0156dc99fb3..bce4c12373d 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift @@ -1,6 +1,5 @@ import SwiftUI import Swinject -import UIKit extension SettingsAuditLog { struct RootView: BaseView { @@ -358,14 +357,4 @@ private struct EventDetailView: View { } } -// MARK: - ShareSheet -private struct ShareSheet: UIViewControllerRepresentable { - let activityItems: [Any] - - func makeUIViewController(context _: Context) -> UIActivityViewController { - UIActivityViewController(activityItems: activityItems, applicationActivities: nil) - } - - func updateUIViewController(_: UIActivityViewController, context _: Context) {} -} From 00c33975ee490efc525d8e7ebf17cbb9b3666f9f Mon Sep 17 00:00:00 2001 From: Sjoerd Bozon Date: Tue, 7 Apr 2026 15:54:09 +0200 Subject: [PATCH 27/29] fix: force-copy strings in changeentry to break nsstring bridge with core data --- .../SettingsAuditLogStateModel.swift | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift index 2c1af186813..8ca43a69c43 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift @@ -47,18 +47,20 @@ extension SettingsAuditLog { 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 = stored.category ?? "" - subcategory = stored.subcategory ?? "" - settingName = stored.settingName ?? "" - settingKey = stored.settingKey ?? "" - oldValue = stored.oldValue ?? "" - newValue = stored.newValue ?? "" - unit = stored.unit - note = stored.note ?? "" - source = stored.source ?? "manual" + 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() } From 2f1471bd78c2116c46dfe85414ad74638f9adb7b Mon Sep 17 00:00:00 2001 From: Sjoerd Bozon Date: Tue, 7 Apr 2026 16:03:43 +0200 Subject: [PATCH 28/29] fix: replace computed properties with stored state to prevent crash during body render --- .../SettingsAuditLogStateModel.swift | 65 +++++++++++-------- 1 file changed, 39 insertions(+), 26 deletions(-) diff --git a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift index 8ca43a69c43..76a11504041 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/SettingsAuditLogStateModel.swift @@ -235,11 +235,21 @@ extension SettingsAuditLog { } @Observable final class StateModel: BaseStateModel { - var searchText: String = "" + var searchText: String = "" { + didSet { recomputeDerivedState() } + } + var selectedCategory: String? = nil - var entries: [ChangeEntry] = [] 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 = { @@ -249,15 +259,6 @@ extension SettingsAuditLog { return df }() - /// Distinct categories from loaded entries. - var allCategories: [String] { - var cats = Set() - for entry in entries { - if !entry.category.isEmpty { cats.insert(entry.category) } - } - return ["All"] + cats.sorted() - } - override func subscribe() { units = settingsManager.settings.units loadEntries() @@ -269,6 +270,7 @@ extension SettingsAuditLog { since: nil, limit: 500 ) + recomputeDerivedState() } func updateNote(forGroup groupId: UUID, note: String) { @@ -292,26 +294,38 @@ extension SettingsAuditLog { groupId: entry.groupId ) } + recomputeDerivedState() } - var filteredEntries: [ChangeEntry] { - guard !searchText.isEmpty else { return entries } - let lower = searchText.lowercased() - return 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) + /// 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) + } } - } - /// Groups filtered entries by `groupId` into `ChangeEvent`s, then groups those by day. - var groupedEvents: [(String, [ChangeEvent])] { + // 3. Grouped events let cal = Self.groupingCalendar let df = Self.groupingFormatter - // Build ChangeEvents from groupId let byGroup = Dictionary(grouping: filteredEntries, by: \.groupId) let events: [ChangeEvent] = byGroup.map { groupId, groupEntries in let sorted = groupEntries.sorted { $0.date > $1.date } @@ -320,11 +334,10 @@ extension SettingsAuditLog { return ChangeEvent(id: groupId, date: date, entries: sorted, note: note) } - // Group events by day let byDay = Dictionary(grouping: events) { event -> DateComponents in cal.dateComponents([.year, .month, .day], from: event.date) } - return byDay + groupedEvents = byDay .sorted { a, b in let aDate = cal.date(from: a.key) ?? .distantPast let bDate = cal.date(from: b.key) ?? .distantPast From be2998ebb4d15aedf90471cadc4bdb9287f97fd4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:25:29 +0000 Subject: [PATCH 29/29] fix: consolidate dual sheet modifiers into single ActiveSheet enum to prevent crash Agent-Logs-Url: https://github.com/Sjoerd-Bo3/Trio/sessions/14755bc8-a3e8-4e17-87a0-9db1229e996e Co-authored-by: Sjoerd-Bo3 <2100083+Sjoerd-Bo3@users.noreply.github.com> --- .../View/SettingsAuditLogRootView.swift | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift index bce4c12373d..ea2247f6220 100644 --- a/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift +++ b/Trio/Sources/Modules/SettingsAuditLog/View/SettingsAuditLogRootView.swift @@ -2,6 +2,21 @@ 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() @@ -9,12 +24,13 @@ extension SettingsAuditLog { @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 - @State private var showExportSheet = false - @State private var csvFileURL: URL? + /// Single active sheet – only one sheet is presented at a time. + @State private var activeSheet: ActiveSheet? var body: some View { List { @@ -38,6 +54,7 @@ extension SettingsAuditLog { .onTapGesture { noteText = event.note selectedEvent = event + activeSheet = .eventDetail(event) } } } @@ -58,13 +75,20 @@ extension SettingsAuditLog { state.updateNote(forGroup: event.id, note: noteText) } } - .sheet(item: $selectedEvent) { event in - NavigationView { - EventDetailView(event: event, units: state.units, noteText: $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(isPresented: $showExportSheet) { - if let url = csvFileURL { + }) { sheet in + switch sheet { + case .eventDetail(let event): + NavigationView { + EventDetailView(event: event, units: state.units, noteText: $noteText) + } + case .export(let url): ShareSheet(activityItems: [url]) } } @@ -117,8 +141,7 @@ extension SettingsAuditLog { let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) do { try csv.write(to: tempURL, atomically: true, encoding: .utf8) - csvFileURL = tempURL - showExportSheet = true + activeSheet = .export(tempURL) } catch { debug(.default, "Failed to write CSV: \(error)") }