Showing 6 changed files with 81 additions and 37 deletions
+3 -3
HealthProbe/Doc/04-project/IMPLEMENTATION_STATUS.md
@@ -27,8 +27,8 @@ 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 | Continue moving capture/Dashboard actions 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, local device profile settings, operation logging, ContentView preview, Settings data maintenance, legacy detail/PDF views, unused legacy repair/observer services, and legacy anomaly/count-drop review have moved outside SwiftData or been removed. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; replace capture/review actions before removing `ModelContainer` |
31
-| UI | Prototype exists; Dashboard status reads archive/cache observation rows and shows cache health, with SwiftData retained only for capture/review actions; Snapshots and Data Types tab roots no longer import SwiftData, load Core Data cached observation rows, and open archive/cache-backed detail rows; `SnapshotArchiveDetailView` and `DataTypeArchiveDetailView` read Core Data type/diff summaries and page record drill-down through SQLite; unused legacy SwiftData snapshot/type detail and PDF views have been deleted; record-change evolution and temporal distribution screens now receive DTO rows/cache input instead of querying SwiftData directly; 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 | Move remaining Dashboard capture/review actions away from SwiftData |
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, local device profile settings, operation logging, ContentView preview, Settings data maintenance, legacy detail/PDF views, unused legacy repair/observer services, Dashboard view access, and legacy anomaly/count-drop review have moved outside SwiftData or been removed. Remaining SwiftData imports are inventoried in [`SwiftData-Retirement-Inventory.md`](SwiftData-Retirement-Inventory.md) | Treat as disposable prototype data; replace capture/review actions before removing `ModelContainer` |
31
+| UI | Prototype exists; Dashboard status reads archive/cache observation rows and shows cache health, and `DashboardView` no longer imports SwiftData or reads `ModelContext`; capture/review actions still route through a Dashboard view-model legacy bridge. Snapshots and Data Types tab roots no longer import SwiftData, load Core Data cached observation rows, and open archive/cache-backed detail rows; `SnapshotArchiveDetailView` and `DataTypeArchiveDetailView` read Core Data type/diff summaries and page record drill-down through SQLite; unused legacy SwiftData snapshot/type detail and PDF views have been deleted; record-change evolution and temporal distribution screens now receive DTO rows/cache input instead of querying SwiftData directly; 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 | Move remaining Dashboard view-model capture/review actions away from SwiftData |
32 32
 | Diff/change explanation | SQL diff summaries, paged diff records, aggregate comparisons, and consolidation-evidence labels exist; legacy anomaly/count-drop review has been removed from active flows; the old direct `HealthSnapshot.typeCounts` diff helper has been retired | Keep active diff/count views on archive/cache DTOs |
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 |
34 34
 | Legacy device support | Simplified detail UI mode is implemented for small/accessibility layouts and as a Settings toggle | Remove SwiftData dependency and validate lower deployment targets |
@@ -48,7 +48,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m
48 48
 
49 49
 - SwiftData currently blocks iOS 15-era device support.
50 50
 - Some screens still imply snapshot-count monitoring rather than Time Machine inspection.
51
-- Current UI/cache layers still depend on 6 SwiftData-backed files for launch container, capture review actions, capture bridge writes, and remaining model definitions.
51
+- Current UI/cache layers still depend on 5 SwiftData-backed files for launch container, capture review actions, capture bridge writes, and remaining model definitions.
52 52
 - Snapshots timeline/detail rows, Data Types list/detail rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist.
53 53
 - Legacy SwiftData-only snapshots are reset for archive v2 test installs rather than migrated.
54 54
 - Capture strategy and some legacy SwiftData transition paths may still decode or cache too much data for low-end devices.
+8 -5
HealthProbe/Doc/04-project/Refactoring-Plan.md
@@ -224,7 +224,9 @@ Acceptance:
224 224
 Checklist:
225 225
 - [ ] Replace direct SwiftData `@Query` dependencies for target screens.
226 226
 - [x] Dashboard status reads Core Data cached observation rows and cache health,
227
-  with SwiftData retained only for capture/review actions.
227
+  and `DashboardView` no longer imports SwiftData or reads `ModelContext`.
228
+  Capture/review actions still route through the Dashboard view model's legacy
229
+  bridge.
228 230
 - [x] Observation timeline rows read Core Data cache when available and no
