@@ -22,6 +22,6 @@ struct ContentView: View {
|
||
| 22 | 22 |
|
| 23 | 23 |
#Preview {
|
| 24 | 24 |
ContentView() |
| 25 |
- .modelContainer(for: [HealthSnapshot.self, SnapshotDelta.self, TypeDelta.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true) |
|
| 25 |
+ .modelContainer(for: [HealthSnapshot.self, SnapshotDelta.self, TypeDelta.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self], inMemory: true) |
|
| 26 | 26 |
.environment(AppSettings()) |
| 27 | 27 |
} |
@@ -27,7 +27,7 @@ There are no real deployments, only test installations. Existing prototype datab |
||
| 27 | 27 |
| HealthKit capture | Capture now opens one archive observation per user-visible snapshot and attaches HealthKit pages, deleted-object evidence, and type verification to that observation id before finishing it | Continue moving UI/cache reads to archive-backed observation ids | |
| 28 | 28 |
| SQLite archive | Archive v2 schema, snapshot-level observation grouping, differential write path, v2 verification/delete bookkeeping, daily aggregate rebuilds, integrity report, v2 record reads, SQL diff/count/aggregate/provenance/consolidation-evidence APIs, large synthetic diff pagination coverage, formal timing/memory metrics, and XCTest coverage are in place; the legacy `archive_samples` mirror has been removed | Move Snapshots/Data Types from SwiftData previews to archive/cache DTOs | |
| 29 | 29 |
| Core Data cache | Initial programmatic Core Data model, full-cache rebuild service, read DTOs for observation/type/diff/health rows, and Dashboard archive-cache status wiring are in place | Move remaining export/report paths to cache DTOs and add targeted partial invalidation | |
| 30 |
-| SwiftData cache | Exists; test builds now reset legacy prototype UI/archive/cache stores once for archive v2 so old SwiftData-only snapshots are not treated as backed-up observations. Metric timeout calibration has moved to a local Codable store outside `ModelContainer`. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; replace remaining Settings/local rows, capture review actions, and navigation handles before removing `ModelContainer` | |
|
| 30 |
+| SwiftData cache | Exists; test builds now reset legacy prototype UI/archive/cache stores once for archive v2 so old SwiftData-only snapshots are not treated as backed-up observations. Metric timeout calibration and local device profile settings have moved to Codable stores outside `ModelContainer`. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; replace remaining operation/local rows, capture review actions, and navigation handles before removing `ModelContainer` | |
|
| 31 | 31 |
| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Dashboard status prefers archive/cache observation rows and shows cache health; Snapshots timeline, snapshot detail summaries/type rows, and Data Types list prefer Core Data cache rows when archive observation ids exist; data type detail reads Core Data type/diff summaries and uses SQLite `diffRecords` for paged drill-down; export preview reads the archive export API before showing/exporting JSON; simplified detail mode replaces heavy charts with summary rows on small/accessibility layouts or when enabled in Settings; visible change labels now use neutral new/missing/change-review language, with SwiftData detail cache as transition fallback | Remove remaining SwiftData navigation handles | |
| 32 | 32 |
| Diff/change explanation | Prototype/legacy anomaly logic exists | Move heavy diffing into SQLite and use neutral change classifications | |
| 33 | 33 |
| Export | SQLite export preview, paged JSON writing, SHA256 manifest hashing, and `export_manifests` rows are in place for selected records and observation diffs | Fill remaining recovery-compatible envelope metadata, CSV export, relationship preservation, and reproducibility checks | |
@@ -49,7 +49,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m |
||
| 49 | 49 |
- SwiftData currently blocks iOS 15-era device support. |
| 50 | 50 |
- Existing `Anomaly*` model/service names are legacy language. |
| 51 | 51 |
- Some screens still imply snapshot-count monitoring rather than Time Machine inspection. |
| 52 |
-- Current UI/cache layers still depend on 27 SwiftData-backed files for launch container, remaining local Settings rows, capture review actions, navigation handles, some charts, and PDF paths. |
|
| 52 |
+- Current UI/cache layers still depend on 26 SwiftData-backed files for launch container, remaining operation/local rows, capture review actions, navigation handles, some charts, and PDF paths. |
|
| 53 | 53 |
- Snapshots timeline, snapshot detail summary/type rows, Data Types list rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist, but navigation still uses SwiftData snapshot handles during the transition. |
| 54 | 54 |
- Legacy SwiftData-only snapshots can show diffs without archive-backed values; they are now reset for archive v2 test installs rather than migrated. |
| 55 | 55 |
- Capture strategy and some legacy SwiftData transition paths may still decode or cache too much data for low-end devices. |
@@ -247,9 +247,9 @@ Acceptance: |
||
| 247 | 247 |
Checklist: |
| 248 | 248 |
- [x] Identify all remaining SwiftData imports. |
| 249 | 249 |
- [ ] Replace SwiftData models used by active flows. Metric timeout calibration |
| 250 |
- has been moved to a local Codable store and removed from `ModelContainer`; |
|
| 251 |
- `DeviceProfile`, `OperationLog`, and SwiftData snapshot/navigation handles |
|
| 252 |
- remain. |
|
| 250 |
+ and local device profile settings have been moved to local Codable stores and |
|
| 251 |
+ removed from `ModelContainer`; `OperationLog` and SwiftData |
|
| 252 |
+ snapshot/navigation handles remain. |
|
| 253 | 253 |
- [ ] Remove/disable `ModelContainer` as required for target builds. |
| 254 | 254 |
- [x] Add prototype-store ignore/delete/reset path for test installs. |
| 255 | 255 |
- [ ] Verify no old-store compatibility layer remains in active flows. |
@@ -9,8 +9,8 @@ local settings stored outside SwiftData where needed. |
||
| 9 | 9 |
|
| 10 | 10 |
## Current Count |
| 11 | 11 |
|
| 12 |
-After moving timeout calibration out of SwiftData, 27 app files still have |
|
| 13 |
-SwiftData imports. |
|
| 12 |
+After moving timeout calibration and local device profile settings out of |
|
| 13 |
+SwiftData, 26 app files still have SwiftData imports. |
|
| 14 | 14 |
|
| 15 | 15 |
## Launch Container |
| 16 | 16 |
|
@@ -20,8 +20,8 @@ These files keep SwiftData required at app launch: |
||
| 20 | 20 |
- `HealthProbe/ContentView.swift` |
| 21 | 21 |
|
| 22 | 22 |
Retirement path: |
| 23 |
-- move remaining local settings models (`DeviceProfile`, `OperationLog`) to |
|
| 24 |
- non-SwiftData storage or Core Data cache/local store; |
|
| 23 |
+- move remaining local settings model (`OperationLog`) to non-SwiftData storage |
|
| 24 |
+ or Core Data cache/local store; |
|
| 25 | 25 |
- replace prototype snapshot model dependencies in tab roots; |
| 26 | 26 |
- remove `.modelContainer(...)` once no active view needs `@Query` or |
| 27 | 27 |
`ModelContext`. |
@@ -32,7 +32,6 @@ These files define SwiftData `@Model` classes and are the largest retirement |
||
| 32 | 32 |
block: |
| 33 | 33 |
|
| 34 | 34 |
- `HealthProbe/Models/AnomalyRecord.swift` |
| 35 |
-- `HealthProbe/Models/DeviceProfile.swift` |
|
| 36 | 35 |
- `HealthProbe/Models/HealthRecord.swift` |
| 37 | 36 |
- `HealthProbe/Models/HealthSnapshot.swift` |
| 38 | 37 |
- `HealthProbe/Models/OperationLog.swift` |
@@ -47,8 +46,8 @@ Retirement path: |
||
| 47 | 46 |
`YearlyCount`, `TypeDistributionBin`, and `HealthRecord` active reads with |
| 48 | 47 |
archive/cache DTOs; |
| 49 | 48 |
- replace `AnomalyRecord` flows with neutral change/diff DTOs; |
| 50 |
-- move `DeviceProfile` and `OperationLog` to a local non-SwiftData store before |
|
| 51 |
- removing the launch container. |
|
| 49 |
+- move `OperationLog` to a local non-SwiftData store before removing the launch |
|
| 50 |
+ container. |
|
| 52 | 51 |
|
| 53 | 52 |
## Capture And Maintenance Services |
| 54 | 53 |
|
@@ -89,7 +88,7 @@ Retirement path: |
||
| 89 | 88 |
queries plus archive ids; |
| 90 | 89 |
- replace detail navigation parameters from SwiftData models to observation/type |
| 91 | 90 |
DTOs; |
| 92 |
-- move Settings device profile rows to local non-SwiftData storage; |
|
| 91 |
+- remove remaining local-only SwiftData rows; |
|
| 93 | 92 |
- keep paged record drill-down and export paths on archive APIs. |
| 94 | 93 |
|
| 95 | 94 |
## Removed During This Pass |
@@ -104,11 +103,15 @@ The following SwiftData dependencies were removed from active flows: |
||
| 104 | 103 |
store outside `ModelContainer`. |
| 105 | 104 |
- `SettingsView`, `DashboardView`, and `HealthKitService` read/write timeout |
| 106 | 105 |
calibration through the local store. |
| 106 |
+- `HealthProbe/Models/DeviceProfile.swift` was deleted. |
|
| 107 |
+- Device display name/color settings now use |
|
| 108 |
+ `HealthProbe/Utilities/LocalDeviceProfile.swift`, a Codable local store used |
|
| 109 |
+ by Settings, Dashboard, Snapshots, and legacy PDF export. |
|
| 107 | 110 |
|
| 108 | 111 |
## Next Recommended Slices |
| 109 | 112 |
|
| 110 |
-1. Replace Settings local rows (`DeviceProfile`) with a small non-SwiftData |
|
| 111 |
- local store so Settings no longer requires `@Query` for local configuration. |
|
| 113 |
+1. Move `OperationLog` away from SwiftData, or delete it if no active reporting |
|
| 114 |
+ flow still needs it. |
|
| 112 | 115 |
2. Replace `ContentView` preview/container dependency after tab roots stop using |
| 113 | 116 |
`@Query`. |
| 114 | 117 |
3. Move `DashboardView` capture review actions away from `ModelContext`. |
@@ -33,7 +33,7 @@ struct HealthProbeApp: App {
|
||
| 33 | 33 |
let fullSchema = Schema([ |
| 34 | 34 |
HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, |
| 35 | 35 |
SnapshotDelta.self, TypeDelta.self, AnomalyRecord.self, |
| 36 |
- OperationLog.self, DeviceProfile.self, |
|
| 36 |
+ OperationLog.self, |
|
| 37 | 37 |
]) |
| 38 | 38 |
|
| 39 | 39 |
let appSupportURL = URL.applicationSupportDirectory |
@@ -44,7 +44,7 @@ struct HealthProbeApp: App {
|
||
| 44 | 44 |
HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, |
| 45 | 45 |
SnapshotDelta.self, TypeDelta.self, AnomalyRecord.self, |
| 46 | 46 |
]) |
| 47 |
- let localModels = Schema([OperationLog.self, DeviceProfile.self]) |
|
| 47 |
+ let localModels = Schema([OperationLog.self]) |
|
| 48 | 48 |
|
| 49 | 49 |
let uiCacheConfig = ModelConfiguration( |
| 50 | 50 |
"ui-cache", |
@@ -1,12 +0,0 @@ |
||
| 1 |
-import Foundation |
|
| 2 |
-import SwiftData |
|
| 3 |
- |
|
| 4 |
-@Model final class DeviceProfile {
|
|
| 5 |
- var deviceID: String = "" |
|
| 6 |
- var name: String = "" |
|
| 7 |
- var colorTag: String = "blue" |
|
| 8 |
- |
|
| 9 |
- init(deviceID: String) {
|
|
| 10 |
- self.deviceID = deviceID |
|
| 11 |
- } |
|
| 12 |
-} |
|
@@ -0,0 +1,56 @@ |
||
| 1 |
+import Foundation |
|
| 2 |
+ |
|
| 3 |
+struct LocalDeviceProfile: Codable, Equatable, Identifiable, Sendable {
|
|
| 4 |
+ var id: String { deviceID }
|
|
| 5 |
+ var deviceID: String |
|
| 6 |
+ var name: String = "" |
|
| 7 |
+ var colorTag: String = "blue" |
|
| 8 |
+ |
|
| 9 |
+ init(deviceID: String, name: String = "", colorTag: String = "blue") {
|
|
| 10 |
+ self.deviceID = deviceID |
|
| 11 |
+ self.name = name |
|
| 12 |
+ self.colorTag = colorTag |
|
| 13 |
+ } |
|
| 14 |
+} |
|
| 15 |
+ |
|
| 16 |
+enum LocalDeviceProfileStore {
|
|
| 17 |
+ private static let key = "hp_localDeviceProfiles" |
|
| 18 |
+ |
|
| 19 |
+ static func allProfiles() -> [LocalDeviceProfile] {
|
|
| 20 |
+ load().sorted {
|
|
| 21 |
+ $0.deviceID.localizedCaseInsensitiveCompare($1.deviceID) == .orderedAscending |
|
| 22 |
+ } |
|
| 23 |
+ } |
|
| 24 |
+ |
|
| 25 |
+ static func profile(for deviceID: String) -> LocalDeviceProfile {
|
|
| 26 |
+ guard !deviceID.isEmpty else { return LocalDeviceProfile(deviceID: deviceID) }
|
|
| 27 |
+ return load().first { $0.deviceID == deviceID } ?? LocalDeviceProfile(deviceID: deviceID)
|
|
| 28 |
+ } |
|
| 29 |
+ |
|
| 30 |
+ static func save(_ profile: LocalDeviceProfile) {
|
|
| 31 |
+ guard !profile.deviceID.isEmpty else { return }
|
|
| 32 |
+ var profiles = load() |
|
| 33 |
+ if let index = profiles.firstIndex(where: { $0.deviceID == profile.deviceID }) {
|
|
| 34 |
+ profiles[index] = profile |
|
| 35 |
+ } else {
|
|
| 36 |
+ profiles.append(profile) |
|
| 37 |
+ } |
|
| 38 |
+ save(profiles) |
|
| 39 |
+ } |
|
| 40 |
+ |
|
| 41 |
+ static func removeAll() {
|
|
| 42 |
+ UserDefaults.standard.removeObject(forKey: key) |
|
| 43 |
+ } |
|
| 44 |
+ |
|
| 45 |
+ private static func load() -> [LocalDeviceProfile] {
|
|
| 46 |
+ guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
|
|
| 47 |
+ return (try? JSONDecoder().decode([LocalDeviceProfile].self, from: data)) ?? [] |
|
| 48 |
+ } |
|
| 49 |
+ |
|
| 50 |
+ private static func save(_ profiles: [LocalDeviceProfile]) {
|
|
| 51 |
+ let sorted = profiles.sorted {
|
|
| 52 |
+ $0.deviceID.localizedCaseInsensitiveCompare($1.deviceID) == .orderedAscending |
|
| 53 |
+ } |
|
| 54 |
+ UserDefaults.standard.set(try? JSONEncoder().encode(sorted), forKey: key) |
|
| 55 |
+ } |
|
| 56 |
+} |
|
@@ -35,7 +35,7 @@ enum SnapshotPDFExporter {
|
||
| 35 | 35 |
static func extractReportData( |
| 36 | 36 |
snapshot: HealthSnapshot, |
| 37 | 37 |
baseline: HealthSnapshot?, |
| 38 |
- profile: DeviceProfile? |
|
| 38 |
+ profile: LocalDeviceProfile? |
|
| 39 | 39 |
) -> SnapshotReportData {
|
| 40 | 40 |
let profileName: String? = {
|
| 41 | 41 |
guard let n = profile?.name, !n.isEmpty else { return nil }
|
@@ -7,8 +7,8 @@ struct DashboardView: View {
|
||
| 7 | 7 |
@Environment(\.modelContext) private var modelContext |
| 8 | 8 |
@Environment(AppSettings.self) private var appSettings |
| 9 | 9 |
@Query(sort: \HealthSnapshot.timestamp, order: .reverse) private var snapshots: [HealthSnapshot] |
| 10 |
- @Query private var deviceProfiles: [DeviceProfile] |
|
| 11 | 10 |
@State private var viewModel = DashboardViewModel() |
| 11 |
+ @State private var currentDeviceProfile = LocalDeviceProfileStore.profile(for: AppSettings.currentDeviceID) |
|
| 12 | 12 |
@State private var didAutoRequestPermissions = false |
| 13 | 13 |
@State private var snapshotSheetTab: SnapshotSheetTab = .progress |
| 14 | 14 |
@State private var diagnosticReport: DiagnosticReport? |
@@ -34,12 +34,8 @@ struct DashboardView: View {
|
||
| 34 | 34 |
guard viewModel.archiveObservationRows.count > 1 else { return nil }
|
| 35 | 35 |
return viewModel.archiveObservationRows[1] |
| 36 | 36 |
} |
| 37 |
- private var currentDeviceProfile: DeviceProfile? {
|
|
| 38 |
- deviceProfiles.first { $0.deviceID == AppSettings.currentDeviceID }
|
|
| 39 |
- } |
|
| 40 |
- |
|
| 41 | 37 |
private var currentDeviceDisplayName: String {
|
| 42 |
- if let name = currentDeviceProfile?.name, !name.isEmpty { return name }
|
|
| 38 |
+ if !currentDeviceProfile.name.isEmpty { return currentDeviceProfile.name }
|
|
| 43 | 39 |
if let latest, !latest.deviceName.isEmpty { return latest.deviceName }
|
| 44 | 40 |
return "Local device" |
| 45 | 41 |
} |
@@ -79,6 +75,7 @@ struct DashboardView: View {
|
||
| 79 | 75 |
progressSheet |
| 80 | 76 |
} |
| 81 | 77 |
.task {
|
| 78 |
+ currentDeviceProfile = LocalDeviceProfileStore.profile(for: AppSettings.currentDeviceID) |
|
| 82 | 79 |
viewModel.loadArchiveCacheStatus() |
| 83 | 80 |
if !didAutoRequestPermissions && !HealthKitService.shared.hasRequestedPermissionsBefore {
|
| 84 | 81 |
didAutoRequestPermissions = true |
@@ -234,6 +234,6 @@ private enum DeltaIndicator {
|
||
| 234 | 234 |
|
| 235 | 235 |
#Preview {
|
| 236 | 236 |
DataTypesView() |
| 237 |
- .modelContainer(for: [HealthSnapshot.self, TypeCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true) |
|
| 237 |
+ .modelContainer(for: [HealthSnapshot.self, TypeCount.self, HealthRecord.self, TypeDistributionBin.self], inMemory: true) |
|
| 238 | 238 |
.environment(AppSettings()) |
| 239 | 239 |
} |
@@ -7,21 +7,17 @@ struct SettingsView: View {
|
||
| 7 | 7 |
@Environment(\.modelContext) private var modelContext |
| 8 | 8 |
@Environment(AppSettings.self) private var appSettings |
| 9 | 9 |
@Query private var snapshots: [HealthSnapshot] |
| 10 |
- @Query private var deviceProfiles: [DeviceProfile] |
|
| 11 | 10 |
@AppStorage("checkFrequencyHours") private var checkFrequencyHours: Int = 6
|
| 12 | 11 |
@State private var showDeleteConfirm = false |
| 13 | 12 |
@State private var showRepairLegacyRecordsConfirm = false |
| 14 | 13 |
@State private var dataMaintenanceMessage: String? |
| 15 | 14 |
@State private var timeoutProfiles: [LocalMetricTimeoutProfile] = [] |
| 15 |
+ @State private var currentDeviceProfile: LocalDeviceProfile? |
|
| 16 | 16 |
|
| 17 | 17 |
private var currentDeviceID: String {
|
| 18 | 18 |
AppSettings.currentDeviceID |
| 19 | 19 |
} |
| 20 | 20 |
|
| 21 |
- private var currentDeviceProfile: DeviceProfile? {
|
|
| 22 |
- deviceProfiles.first { $0.deviceID == currentDeviceID }
|
|
| 23 |
- } |
|
| 24 |
- |
|
| 25 | 21 |
var body: some View {
|
| 26 | 22 |
NavigationStack {
|
| 27 | 23 |
List {
|
@@ -35,7 +31,7 @@ struct SettingsView: View {
|
||
| 35 | 31 |
} |
| 36 | 32 |
.navigationTitle("Settings")
|
| 37 | 33 |
.onAppear {
|
| 38 |
- ensureCurrentDeviceProfile() |
|
| 34 |
+ loadCurrentDeviceProfile() |
|
| 39 | 35 |
loadTimeoutProfiles() |
| 40 | 36 |
} |
| 41 | 37 |
.confirmationDialog( |
@@ -71,9 +67,10 @@ struct SettingsView: View {
|
||
| 71 | 67 |
Spacer() |
| 72 | 68 |
TextField("Device name", text: Binding(
|
| 73 | 69 |
get: { profile.name },
|
| 74 |
- set: {
|
|
| 75 |
- profile.name = $0 |
|
| 76 |
- try? modelContext.save() |
|
| 70 |
+ set: { newName in
|
|
| 71 |
+ updateCurrentDeviceProfile { profile in
|
|
| 72 |
+ profile.name = newName |
|
| 73 |
+ } |
|
| 77 | 74 |
} |
| 78 | 75 |
)) |
| 79 | 76 |
.multilineTextAlignment(.trailing) |
@@ -96,8 +93,9 @@ struct SettingsView: View {
|
||
| 96 | 93 |
} |
| 97 | 94 |
} |
| 98 | 95 |
.onTapGesture {
|
| 99 |
- profile.colorTag = dc.rawValue |
|
| 100 |
- try? modelContext.save() |
|
| 96 |
+ updateCurrentDeviceProfile { profile in
|
|
| 97 |
+ profile.colorTag = dc.rawValue |
|
| 98 |
+ } |
|
| 101 | 99 |
} |
| 102 | 100 |
.accessibilityLabel(dc.rawValue.capitalized) |
| 103 | 101 |
.accessibilityAddTraits(profile.colorTag == dc.rawValue ? .isSelected : []) |
@@ -220,10 +218,18 @@ struct SettingsView: View {
|
||
| 220 | 218 |
|
| 221 | 219 |
// MARK: - Actions |
| 222 | 220 |
|
| 223 |
- private func ensureCurrentDeviceProfile() {
|
|
| 224 |
- guard currentDeviceProfile == nil, !currentDeviceID.isEmpty else { return }
|
|
| 225 |
- modelContext.insert(DeviceProfile(deviceID: currentDeviceID)) |
|
| 226 |
- try? modelContext.save() |
|
| 221 |
+ private func loadCurrentDeviceProfile() {
|
|
| 222 |
+ currentDeviceProfile = LocalDeviceProfileStore.profile(for: currentDeviceID) |
|
| 223 |
+ if let currentDeviceProfile {
|
|
| 224 |
+ LocalDeviceProfileStore.save(currentDeviceProfile) |
|
| 225 |
+ } |
|
| 226 |
+ } |
|
| 227 |
+ |
|
| 228 |
+ private func updateCurrentDeviceProfile(_ mutate: (inout LocalDeviceProfile) -> Void) {
|
|
| 229 |
+ var profile = currentDeviceProfile ?? LocalDeviceProfileStore.profile(for: currentDeviceID) |
|
| 230 |
+ mutate(&profile) |
|
| 231 |
+ currentDeviceProfile = profile |
|
| 232 |
+ LocalDeviceProfileStore.save(profile) |
|
| 227 | 233 |
} |
| 228 | 234 |
|
| 229 | 235 |
private func loadTimeoutProfiles() {
|
@@ -420,6 +426,6 @@ private func formatDuration(_ seconds: TimeInterval) -> String {
|
||
| 420 | 426 |
|
| 421 | 427 |
#Preview {
|
| 422 | 428 |
SettingsView() |
| 423 |
- .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true) |
|
| 429 |
+ .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self], inMemory: true) |
|
| 424 | 430 |
.environment(AppSettings()) |
| 425 | 431 |
} |
@@ -6,7 +6,7 @@ import UIKit |
||
| 6 | 6 |
struct SnapshotDetailView: View {
|
| 7 | 7 |
let snapshot: HealthSnapshot |
| 8 | 8 |
let baseline: HealthSnapshot? |
| 9 |
- let profile: DeviceProfile? |
|
| 9 |
+ let profile: LocalDeviceProfile? |
|
| 10 | 10 |
|
| 11 | 11 |
@Query(sort: \HealthSnapshot.timestamp) private var allSnapshots: [HealthSnapshot] |
| 12 | 12 |
@Query private var allDeltas: [SnapshotDelta] |
@@ -1152,8 +1152,8 @@ private struct ShareSheet: UIViewControllerRepresentable {
|
||
| 1152 | 1152 |
deviceID: "preview-device" |
| 1153 | 1153 |
), |
| 1154 | 1154 |
baseline: nil, |
| 1155 |
- profile: DeviceProfile(deviceID: "preview-device") |
|
| 1155 |
+ profile: LocalDeviceProfile(deviceID: "preview-device") |
|
| 1156 | 1156 |
) |
| 1157 | 1157 |
} |
| 1158 |
- .modelContainer(for: [HealthSnapshot.self, SnapshotDelta.self, TypeDelta.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true) |
|
| 1158 |
+ .modelContainer(for: [HealthSnapshot.self, SnapshotDelta.self, TypeDelta.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self], inMemory: true) |
|
| 1159 | 1159 |
} |
@@ -5,14 +5,8 @@ struct SnapshotsView: View {
|
||
| 5 | 5 |
@Environment(\.modelContext) private var modelContext |
| 6 | 6 |
@Query(sort: \HealthSnapshot.timestamp, order: .reverse) private var allSnapshots: [HealthSnapshot] |
| 7 | 7 |
@Query private var allDeltas: [SnapshotDelta] |
| 8 |
- @Query private var deviceProfiles: [DeviceProfile] |
|
| 9 | 8 |
@State private var viewModel = SnapshotsViewModel() |
| 10 |
- |
|
| 11 |
- private var profileMap: [String: DeviceProfile] {
|
|
| 12 |
- Dictionary(uniqueKeysWithValues: deviceProfiles.compactMap {
|
|
| 13 |
- $0.deviceID.isEmpty ? nil : ($0.deviceID, $0) |
|
| 14 |
- }) |
|
| 15 |
- } |
|
| 9 |
+ @State private var profileMap: [String: LocalDeviceProfile] = [:] |
|
| 16 | 10 |
|
| 17 | 11 |
private var displayedSnapshots: [HealthSnapshot] {
|
| 18 | 12 |
guard let deviceID = localDeviceID else { return [] }
|
@@ -93,6 +87,7 @@ struct SnapshotsView: View {
|
||
| 93 | 87 |
repairDeltaListSummariesIfNeeded() |
| 94 | 88 |
} |
| 95 | 89 |
.task(id: timelineReloadID) {
|
| 90 |
+ loadDeviceProfiles() |
|
| 96 | 91 |
await viewModel.loadArchiveRows() |
| 97 | 92 |
} |
| 98 | 93 |
} |
@@ -186,6 +181,13 @@ struct SnapshotsView: View {
|
||
| 186 | 181 |
// Keep the list responsive even if summary repair fails. |
| 187 | 182 |
} |
| 188 | 183 |
} |
| 184 |
+ |
|
| 185 |
+ private func loadDeviceProfiles() {
|
|
| 186 |
+ let profiles = LocalDeviceProfileStore.allProfiles() |
|
| 187 |
+ profileMap = Dictionary(uniqueKeysWithValues: profiles.compactMap {
|
|
| 188 |
+ $0.deviceID.isEmpty ? nil : ($0.deviceID, $0) |
|
| 189 |
+ }) |
|
| 190 |
+ } |
|
| 189 | 191 |
} |
| 190 | 192 |
|
| 191 | 193 |
private struct SnapshotListItem: Identifiable {
|
@@ -212,7 +214,7 @@ private struct SnapshotRow: View {
|
||
| 212 | 214 |
let deltaSummary: SnapshotDeltaListSummary? |
| 213 | 215 |
let showsDeltaSummary: Bool |
| 214 | 216 |
let isSelectedBaseline: Bool |
| 215 |
- let profile: DeviceProfile? |
|
| 217 |
+ let profile: LocalDeviceProfile? |
|
| 216 | 218 |
|
| 217 | 219 |
private static let dateFormatter: DateFormatter = {
|
| 218 | 220 |
let f = DateFormatter() |
@@ -435,6 +437,6 @@ private struct SnapshotRow: View {
|
||
| 435 | 437 |
|
| 436 | 438 |
#Preview {
|
| 437 | 439 |
SnapshotsView() |
| 438 |
- .modelContainer(for: [HealthSnapshot.self, SnapshotDelta.self, TypeDelta.self, TypeCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true) |
|
| 440 |
+ .modelContainer(for: [HealthSnapshot.self, SnapshotDelta.self, TypeDelta.self, TypeCount.self, HealthRecord.self, TypeDistributionBin.self], inMemory: true) |
|
| 439 | 441 |
.environment(AppSettings()) |
| 440 | 442 |
} |
@@ -0,0 +1,24 @@ |
||
| 1 |
+import XCTest |
|
| 2 |
+@testable import HealthProbe |
|
| 3 |
+ |
|
| 4 |
+final class LocalDeviceProfileTests: XCTestCase {
|
|
| 5 |
+ override func tearDown() {
|
|
| 6 |
+ LocalDeviceProfileStore.removeAll() |
|
| 7 |
+ super.tearDown() |
|
| 8 |
+ } |
|
| 9 |
+ |
|
| 10 |
+ func testSavesAndLoadsDeviceProfile() {
|
|
| 11 |
+ let profile = LocalDeviceProfile(deviceID: "device-a", name: "Test Phone", colorTag: "green") |
|
| 12 |
+ |
|
| 13 |
+ LocalDeviceProfileStore.save(profile) |
|
| 14 |
+ |
|
| 15 |
+ let loaded = LocalDeviceProfileStore.profile(for: "device-a") |
|
| 16 |
+ XCTAssertEqual(loaded, profile) |
|
| 17 |
+ } |
|
| 18 |
+ |
|
| 19 |
+ func testEmptyDeviceIDIsNotPersisted() {
|
|
| 20 |
+ LocalDeviceProfileStore.save(LocalDeviceProfile(deviceID: "", name: "Ignored", colorTag: "red")) |
|
| 21 |
+ |
|
| 22 |
+ XCTAssertTrue(LocalDeviceProfileStore.allProfiles().isEmpty) |
|
| 23 |
+ } |
|
| 24 |
+} |
|