229 231
   longer query `SnapshotDelta` list summaries.
230 232
 - [x] Observation root and archive detail use cached summary/type rows plus
@@ -266,11 +268,12 @@ Checklist:
266 268
   moved to local Codable stores and removed from `ModelContainer`; Settings
267 269
   data maintenance now uses the rebuildable Core Data cache; legacy
268 270
   anomaly/count-drop review has been deleted; Snapshots/Data Types tab roots no
269
-  longer import SwiftData; unused legacy snapshot/type detail and PDF views have
270
-  been deleted; unused legacy lifecycle/observer/repair services have been
271
-  deleted; unused legacy delta service/models have been deleted; `HealthRecord`,
271
+  longer import SwiftData; `DashboardView` no longer imports SwiftData or reads
272
+  `ModelContext`; unused legacy snapshot/type detail and PDF views have been
273
+  deleted; unused legacy lifecycle/observer/repair services have been deleted;
274
+  unused legacy delta service/models have been deleted; `HealthRecord`,
272 275
   `YearlyCount`, and `TypeDistributionBin` are no longer SwiftData models;
273
-  Dashboard capture/review actions and capture bridge writes remain.
276
+  Dashboard view-model capture/review actions and capture bridge writes remain.
274 277
 - [ ] Remove/disable `ModelContainer` as required for target builds.
275 278
 - [x] Add prototype-store ignore/delete/reset path for test installs.
276 279
 - [ ] Verify no old-store compatibility layer remains in active flows.
+10 -7
HealthProbe/Doc/04-project/SwiftData-Retirement-Inventory.md
@@ -9,10 +9,10 @@ local settings stored outside SwiftData where needed.
9 9
 
10 10
 ## Current Count
11 11
 
12
-After moving the Snapshots and Data Types tab roots to archive/cache
13
-observations and deleting unused legacy repair/detail/delta services, 6 app
14
-files still have SwiftData imports because capture, Dashboard review actions,
15
-and remaining model definitions still use prototype snapshot handles.
12
+After moving the Snapshots, Data Types, and Dashboard view roots to
13
+archive/cache observations or view-model bridges, 5 app files still have
14
+SwiftData imports because capture, Dashboard review actions, and remaining model
15
+definitions still use prototype snapshot handles.
16 16
 
17 17
 ## Launch Container
18 18
 
@@ -53,7 +53,6 @@ These active surfaces still use `@Query`, `ModelContext`, or SwiftData model
53 53
 types:
54 54
 
55 55
 - `HealthProbe/ViewModels/DashboardViewModel.swift`
56
-- `HealthProbe/Views/Dashboard/DashboardView.swift`
57 56
 
58 57
 Retirement path:
59 58
 - move capture/review actions away from `ModelContext`;
@@ -87,6 +86,9 @@ The following SwiftData dependencies were removed from active flows:
87 86
 - `HealthProbe/Views/Dashboard/DashboardView.swift` no longer queries
88 87
   `HealthSnapshot` for status rows; Dashboard status now uses archive/cache rows
89 88
   only. SwiftData remains there for capture/review actions.
89
+- `HealthProbe/Views/Dashboard/DashboardView.swift` no longer imports SwiftData
90
+  or reads `ModelContext`. Capture/review actions now route through the
91
+  Dashboard view model's legacy bridge while that bridge is retired.
90 92
 - `HealthProbe/Views/DataTypes/RecordChangeEvolutionChart.swift` now accepts a
91 93
   small `RecordChangeEvolutionSnapshot` DTO and loads archive/cache counts
92 94
   without importing SwiftData or querying `SnapshotDelta`.
@@ -149,6 +151,7 @@ The following SwiftData dependencies were removed from active flows:
149 151
 
150 152
 ## Next Recommended Slices
151 153
 
152
-1. Move `DashboardView` capture review actions away from `ModelContext`.
153
-2. Stop writing prototype `HealthSnapshot` bridge rows during capture once
154
+1. Stop writing prototype `HealthSnapshot` bridge rows during capture once
154 155
    Dashboard actions no longer need them.
156
+2. Remove `HealthSnapshot`/`TypeCount` from active capture/review state and then
157
+   delete the launch `ModelContainer`.
+3 -1
HealthProbe/HealthProbeApp.swift
@@ -7,7 +7,9 @@ struct HealthProbeApp: App {
7 7
 
8 8
     var sharedModelContainer: ModelContainer = {
9 9
         do {
10
-            return try createModelContainer()
10
+            let container = try createModelContainer()
11
+            LegacySwiftDataBridge.configure(container: container)
12
+            return container
11 13
         } catch {
12 14
             fatalError("Could not create ModelContainer: \(error)")
13 15
         }
+52 -10
HealthProbe/ViewModels/DashboardViewModel.swift
@@ -1,6 +1,37 @@
1 1
 import Foundation
2 2
 import SwiftData
3 3
 
4
+enum LegacySwiftDataBridgeError: LocalizedError {
5
+    case notConfigured
6
+
7
+    var errorDescription: String? {
8
+        switch self {
9
+        case .notConfigured:
10
+            return "Legacy SwiftData bridge is not configured."
11
+        }
12
+    }
13
+}
14
+
15
+enum LegacySwiftDataBridge {
16
+    private static var container: ModelContainer?
17
+    private static var context: ModelContext?
18
+
19
+    static func configure(container: ModelContainer) {
20
+        self.container = container
21
+        context = ModelContext(container)
22
+    }
23
+
24
+    static func modelContext() throws -> ModelContext {
25
+        if let context { return context }
26
+        guard let container else {
27
+            throw LegacySwiftDataBridgeError.notConfigured
28
+        }
29
+        let context = ModelContext(container)
30
+        self.context = context
31
+        return context
32
+    }
33
+}
34
+
4 35
 @Observable
5 36
 final class DashboardViewModel {
6 37
     var isRequestingAuth = false
@@ -44,7 +75,6 @@ final class DashboardViewModel {
44 75
     }
45 76
 
46 77
     func createSnapshot(
47
-        context: ModelContext,
48 78
         selectedTypeIDs: Set<String>,
49 79
         adaptiveTimeoutsEnabled: Bool,
50 80
         triggerReason: String = "manual",
@@ -56,6 +86,16 @@ final class DashboardViewModel {
56 86
             return
57 87
         }
58 88
 
89
+        let context: ModelContext
90
+        do {
91
+            context = try LegacySwiftDataBridge.modelContext()
92
+        } catch {
93
+            snapshotError = "Failed to access legacy snapshot cache: \(error.localizedDescription)"
94
+            snapshotProgress = .idle
95
+            showProgressSheet = true
96
+            return
97
+        }
98
+
59 99
         isCreatingSnapshot = true
60 100
         snapshotError = nil
61 101
         snapshotProgress = .fetching
@@ -140,7 +180,7 @@ final class DashboardViewModel {
140 180
                 if shouldAutoSaveKnownUnauthorizedPartial(snapshot: snapshot, failedCount: failedCount, context: context) {
141 181
                     snapshotProgressMessage = "Known unavailable metrics"
142 182
                     snapshotProgressDetail = "Snapshot was auto-saved as partial because unavailable metrics were already confirmed in the previous snapshot."
143
-                    await savePartialSnapshot(context: context, keepSheetOpenForReview: true)
183
+                    await savePartialSnapshot(keepSheetOpenForReview: true)
144 184
                     return
145 185
                 }
146 186
 
@@ -216,7 +256,7 @@ final class DashboardViewModel {
216 256
         }
217 257
     }
218 258
 
219
-    func treatAmbiguousMetricsAsUnauthorized(context: ModelContext) async {
259
+    func treatAmbiguousMetricsAsUnauthorized() async {
220 260
         guard let snapshot = pendingAmbiguousSnapshot else { return }
221 261
         let ambiguousIDs = Set(ambiguousDisappearedMetrics.map(\.id))
222 262
         let typeCounts = snapshot.typeCounts ?? []
@@ -232,6 +272,7 @@ final class DashboardViewModel {
232 272
         snapshot.snapshotQuality = healthKit.deriveSnapshotQuality(from: typeCounts)
233 273
 
234 274
         do {
275
+            let context = try LegacySwiftDataBridge.modelContext()
235 276
             let saved = try await healthKit.savePartialSnapshot(snapshot, in: context)
236 277
             finishSavedReviewedSnapshot(saved)
237 278
         } catch {
@@ -240,10 +281,11 @@ final class DashboardViewModel {
240 281
         }
241 282
     }
242 283
 
243
-    func treatAmbiguousMetricsAsDeleted(context: ModelContext) async {
284
+    func treatAmbiguousMetricsAsDeleted() async {
244 285
         guard let snapshot = pendingAmbiguousSnapshot else { return }
245 286
 
246 287
         do {
288
+            let context = try LegacySwiftDataBridge.modelContext()
247 289
             let saved = try await healthKit.saveReviewedCompleteSnapshot(snapshot, in: context)
248 290
             finishSavedReviewedSnapshot(saved)
249 291
         } catch {
@@ -252,12 +294,12 @@ final class DashboardViewModel {
252 294
         }
253 295
     }
254 296
 
255
-    func retryWithPermissions(context: ModelContext, selectedTypeIDs: Set<String>, adaptiveTimeoutsEnabled: Bool) async {
297
+    func retryWithPermissions(selectedTypeIDs: Set<String>, adaptiveTimeoutsEnabled: Bool) async {
256 298
         isRequestingAuth = true
257 299
         defer { isRequestingAuth = false }
258 300
         do {
259 301
             try await healthKit.requestAuthorization()
260
-            await createSnapshot(context: context, selectedTypeIDs: selectedTypeIDs, adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled)
302
+            await createSnapshot(selectedTypeIDs: selectedTypeIDs, adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled)
261 303
         } catch {
262 304
             snapshotError = "Failed to request permissions: \(error.localizedDescription)"
263 305
             snapshotProgress = .idle
@@ -266,7 +308,6 @@ final class DashboardViewModel {
266 308
     }
267 309
 
268 310
     func retryFailedMetricsWithExtendedTimeout(
269
-        context: ModelContext,
270 311
         selectedTypeIDs: Set<String>,
271 312
         adaptiveTimeoutsEnabled: Bool
272 313
     ) async {
@@ -278,7 +319,6 @@ final class DashboardViewModel {
278 319
         guard hasTimeout else { return }
279 320
 
280 321
         await createSnapshot(
281
-            context: context,
282 322
             selectedTypeIDs: selectedTypeIDs,
283 323
             adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled,
284 324
             triggerReason: "retryFailedMetrics",
@@ -286,7 +326,7 @@ final class DashboardViewModel {
286 326
         )
287 327
     }
288 328
 
289
-    func savePartialSnapshot(context: ModelContext, keepSheetOpenForReview: Bool = false) async {
329
+    func savePartialSnapshot(keepSheetOpenForReview: Bool = false) async {
290 330
         guard let snapshot = pendingPartialSnapshot else {
291 331
             if !keepSheetOpenForReview {
292 332
                 fetchProgress = nil
@@ -297,6 +337,7 @@ final class DashboardViewModel {
297 337
         }
298 338
 
299 339
         do {
340
+            let context = try LegacySwiftDataBridge.modelContext()
300 341
             let saved = try await healthKit.savePartialSnapshot(snapshot, in: context)
301 342
             completedSnapshotID = saved.id
302 343
             completedSnapshotTimestamp = saved.timestamp
@@ -329,12 +370,13 @@ final class DashboardViewModel {
329 370
         snapshotProgress = .idle
330 371
     }
331 372
 
332
-    func discardSnapshot(context: ModelContext) async {
373
+    func discardSnapshot() async {
333 374
         pendingPartialSnapshot = nil
334 375
         pendingAmbiguousSnapshot = nil
335 376
         ambiguousDisappearedMetrics = []
336 377
         if let snapshotID = completedSnapshotID {
337 378
             do {
379
+                let context = try LegacySwiftDataBridge.modelContext()
338 380
                 var descriptor = FetchDescriptor<HealthSnapshot>(
339 381
                     predicate: #Predicate<HealthSnapshot> { $0.id == snapshotID }
340 382
                 )
+5 -11
HealthProbe/Views/Dashboard/DashboardView.swift
@@ -1,10 +1,8 @@
1 1
 import SwiftUI
2
-import SwiftData
3 2
 import HealthKit
4 3
 import UIKit
5 4
 
6 5
 struct DashboardView: View {
7
-    @Environment(\.modelContext) private var modelContext
8 6
     @Environment(AppSettings.self) private var appSettings
9 7
     @State private var viewModel = DashboardViewModel()
10 8
     @State private var currentDeviceProfile = LocalDeviceProfileStore.profile(for: AppSettings.currentDeviceID)
@@ -1038,13 +1036,11 @@ struct DashboardView: View {
1038 1036
                     Task {
1039 1037
                         if viewModel.canRetryWithPermissions {
1040 1038
                             await viewModel.retryWithPermissions(
1041
-                                context: modelContext,
1042 1039
                                 selectedTypeIDs: appSettings.selectedTypeIDs,
1043 1040
                                 adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled
1044 1041
                             )
1045 1042
                         } else {
1046 1043
                             await viewModel.retryFailedMetricsWithExtendedTimeout(
1047
-                                context: modelContext,
1048 1044
                                 selectedTypeIDs: appSettings.selectedTypeIDs,
1049 1045
                                 adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled
1050 1046
                             )
@@ -1058,7 +1054,7 @@ struct DashboardView: View {
1058 1054
             }
1059 1055
 
1060 1056
             Button {
1061
-                Task { await viewModel.savePartialSnapshot(context: modelContext) }
1057
+                Task { await viewModel.savePartialSnapshot() }
1062 1058
             } label: {
1063 1059
                 Text("Save Partial").frame(maxWidth: .infinity)
1064 1060
             }
@@ -1067,7 +1063,7 @@ struct DashboardView: View {
1067 1063
             .accessibilityLabel("Save partial snapshot")
1068 1064
 
1069 1065
             Button {
1070
-                Task { await viewModel.discardSnapshot(context: modelContext) }
1066
+                Task { await viewModel.discardSnapshot() }
1071 1067
             } label: {
1072 1068
                 Text("Discard").foregroundStyle(Color.criticalRed).frame(maxWidth: .infinity)
1073 1069
             }
@@ -1080,7 +1076,7 @@ struct DashboardView: View {
1080 1076
     private var ambiguousResolutionActionsSection: some View {
1081 1077
         VStack(spacing: 10) {
1082 1078
             Button {
1083
-                Task { await viewModel.treatAmbiguousMetricsAsUnauthorized(context: modelContext) }
1079
+                Task { await viewModel.treatAmbiguousMetricsAsUnauthorized() }
1084 1080
             } label: {
1085 1081
                 Label("Treat as Unauthorized", systemImage: "lock.slash")
1086 1082
                     .frame(maxWidth: .infinity)
@@ -1090,7 +1086,7 @@ struct DashboardView: View {
1090 1086
             .accessibilityLabel("Treat missing metrics as unauthorized")
1091 1087
 
1092 1088
             Button {
1093
-                Task { await viewModel.treatAmbiguousMetricsAsDeleted(context: modelContext) }
1089
+                Task { await viewModel.treatAmbiguousMetricsAsDeleted() }
1094 1090
             } label: {
1095 1091
                 Label("Treat as Missing", systemImage: "minus.circle")
1096 1092
                     .frame(maxWidth: .infinity)
@@ -1100,7 +1096,7 @@ struct DashboardView: View {
1100 1096
             .accessibilityLabel("Treat missing metrics as absent from the current Health store")
1101 1097
 
1102 1098
             Button {
1103
-                Task { await viewModel.discardSnapshot(context: modelContext) }
1099
+                Task { await viewModel.discardSnapshot() }
1104 1100
             } label: {
1105 1101
                 Label("Cancel Save", systemImage: "xmark")
1106 1102
                     .foregroundStyle(Color.criticalRed)
@@ -1206,7 +1202,6 @@ struct DashboardView: View {
1206 1202
             Button {
1207 1203
                 Task {
1208 1204
                     await viewModel.createSnapshot(
1209
-                        context: modelContext,
1210 1205
                         selectedTypeIDs: appSettings.selectedTypeIDs,
1211 1206
                         adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled
1212 1207
                     )
@@ -1360,6 +1355,5 @@ extension Bundle {
1360 1355
 
1361 1356
 #Preview {
1362 1357
     DashboardView()
1363
-        .modelContainer(for: [HealthSnapshot.self], inMemory: true)
1364 1358
         .environment(AppSettings())
1365 1359
 }