@@ -25,10 +25,10 @@ There are no real deployments, only test installations. Existing prototype datab |
||
| 25 | 25 |
|------|----------------|--------------------| |
| 26 | 26 |
| Product docs | Updated | Keep `HealthProbe/Doc/README.md` as canonical index | |
| 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 |
-| 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 replacing remaining transition detail/PDF paths with archive/cache DTOs | |
|
| 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, 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 and transition detail/PDF paths 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; legacy `SnapshotDetailView`/`DataTypeSnapshotDetailView` remain as transition views outside the active tab roots; 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, with already-existing SwiftData detail cache as transition fallback | Remove or isolate remaining SwiftData transition detail/PDF paths | |
|
| 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, 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 | |
|
| 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 | Continue moving remaining SwiftData fallback detail paths to 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 | |
@@ -38,7 +38,7 @@ There are no real deployments, only test installations. Existing prototype datab |
||
| 38 | 38 |
|
| 39 | 39 |
Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.md). |
| 40 | 40 |
|
| 41 |
-1. Remove or isolate remaining SwiftData transition detail/PDF paths. |
|
| 41 |
+1. Move remaining Dashboard capture/review actions away from SwiftData. |
|
| 42 | 42 |
2. Add targeted cache invalidation for affected observation/type ranges. |
| 43 | 43 |
3. Finish remaining UI language cleanup from anomaly/status to observation/diff/export where legacy model names still leak into active flows. |
| 44 | 44 |
4. Complete recovery-compatible export metadata, CSV output, and reproducibility checks. |
@@ -48,9 +48,9 @@ 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 17 SwiftData-backed files for launch container, capture review actions, legacy transition detail paths, model definitions, and PDF paths. |
|
| 51 |
+- Current UI/cache layers still depend on 15 SwiftData-backed files for launch container, capture review actions, model definitions, and legacy repair services. |
|
| 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 |
-- Legacy SwiftData-only snapshots can show diffs without archive-backed values; they are now reset for archive v2 test installs rather than migrated. |
|
| 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. |
| 55 | 55 |
- Old prototype database compatibility is no longer required. |
| 56 | 56 |
- Initial SQLite archive tests cover open/init/reset/idempotency, snapshot-level observation grouping, legacy mirror removal, small observation diffs, large synthetic diff pagination, formal timing/memory metrics, materialized aggregate comparison, source/provenance breakdowns, consolidation-evidence labels, export preview, paged JSON output, and manifest row persistence. |
@@ -232,6 +232,8 @@ Checklist: |
||
| 232 | 232 |
- [x] Data Types list rows use Core Data cached counts plus SQLite `diffSummary` and no longer fall back to SwiftData `TypeCount` traversal. |
| 233 | 233 |
- [x] Data Types root reads Core Data cached observation rows directly and no longer imports SwiftData. |
| 234 | 234 |
- [x] Snapshots root reads Core Data cached observation rows directly and no longer imports SwiftData. |
| 235 |
+- [x] Delete unused legacy SwiftData snapshot/type detail views and the PDF |
|
| 236 |
+ exporter tied to those views. |
|
| 235 | 237 |
- [x] Data type detail uses Core Data/SQLite `diffSummary` when archive observation ids exist and no longer queries `SnapshotDelta`/`TypeDelta` or rebuilds legacy detail caches from the UI. |
| 236 | 238 |
- [x] Remove unused legacy Type Evolution timeline and direct `HealthSnapshot.typeCounts` diff helper. |
| 237 | 239 |
- [x] Data type new/missing drill-down pages through SQLite `diffRecords` when archive observation ids exist. |
@@ -261,8 +263,8 @@ Checklist: |
||
| 261 | 263 |
moved to local Codable stores and removed from `ModelContainer`; Settings |
| 262 | 264 |
data maintenance now uses the rebuildable Core Data cache; legacy |
| 263 | 265 |
anomaly/count-drop review has been deleted; Snapshots/Data Types tab roots no |
| 264 |
- longer import SwiftData; Dashboard capture/review actions and legacy |
|
| 265 |
- transition detail/PDF paths remain. |
|
| 266 |
+ longer import SwiftData; unused legacy snapshot/type detail and PDF views have |
|
| 267 |
+ been deleted; Dashboard capture/review actions remain. |
|
| 266 | 268 |
- [ ] Remove/disable `ModelContainer` as required for target builds. |
| 267 | 269 |
- [x] Add prototype-store ignore/delete/reset path for test installs. |
| 268 | 270 |
- [ ] Verify no old-store compatibility layer remains in active flows. |
@@ -10,9 +10,10 @@ local settings stored outside SwiftData where needed. |
||
| 10 | 10 |
## Current Count |
| 11 | 11 |
|
| 12 | 12 |
After moving the Snapshots and Data Types tab roots to archive/cache |
| 13 |
-observations, 17 app files still have SwiftData imports because capture, |
|
| 14 |
-Dashboard review actions, legacy detail transition paths, model definitions, and |
|
| 15 |
-PDF/export transition paths still use prototype snapshot handles. |
|
| 13 |
+observations and deleting the unused legacy snapshot/type detail views, 15 app |
|
| 14 |
+files still have SwiftData imports because capture, Dashboard review actions, |
|
| 15 |
+model definitions, and legacy repair services still use prototype snapshot |
|
| 16 |
+handles. |
|
| 16 | 17 |
|
| 17 | 18 |
## Launch Container |
| 18 | 19 |
|
@@ -21,9 +22,8 @@ This file keeps SwiftData required at app launch: |
||
| 21 | 22 |
- `HealthProbe/HealthProbeApp.swift` |
| 22 | 23 |
|
| 23 | 24 |
Retirement path: |
| 24 |
-- replace prototype snapshot model dependencies in tab roots; |
|
| 25 |
-- remove `.modelContainer(...)` once no active view needs `@Query` or |
|
| 26 |
- `ModelContext`. |
|
| 25 |
+- remove `.modelContainer(...)` once no active view or capture service needs |
|
| 26 |
+ `@Query` or `ModelContext`. |
|
| 27 | 27 |
|
| 28 | 28 |
## Legacy Model Definitions |
| 29 | 29 |
|
@@ -55,12 +55,11 @@ These services still write/read legacy SwiftData transition models: |
||
| 55 | 55 |
- `HealthProbe/Utilities/TypeCountArchiveRepair.swift` |
| 56 | 56 |
|
| 57 | 57 |
Retirement path: |
| 58 |
-- make capture persist archive observations first and expose only bridge ids |
|
| 59 |
- while transition UI still exists; |
|
| 60 |
-- move operation logging out of SwiftData; |
|
| 58 |
+- make capture persist archive observations without writing prototype |
|
| 59 |
+ `HealthSnapshot` bridge rows; |
|
| 61 | 60 |
- delete legacy record repair once old SwiftData stores are no longer opened; |
| 62 |
-- remove snapshot deletion/repair logic after archive/cache navigation replaces |
|
| 63 |
- prototype snapshots. |
|
| 61 |
+- remove snapshot deletion/repair logic after capture and Dashboard actions no |
|
| 62 |
+ longer require prototype snapshots. |
|
| 64 | 63 |
|
| 65 | 64 |
## UI And View Models |
| 66 | 65 |
|
@@ -69,14 +68,10 @@ types: |
||
| 69 | 68 |
|
| 70 | 69 |
- `HealthProbe/ViewModels/DashboardViewModel.swift` |
| 71 | 70 |
- `HealthProbe/Views/Dashboard/DashboardView.swift` |
| 72 |
-- `HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift` |
|
| 73 |
-- `HealthProbe/Views/Snapshots/SnapshotDetailView.swift` |
|
| 74 | 71 |
|
| 75 | 72 |
Retirement path: |
| 76 |
-- replace detail navigation parameters from SwiftData models to observation/type |
|
| 77 |
- DTOs; |
|
| 78 |
-- remove remaining snapshot/cache SwiftData rows from active flows; |
|
| 79 |
-- keep paged record drill-down and export paths on archive APIs. |
|
| 73 |
+- move capture/review actions away from `ModelContext`; |
|
| 74 |
+- keep status/report rows on archive/cache APIs. |
|
| 80 | 75 |
|
| 81 | 76 |
## Removed During This Pass |
| 82 | 77 |
|
@@ -93,7 +88,7 @@ The following SwiftData dependencies were removed from active flows: |
||
| 93 | 88 |
- `HealthProbe/Models/DeviceProfile.swift` was deleted. |
| 94 | 89 |
- Device display name/color settings now use |
| 95 | 90 |
`HealthProbe/Utilities/LocalDeviceProfile.swift`, a Codable local store used |
| 96 |
- by Settings, Dashboard, Snapshots, and legacy PDF export. |
|
| 91 |
+ by Settings, Dashboard, and archive/cache snapshot rows. |
|
| 97 | 92 |
- `HealthProbe/Models/OperationLog.swift` was deleted. |
| 98 | 93 |
- Snapshot deletion logging now uses |
| 99 | 94 |
`HealthProbe/Utilities/LocalOperationLog.swift`, a bounded Codable local log |
@@ -129,16 +124,11 @@ The following SwiftData dependencies were removed from active flows: |
||
| 129 | 124 |
SwiftData or queries `HealthSnapshot`; it loads Core Data cached observation |
| 130 | 125 |
rows and opens `SnapshotArchiveDetailView`, an archive/cache-only detail view |
| 131 | 126 |
that feeds Data Type drill-down through observation ids and cached summaries. |
| 132 |
-- `HealthProbe/Views/Snapshots/SnapshotDetailView.swift` no longer queries |
|
| 133 |
- `SnapshotDelta`/`TypeDelta` or carries the old SwiftData type-delta/chart |
|
| 134 |
- fallback. Snapshot detail type rows now require archive/cache summaries; the |
|
| 135 |
- temporary SwiftData dependency is limited to snapshot navigation, metadata, |
|
| 136 |
- and PDF export handles. |
|
| 137 |
-- `HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift` no longer |
|
| 138 |
- queries `SnapshotDelta`/`TypeDelta` and no longer rebuilds legacy |
|
| 139 |
- `TypeCount.detailCache` rows from the UI. It reads Core Data/SQLite diff |
|
| 140 |
- summaries first and only displays an already-existing legacy detail cache as a |
|
| 141 |
- transition fallback. |
|
| 127 |
+- The unused legacy SwiftData |
|
| 128 |
+ `HealthProbe/Views/Snapshots/SnapshotDetailView.swift`, |
|
| 129 |
+ `HealthProbe/Views/Snapshots/DataTypeSnapshotDetailView.swift`, and |
|
| 130 |
+ `HealthProbe/Utilities/SnapshotPDFExporter.swift` were deleted. Active |
|
| 131 |
+ snapshot/type drill-down now uses archive/cache DTOs. |
|
| 142 | 132 |
- The unused `HealthProbe/Views/DataTypes/TypeEvolutionTimeline.swift` legacy |
| 143 | 133 |
chart was deleted, and the remaining `TypeDiff`/`DiffFilter` DTOs now live in |
| 144 | 134 |
`HealthProbe/Models/TypeDiff.swift` instead of the removed |
@@ -152,5 +142,5 @@ The following SwiftData dependencies were removed from active flows: |
||
| 152 | 142 |
## Next Recommended Slices |
| 153 | 143 |
|
| 154 | 144 |
1. Move `DashboardView` capture review actions away from `ModelContext`. |
| 155 |
-2. Delete or isolate unused SwiftData snapshot/type detail transition views once |
|
| 156 |
- PDF/export and any remaining preview paths have archive/cache replacements. |
|
| 145 |
+2. Stop writing prototype `HealthSnapshot` bridge rows during capture once |
|
| 146 |
+ Dashboard actions no longer need them. |
|
@@ -1,380 +0,0 @@ |
||
| 1 |
-import CoreGraphics |
|
| 2 |
-import CoreText |
|
| 3 |
-import Foundation |
|
| 4 |
- |
|
| 5 |
-// MARK: - Report data (value type, Sendable — passed to background task) |
|
| 6 |
- |
|
| 7 |
-struct SnapshotReportData: Sendable {
|
|
| 8 |
- let timestamp: Date |
|
| 9 |
- let osVersion: String |
|
| 10 |
- let deviceName: String |
|
| 11 |
- let deviceID: String |
|
| 12 |
- let typeCounts: [TypeCountData] |
|
| 13 |
- let baseline: BaselineData? |
|
| 14 |
- |
|
| 15 |
- struct TypeCountData: Sendable {
|
|
| 16 |
- let identifier: String |
|
| 17 |
- let displayName: String |
|
| 18 |
- let count: Int |
|
| 19 |
- } |
|
| 20 |
- |
|
| 21 |
- struct BaselineData: Sendable {
|
|
| 22 |
- let timestamp: Date |
|
| 23 |
- let totalChange: Int |
|
| 24 |
- let changedCount: Int |
|
| 25 |
- let countByIdentifier: [String: Int] |
|
| 26 |
- } |
|
| 27 |
-} |
|
| 28 |
- |
|
| 29 |
-// MARK: - Exporter |
|
| 30 |
- |
|
| 31 |
-enum SnapshotPDFExporter {
|
|
| 32 |
- |
|
| 33 |
- /// Reads SwiftData models. Must be called on the main actor. |
|
| 34 |
- @MainActor |
|
| 35 |
- static func extractReportData( |
|
| 36 |
- snapshot: HealthSnapshot, |
|
| 37 |
- baseline: HealthSnapshot?, |
|
| 38 |
- profile: LocalDeviceProfile? |
|
| 39 |
- ) -> SnapshotReportData {
|
|
| 40 |
- let profileName: String? = {
|
|
| 41 |
- guard let n = profile?.name, !n.isEmpty else { return nil }
|
|
| 42 |
- return n |
|
| 43 |
- }() |
|
| 44 |
- |
|
| 45 |
- let typeCounts = (snapshot.typeCounts ?? []) |
|
| 46 |
- .sorted { $0.displayName.localizedCompare($1.displayName) == .orderedAscending }
|
|
| 47 |
- .map {
|
|
| 48 |
- SnapshotReportData.TypeCountData( |
|
| 49 |
- identifier: $0.typeIdentifier, |
|
| 50 |
- displayName: $0.displayName, |
|
| 51 |
- count: $0.count |
|
| 52 |
- ) |
|
| 53 |
- } |
|
| 54 |
- |
|
| 55 |
- let baselineData: SnapshotReportData.BaselineData? |
|
| 56 |
- if let baseline {
|
|
| 57 |
- let baselineCountByIdentifier = Dictionary( |
|
| 58 |
- uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0.count) }
|
|
| 59 |
- ) |
|
| 60 |
- let comparableCurrentCounts = (snapshot.typeCounts ?? []).filter { currentType in
|
|
| 61 |
- currentType.quality == .complete && |
|
| 62 |
- (baseline.typeCounts ?? []).contains {
|
|
| 63 |
- $0.typeIdentifier == currentType.typeIdentifier && $0.quality == .complete |
|
| 64 |
- } |
|
| 65 |
- } |
|
| 66 |
- let totalChange = comparableCurrentCounts.reduce(0) { partial, currentType in
|
|
| 67 |
- partial + abs(currentType.count - (baselineCountByIdentifier[currentType.typeIdentifier] ?? 0)) |
|
| 68 |
- } |
|
| 69 |
- let changedCount = comparableCurrentCounts.filter {
|
|
| 70 |
- $0.count != (baselineCountByIdentifier[$0.typeIdentifier] ?? 0) |
|
| 71 |
- }.count |
|
| 72 |
- |
|
| 73 |
- baselineData = SnapshotReportData.BaselineData( |
|
| 74 |
- timestamp: baseline.timestamp, |
|
| 75 |
- totalChange: totalChange, |
|
| 76 |
- changedCount: changedCount, |
|
| 77 |
- countByIdentifier: baselineCountByIdentifier |
|
| 78 |
- ) |
|
| 79 |
- } else {
|
|
| 80 |
- baselineData = nil |
|
| 81 |
- } |
|
| 82 |
- |
|
| 83 |
- // Device ID is truncated to first 8 chars — enough for local correlation, |
|
| 84 |
- // not enough to uniquely identify the device in a shared report. |
|
| 85 |
- let rawID = snapshot.deviceID |
|
| 86 |
- let displayID = rawID.isEmpty ? "—" : String(rawID.prefix(8)) + "…" |
|
| 87 |
- |
|
| 88 |
- return SnapshotReportData( |
|
| 89 |
- timestamp: snapshot.timestamp, |
|
| 90 |
- osVersion: snapshot.osVersion.isEmpty ? "—" : snapshot.osVersion, |
|
| 91 |
- deviceName: profileName ?? "This Device", |
|
| 92 |
- deviceID: displayID, |
|
| 93 |
- typeCounts: typeCounts, |
|
| 94 |
- baseline: baselineData |
|
| 95 |
- ) |
|
| 96 |
- } |
|
| 97 |
- |
|
| 98 |
- /// Generates PDF using only CoreGraphics + CoreText. Safe to call off the main thread. |
|
| 99 |
- static func generatePDF(from data: SnapshotReportData) -> Data {
|
|
| 100 |
- let pageSize = CGSize(width: 595.2, height: 841.8) |
|
| 101 |
- let pdfData = NSMutableData() |
|
| 102 |
- guard let consumer = CGDataConsumer(data: pdfData as CFMutableData) else { return Data() }
|
|
| 103 |
- var mediaBox = CGRect(origin: .zero, size: pageSize) |
|
| 104 |
- guard let ctx = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { return Data() }
|
|
| 105 |
- |
|
| 106 |
- let pen = Pen(ctx: ctx, pageSize: pageSize, margin: 48) |
|
| 107 |
- pen.beginPage() |
|
| 108 |
- |
|
| 109 |
- drawPageHeader(pen, timestamp: data.timestamp) |
|
| 110 |
- drawSummarySection(pen, data: data) |
|
| 111 |
- drawDeviceSection(pen, data: data) |
|
| 112 |
- if let baseline = data.baseline {
|
|
| 113 |
- drawComparisonSection(pen, baseline: baseline) |
|
| 114 |
- } |
|
| 115 |
- drawDataTypesSection(pen, data: data) |
|
| 116 |
- pen.endPage() |
|
| 117 |
- |
|
| 118 |
- ctx.closePDF() |
|
| 119 |
- return pdfData as Data |
|
| 120 |
- } |
|
| 121 |
- |
|
| 122 |
- // MARK: - Sections |
|
| 123 |
- |
|
| 124 |
- private static func drawPageHeader(_ pen: Pen, timestamp: Date) {
|
|
| 125 |
- text("HealthProbe", x: pen.margin, y: pen.y, font: pf(10), color: .secondary, pen: pen)
|
|
| 126 |
- pen.advance(16) |
|
| 127 |
- text("Snapshot Report", x: pen.margin, y: pen.y, font: pf(22, .bold), color: .primary, pen: pen)
|
|
| 128 |
- pen.advance(30) |
|
| 129 |
- text("Generated \(formatted(Date()))", x: pen.margin, y: pen.y, font: pf(9), color: .secondary, pen: pen)
|
|
| 130 |
- pen.advance(14) |
|
| 131 |
- rule(pen) |
|
| 132 |
- pen.advance(16) |
|
| 133 |
- } |
|
| 134 |
- |
|
| 135 |
- private static func drawSummarySection(_ pen: Pen, data: SnapshotReportData) {
|
|
| 136 |
- let total = data.typeCounts.filter { $0.count > 0 }.reduce(0) { $0 + $1.count }
|
|
| 137 |
- sectionTitle(pen, "Summary") |
|
| 138 |
- keyValue(pen, "Captured", value: formatted(data.timestamp)) |
|
| 139 |
- keyValue(pen, "Tracked Types", value: "\(data.typeCounts.count)") |
|
| 140 |
- keyValue(pen, "Total Records", value: "\(total)") |
|
| 141 |
- pen.advance(12) |
|
| 142 |
- } |
|
| 143 |
- |
|
| 144 |
- private static func drawDeviceSection(_ pen: Pen, data: SnapshotReportData) {
|
|
| 145 |
- sectionTitle(pen, "Device") |
|
| 146 |
- keyValue(pen, "Name", value: data.deviceName) |
|
| 147 |
- keyValue(pen, "OS", value: data.osVersion) |
|
| 148 |
- keyValue(pen, "Device ID", value: data.deviceID) |
|
| 149 |
- pen.advance(12) |
|
| 150 |
- } |
|
| 151 |
- |
|
| 152 |
- private static func drawComparisonSection(_ pen: Pen, baseline: SnapshotReportData.BaselineData) {
|
|
| 153 |
- sectionTitle(pen, "Comparison vs. Baseline") |
|
| 154 |
- keyValue(pen, "Baseline Date", value: formatted(baseline.timestamp)) |
|
| 155 |
- keyValue(pen, "Total Changes", value: baseline.totalChange == 0 ? "None" : "\(baseline.totalChange) records") |
|
| 156 |
- keyValue(pen, "Changed Types", value: "\(baseline.changedCount)") |
|
| 157 |
- pen.advance(12) |
|
| 158 |
- } |
|
| 159 |
- |
|
| 160 |
- private static func drawDataTypesSection(_ pen: Pen, data: SnapshotReportData) {
|
|
| 161 |
- guard !data.typeCounts.isEmpty else { return }
|
|
| 162 |
- let hasBaseline = data.baseline != nil |
|
| 163 |
- sectionTitle(pen, "Data Types (\(data.typeCounts.count))") |
|
| 164 |
- tableHeader(pen, hasBaseline: hasBaseline) |
|
| 165 |
- for tc in data.typeCounts {
|
|
| 166 |
- pen.checkBreak(height: 16) |
|
| 167 |
- let delta = data.baseline?.countByIdentifier[tc.identifier].map { tc.count - $0 }
|
|
| 168 |
- tableRow(pen, tc: tc, delta: delta, hasBaseline: hasBaseline) |
|
| 169 |
- } |
|
| 170 |
- } |
|
| 171 |
- |
|
| 172 |
- // MARK: - Drawing primitives |
|
| 173 |
- |
|
| 174 |
- private static func sectionTitle(_ pen: Pen, _ title: String) {
|
|
| 175 |
- pen.checkBreak(height: 40) |
|
| 176 |
- text(title, x: pen.margin, y: pen.y, font: pf(12, .semibold), color: .primary, pen: pen) |
|
| 177 |
- pen.advance(18) |
|
| 178 |
- } |
|
| 179 |
- |
|
| 180 |
- private static func keyValue(_ pen: Pen, _ label: String, value: String) {
|
|
| 181 |
- pen.checkBreak(height: 18) |
|
| 182 |
- let f = pf(10) |
|
| 183 |
- text(label, x: pen.margin, y: pen.y, font: f, color: .secondary, pen: pen) |
|
| 184 |
- text(value, x: pen.margin + pen.contentWidth - tw(value, font: f), y: pen.y, font: f, color: .primary, pen: pen) |
|
| 185 |
- pen.advance(16) |
|
| 186 |
- } |
|
| 187 |
- |
|
| 188 |
- private static func tableHeader(_ pen: Pen, hasBaseline: Bool) {
|
|
| 189 |
- let f = pf(8, .semibold) |
|
| 190 |
- let countRight = pen.margin + pen.contentWidth - (hasBaseline ? 100 : 0) |
|
| 191 |
- let deltaRight = pen.margin + pen.contentWidth |
|
| 192 |
- |
|
| 193 |
- text("TYPE", x: pen.margin, y: pen.y, font: f, color: .tertiary, pen: pen)
|
|
| 194 |
- text("COUNT", x: countRight - tw("COUNT", font: f), y: pen.y, font: f, color: .tertiary, pen: pen)
|
|
| 195 |
- if hasBaseline {
|
|
| 196 |
- text("DELTA", x: deltaRight - tw("DELTA", font: f), y: pen.y, font: f, color: .tertiary, pen: pen)
|
|
| 197 |
- } |
|
| 198 |
- pen.advance(12) |
|
| 199 |
- rule(pen, alpha: 0.35, width: 0.25) |
|
| 200 |
- pen.advance(4) |
|
| 201 |
- } |
|
| 202 |
- |
|
| 203 |
- private static func tableRow( |
|
| 204 |
- _ pen: Pen, |
|
| 205 |
- tc: SnapshotReportData.TypeCountData, |
|
| 206 |
- delta: Int?, |
|
| 207 |
- hasBaseline: Bool |
|
| 208 |
- ) {
|
|
| 209 |
- let nf = pf(9) |
|
| 210 |
- let mf = mf(9) |
|
| 211 |
- let countRight = pen.margin + pen.contentWidth - (hasBaseline ? 100 : 0) |
|
| 212 |
- let deltaRight = pen.margin + pen.contentWidth |
|
| 213 |
- let maxNameW = countRight - pen.margin - 12 |
|
| 214 |
- |
|
| 215 |
- var name = tc.displayName |
|
| 216 |
- if tw(name, font: nf) > maxNameW { name = String(name.prefix(45)) + "…" }
|
|
| 217 |
- text(name, x: pen.margin, y: pen.y, font: nf, color: .primary, pen: pen) |
|
| 218 |
- |
|
| 219 |
- let cs = tc.count < 0 ? "err" : "\(tc.count)" |
|
| 220 |
- text(cs, x: countRight - tw(cs, font: mf), y: pen.y, font: mf, color: .primary, pen: pen) |
|
| 221 |
- |
|
| 222 |
- if hasBaseline, let delta {
|
|
| 223 |
- let ds = delta == 0 ? "—" : (delta > 0 ? "+\(delta)" : "\(delta)") |
|
| 224 |
- let col: CGColor = delta > 0 ? .orange : delta < 0 ? .red : .tertiary |
|
| 225 |
- text(ds, x: deltaRight - tw(ds, font: mf), y: pen.y, font: mf, color: col, pen: pen) |
|
| 226 |
- } |
|
| 227 |
- pen.advance(15) |
|
| 228 |
- } |
|
| 229 |
- |
|
| 230 |
- private static func rule(_ pen: Pen, alpha: CGFloat = 1, width: CGFloat = 0.5) {
|
|
| 231 |
- let cgY = pen.pageSize.height - pen.y |
|
| 232 |
- pen.ctx.saveGState() |
|
| 233 |
- pen.ctx.setStrokeColor(CGColor(gray: 0.75, alpha: alpha)) |
|
| 234 |
- pen.ctx.setLineWidth(width) |
|
| 235 |
- pen.ctx.move(to: CGPoint(x: pen.margin, y: cgY)) |
|
| 236 |
- pen.ctx.addLine(to: CGPoint(x: pen.margin + pen.contentWidth, y: cgY)) |
|
| 237 |
- pen.ctx.strokePath() |
|
| 238 |
- pen.ctx.restoreGState() |
|
| 239 |
- pen.advance(6) |
|
| 240 |
- } |
|
| 241 |
- |
|
| 242 |
- // MARK: - CoreText |
|
| 243 |
- |
|
| 244 |
- private static func text( |
|
| 245 |
- _ string: String, |
|
| 246 |
- x: CGFloat, |
|
| 247 |
- y: CGFloat, |
|
| 248 |
- font: CTFont, |
|
| 249 |
- color: CGColor, |
|
| 250 |
- pen: Pen |
|
| 251 |
- ) {
|
|
| 252 |
- let attrStr = NSAttributedString(string: string, attributes: [ |
|
| 253 |
- kCTFontAttributeName as NSAttributedString.Key: font, |
|
| 254 |
- kCTForegroundColorAttributeName as NSAttributedString.Key: color |
|
| 255 |
- ]) |
|
| 256 |
- let line = CTLineCreateWithAttributedString(attrStr) |
|
| 257 |
- let cgY = pen.pageSize.height - y - CTFontGetAscent(font) |
|
| 258 |
- pen.ctx.textMatrix = .identity |
|
| 259 |
- pen.ctx.textPosition = CGPoint(x: x, y: cgY) |
|
| 260 |
- CTLineDraw(line, pen.ctx) |
|
| 261 |
- } |
|
| 262 |
- |
|
| 263 |
- private static func tw(_ string: String, font: CTFont) -> CGFloat {
|
|
| 264 |
- let attrStr = NSAttributedString(string: string, attributes: [ |
|
| 265 |
- kCTFontAttributeName as NSAttributedString.Key: font |
|
| 266 |
- ]) |
|
| 267 |
- return CGFloat(CTLineGetTypographicBounds(CTLineCreateWithAttributedString(attrStr), nil, nil, nil)) |
|
| 268 |
- } |
|
| 269 |
- |
|
| 270 |
- // MARK: - Font helpers (CTFont, no UIKit) |
|
| 271 |
- |
|
| 272 |
- private enum Weight { case regular, semibold, bold }
|
|
| 273 |
- |
|
| 274 |
- private static func pf(_ size: CGFloat, _ weight: Weight = .regular) -> CTFont {
|
|
| 275 |
- let name: CFString |
|
| 276 |
- switch weight {
|
|
| 277 |
- case .regular: name = "HelveticaNeue" as CFString |
|
| 278 |
- case .semibold: name = "HelveticaNeue-Medium" as CFString |
|
| 279 |
- case .bold: name = "HelveticaNeue-Bold" as CFString |
|
| 280 |
- } |
|
| 281 |
- return CTFontCreateWithName(name, size, nil) |
|
| 282 |
- } |
|
| 283 |
- |
|
| 284 |
- private static func mf(_ size: CGFloat) -> CTFont {
|
|
| 285 |
- CTFontCreateWithName("Menlo-Regular" as CFString, size, nil)
|
|
| 286 |
- } |
|
| 287 |
- |
|
| 288 |
- private static func formatted(_ date: Date) -> String {
|
|
| 289 |
- let f = DateFormatter() |
|
| 290 |
- f.dateStyle = .medium |
|
| 291 |
- f.timeStyle = .short |
|
| 292 |
- return f.string(from: date) |
|
| 293 |
- } |
|
| 294 |
-} |
|
| 295 |
- |
|
| 296 |
-// MARK: - CGColor shortcuts |
|
| 297 |
- |
|
| 298 |
-private extension CGColor {
|
|
| 299 |
- static let primary = CGColor(gray: 0.05, alpha: 1) |
|
| 300 |
- static let secondary = CGColor(gray: 0.40, alpha: 1) |
|
| 301 |
- static let tertiary = CGColor(gray: 0.60, alpha: 1) |
|
| 302 |
- static let orange = CGColor(srgbRed: 1.00, green: 0.58, blue: 0.00, alpha: 1) |
|
| 303 |
- static let red = CGColor(srgbRed: 1.00, green: 0.23, blue: 0.19, alpha: 1) |
|
| 304 |
-} |
|
| 305 |
- |
|
| 306 |
-// MARK: - Pen (page state, CoreGraphics only) |
|
| 307 |
- |
|
| 308 |
-private final class Pen {
|
|
| 309 |
- let ctx: CGContext |
|
| 310 |
- let pageSize: CGSize |
|
| 311 |
- let margin: CGFloat |
|
| 312 |
- private(set) var y: CGFloat |
|
| 313 |
- private(set) var pageNumber: Int = 0 |
|
| 314 |
- |
|
| 315 |
- var contentWidth: CGFloat { pageSize.width - margin * 2 }
|
|
| 316 |
- var bottomBoundary: CGFloat { pageSize.height - margin - 28 }
|
|
| 317 |
- |
|
| 318 |
- init(ctx: CGContext, pageSize: CGSize, margin: CGFloat) {
|
|
| 319 |
- self.ctx = ctx |
|
| 320 |
- self.pageSize = pageSize |
|
| 321 |
- self.margin = margin |
|
| 322 |
- self.y = margin |
|
| 323 |
- } |
|
| 324 |
- |
|
| 325 |
- func beginPage() {
|
|
| 326 |
- ctx.beginPDFPage(nil) |
|
| 327 |
- y = margin |
|
| 328 |
- pageNumber += 1 |
|
| 329 |
- } |
|
| 330 |
- |
|
| 331 |
- func endPage() {
|
|
| 332 |
- drawFooter() |
|
| 333 |
- ctx.endPDFPage() |
|
| 334 |
- } |
|
| 335 |
- |
|
| 336 |
- func advance(_ delta: CGFloat) { y += delta }
|
|
| 337 |
- |
|
| 338 |
- func checkBreak(height: CGFloat) {
|
|
| 339 |
- guard y + height > bottomBoundary else { return }
|
|
| 340 |
- endPage() |
|
| 341 |
- beginPage() |
|
| 342 |
- } |
|
| 343 |
- |
|
| 344 |
- private func drawFooter() {
|
|
| 345 |
- let footerFont = CTFontCreateWithName("HelveticaNeue" as CFString, 8, nil)
|
|
| 346 |
- let footerColor = CGColor(gray: 0.6, alpha: 1) |
|
| 347 |
- let ascent = CTFontGetAscent(footerFont) |
|
| 348 |
- let sepCGY = margin // separator y in CGContext (from bottom) |
|
| 349 |
- let textCGY = margin - 10 - ascent // text y in CGContext |
|
| 350 |
- |
|
| 351 |
- // Separator |
|
| 352 |
- ctx.saveGState() |
|
| 353 |
- ctx.setStrokeColor(CGColor(gray: 0.75, alpha: 0.4)) |
|
| 354 |
- ctx.setLineWidth(0.5) |
|
| 355 |
- ctx.move(to: CGPoint(x: margin, y: sepCGY)) |
|
| 356 |
- ctx.addLine(to: CGPoint(x: pageSize.width - margin, y: sepCGY)) |
|
| 357 |
- ctx.strokePath() |
|
| 358 |
- ctx.restoreGState() |
|
| 359 |
- |
|
| 360 |
- func footerLine(_ str: String, x: CGFloat) {
|
|
| 361 |
- let a = NSAttributedString(string: str, attributes: [ |
|
| 362 |
- kCTFontAttributeName as NSAttributedString.Key: footerFont, |
|
| 363 |
- kCTForegroundColorAttributeName as NSAttributedString.Key: footerColor |
|
| 364 |
- ]) |
|
| 365 |
- let line = CTLineCreateWithAttributedString(a) |
|
| 366 |
- ctx.textMatrix = .identity |
|
| 367 |
- ctx.textPosition = CGPoint(x: x, y: textCGY) |
|
| 368 |
- CTLineDraw(line, ctx) |
|
| 369 |
- } |
|
| 370 |
- |
|
| 371 |
- footerLine("HealthProbe — Snapshot Report", x: margin)
|
|
| 372 |
- |
|
| 373 |
- let pageStr = "Page \(pageNumber)" |
|
| 374 |
- let pageAttr = NSAttributedString(string: pageStr, attributes: [ |
|
| 375 |
- kCTFontAttributeName as NSAttributedString.Key: footerFont |
|
| 376 |
- ]) |
|
| 377 |
- let pageWidth = CGFloat(CTLineGetTypographicBounds(CTLineCreateWithAttributedString(pageAttr), nil, nil, nil)) |
|
| 378 |
- footerLine(pageStr, x: pageSize.width - margin - pageWidth) |
|
| 379 |
- } |
|
| 380 |
-} |
|
@@ -1,733 +0,0 @@ |
||
| 1 |
-import SwiftUI |
|
| 2 |
-import SwiftData |
|
| 3 |
- |
|
| 4 |
-struct DataTypeSnapshotDetailView: View {
|
|
| 5 |
- let snapshot: HealthSnapshot |
|
| 6 |
- let typeIdentifier: String |
|
| 7 |
- let displayName: String |
|
| 8 |
- |
|
| 9 |
- @Environment(AppSettings.self) private var appSettings |
|
| 10 |
- @Environment(\.dynamicTypeSize) private var dynamicTypeSize |
|
| 11 |
- @Environment(\.horizontalSizeClass) private var horizontalSizeClass |
|
| 12 |
- @Query(sort: \HealthSnapshot.timestamp) private var allSnapshots: [HealthSnapshot] |
|
| 13 |
- |
|
| 14 |
- @State private var displayedSnapshot: HealthSnapshot? |
|
| 15 |
- @State private var diffState: RecordDiffState = .idle |
|
| 16 |
- @State private var showAddedRecords = false |
|
| 17 |
- @State private var showDisappearedRecords = false |
|
| 18 |
- @State private var showTemporalDistribution = false |
|
| 19 |
- @State private var detailCacheDiagnostic: String? |
|
| 20 |
- @State private var currentCachedTypeSummary: CachedArchiveTypeSummary? |
|
| 21 |
- @State private var previousCachedTypeSummary: CachedArchiveTypeSummary? |
|
| 22 |
- @State private var contentWidth: CGFloat = 744 |
|
| 23 |
- |
|
| 24 |
- private var currentSnapshot: HealthSnapshot {
|
|
| 25 |
- displayedSnapshot ?? snapshot |
|
| 26 |
- } |
|
| 27 |
- |
|
| 28 |
- private var timelineSnapshots: [HealthSnapshot] {
|
|
| 29 |
- allSnapshots.filter { candidate in
|
|
| 30 |
- if currentSnapshot.deviceID.isEmpty {
|
|
| 31 |
- return candidate.deviceID.isEmpty |
|
| 32 |
- } |
|
| 33 |
- return candidate.deviceID == currentSnapshot.deviceID |
|
| 34 |
- } |
|
| 35 |
- .sorted(by: HealthSnapshot.timelineSort) |
|
| 36 |
- } |
|
| 37 |
- |
|
| 38 |
- private var previousSnapshot: HealthSnapshot? {
|
|
| 39 |
- currentSnapshot.previousInTimeline(timelineSnapshots) |
|
| 40 |
- } |
|
| 41 |
- |
|
| 42 |
- private var currentTypeCount: TypeCount? {
|
|
| 43 |
- typeCount(in: currentSnapshot) |
|
| 44 |
- } |
|
| 45 |
- |
|
| 46 |
- private var previousTypeCount: TypeCount? {
|
|
| 47 |
- previousSnapshot.flatMap(typeCount(in:)) |
|
| 48 |
- } |
|
| 49 |
- |
|
| 50 |
- private var isCurrentTypeContentAliasToPrevious: Bool {
|
|
| 51 |
- guard let currentTypeCount, |
|
| 52 |
- let previousTypeCount else { return false }
|
|
| 53 |
- return currentTypeCount.contentEquivalentTypeCountID == previousTypeCount.contentRepresentativeTypeCountID |
|
| 54 |
- } |
|
| 55 |
- |
|
| 56 |
- private var hasTemporalDistributionCache: Bool {
|
|
| 57 |
- temporalDistributionInput != nil |
|
| 58 |
- } |
|
| 59 |
- |
|
| 60 |
- private var temporalDistributionInput: TemporalDistributionInput? {
|
|
| 61 |
- guard let currentTypeCount, |
|
| 62 |
- let previousSnapshot, |
|
| 63 |
- let cache = currentTypeCount.detailCache, |
|
| 64 |
- cache.matchesBaseline(previousSnapshot.id) else { return nil }
|
|
| 65 |
- |
|
| 66 |
- return TemporalDistributionInput( |
|
| 67 |
- displayName: currentTypeCount.displayName, |
|
| 68 |
- currentCount: currentTypeCount.count, |
|
| 69 |
- previousCount: previousTypeCount?.count ?? 0, |
|
| 70 |
- detailCache: cache |
|
| 71 |
- ) |
|
| 72 |
- } |
|
| 73 |
- |
|
| 74 |
- private var diffTaskID: String {
|
|
| 75 |
- [ |
|
| 76 |
- currentSnapshot.id.uuidString, |
|
| 77 |
- previousSnapshot?.id.uuidString ?? "none", |
|
| 78 |
- typeIdentifier |
|
| 79 |
- ].joined(separator: "|") |
|
| 80 |
- } |
|
| 81 |
- |
|
| 82 |
- private var totalDelta: Int? {
|
|
| 83 |
- guard previousSnapshot != nil, |
|
| 84 |
- let currentCount = countValue(for: currentTypeCount), |
|
| 85 |
- let previousCount = countValue(for: previousTypeCount) else { return nil }
|
|
| 86 |
- return currentCount - previousCount |
|
| 87 |
- } |
|
| 88 |
- |
|
| 89 |
- private var currentCountText: String {
|
|
| 90 |
- countText(for: currentTypeCount) |
|
| 91 |
- } |
|
| 92 |
- |
|
| 93 |
- private var previousCountText: String {
|
|
| 94 |
- countText(for: previousTypeCount) |
|
| 95 |
- } |
|
| 96 |
- |
|
| 97 |
- private var quickCurrentCountValue: Int {
|
|
| 98 |
- max(currentCachedTypeSummary?.visibleRecordCount ?? currentTypeCount?.count ?? 0, 0) |
|
| 99 |
- } |
|
| 100 |
- |
|
| 101 |
- private var quickPreviousCountValue: Int {
|
|
| 102 |
- max(previousCachedTypeSummary?.visibleRecordCount ?? previousTypeCount?.count ?? 0, 0) |
|
| 103 |
- } |
|
| 104 |
- |
|
| 105 |
- private var quickAddedDisappeared: (added: Int, disappeared: Int, exact: Bool) {
|
|
| 106 |
- if let previousSnapshot, |
|
| 107 |
- let cache = currentTypeCount?.detailCache, |
|
| 108 |
- cache.matchesBaseline(previousSnapshot.id) {
|
|
| 109 |
- return (cache.addedCount, cache.disappearedCount, true) |
|
| 110 |
- } |
|
| 111 |
- |
|
| 112 |
- let net = quickCurrentCountValue - quickPreviousCountValue |
|
| 113 |
- return (max(net, 0), max(-net, 0), false) |
|
| 114 |
- } |
|
| 115 |
- |
|
| 116 |
- private var recordEvolutionSnapshots: [RecordChangeEvolutionSnapshot] {
|
|
| 117 |
- timelineSnapshots.map { snapshot in
|
|
| 118 |
- let count = max(typeCount(in: snapshot)?.count ?? 0, 0) |
|
| 119 |
- let fallback = recordEvolutionCachedFallback(for: snapshot) |
|
| 120 |
- return RecordChangeEvolutionSnapshot( |
|
| 121 |
- id: snapshot.id, |
|
| 122 |
- timestamp: snapshot.timestamp, |
|
| 123 |
- localSequenceNumber: snapshot.localSequenceNumber, |
|
| 124 |
- previousSnapshotID: snapshot.previousSnapshotID, |
|
| 125 |
- archiveObservationID: snapshot.archiveObservationID, |
|
| 126 |
- count: count, |
|
| 127 |
- fallbackAdded: fallback.added, |
|
| 128 |
- fallbackDisappeared: fallback.disappeared, |
|
| 129 |
- fallbackIsExact: fallback.exact |
|
| 130 |
- ) |
|
| 131 |
- } |
|
| 132 |
- } |
|
| 133 |
- |
|
| 134 |
- private var simplifiedRecordCounts: (added: Int, disappeared: Int, exact: Bool) {
|
|
| 135 |
- if case .loaded(let diff) = diffState {
|
|
| 136 |
- return (diff.addedCount, diff.disappearedCount, true) |
|
| 137 |
- } |
|
| 138 |
- return quickAddedDisappeared |
|
| 139 |
- } |
|
| 140 |
- |
|
| 141 |
- private var currentArchiveObservationID: Int64? {
|
|
| 142 |
- currentSnapshot.archiveObservationID |
|
| 143 |
- } |
|
| 144 |
- |
|
| 145 |
- private var previousArchiveObservationID: Int64? {
|
|
| 146 |
- previousSnapshot?.archiveObservationID |
|
| 147 |
- } |
|
| 148 |
- |
|
| 149 |
- private var isTypeTrackedInCurrentContext: Bool {
|
|
| 150 |
- currentTypeCount != nil || |
|
| 151 |
- previousTypeCount != nil || |
|
| 152 |
- currentCachedTypeSummary != nil || |
|
| 153 |
- previousCachedTypeSummary != nil || |
|
| 154 |
- currentArchiveObservationID != nil |
|
| 155 |
- } |
|
| 156 |
- |
|
| 157 |
- private var usesSimplifiedDetailUI: Bool {
|
|
| 158 |
- LegacyUIMode.isEnabled( |
|
| 159 |
- forceEnabled: appSettings.simplifiedUIModeEnabled, |
|
| 160 |
- horizontalSizeClass: horizontalSizeClass, |
|
| 161 |
- dynamicTypeSize: dynamicTypeSize, |
|
| 162 |
- screenWidth: contentWidth |
|
| 163 |
- ) |
|
| 164 |
- } |
|
| 165 |
- |
|
| 166 |
- var body: some View {
|
|
| 167 |
- ScrollView {
|
|
| 168 |
- VStack(spacing: 16) {
|
|
| 169 |
- if previousSnapshot == nil {
|
|
| 170 |
- emptyStateContent("No baseline available for this device.", icon: "clock.badge.questionmark")
|
|
| 171 |
- } else if !isTypeTrackedInCurrentContext {
|
|
| 172 |
- emptyStateContent("Data type not tracked in selected snapshots.", icon: "eye.slash")
|
|
| 173 |
- } else {
|
|
| 174 |
- dataRangeSection |
|
| 175 |
- recordChangeComparisonSection |
|
| 176 |
- if usesSimplifiedDetailUI {
|
|
| 177 |
- simplifiedDetailSection |
|
| 178 |
- } else {
|
|
| 179 |
- recordChangeEvolutionSection |
|
| 180 |
- temporalDistributionSection |
|
| 181 |
- } |
|
| 182 |
- } |
|
| 183 |
- } |
|
| 184 |
- .padding(16) |
|
| 185 |
- } |
|
| 186 |
- .navigationTitle(displayName) |
|
| 187 |
- .navigationBarTitleDisplayMode(.inline) |
|
| 188 |
- .safeAreaInset(edge: .top, spacing: 0) {
|
|
| 189 |
- SnapshotNavigationHeader( |
|
| 190 |
- snapshots: timelineSnapshots, |
|
| 191 |
- currentSnapshot: currentSnapshot, |
|
| 192 |
- onSnapshotSelected: { displayedSnapshot = $0 }
|
|
| 193 |
- ) |
|
| 194 |
- .frame(height: 64) |
|
| 195 |
- } |
|
| 196 |
- .toolbar {
|
|
| 197 |
- ToolbarItem(placement: .principal) {
|
|
| 198 |
- snapshotToolbarTitle |
|
| 199 |
- } |
|
| 200 |
- } |
|
| 201 |
- .background(contentWidthReader) |
|
| 202 |
- .task(id: diffTaskID) {
|
|
| 203 |
- await loadArchiveTypeSummaries() |
|
| 204 |
- await loadRecordDiff() |
|
| 205 |
- } |
|
| 206 |
- .navigationDestination(isPresented: $showTemporalDistribution) {
|
|
| 207 |
- DataTypeTemporalDistributionView( |
|
| 208 |
- input: temporalDistributionInput, |
|
| 209 |
- displayName: displayName |
|
| 210 |
- ) |
|
| 211 |
- } |
|
| 212 |
- } |
|
| 213 |
- |
|
| 214 |
- private var contentWidthReader: some View {
|
|
| 215 |
- GeometryReader { proxy in
|
|
| 216 |
- Color.clear |
|
| 217 |
- .onAppear {
|
|
| 218 |
- contentWidth = proxy.size.width |
|
| 219 |
- } |
|
| 220 |
- .onChange(of: proxy.size.width) { _, newWidth in
|
|
| 221 |
- contentWidth = newWidth |
|
| 222 |
- } |
|
| 223 |
- } |
|
| 224 |
- } |
|
| 225 |
- |
|
| 226 |
- private func typeCount(in snapshot: HealthSnapshot) -> TypeCount? {
|
|
| 227 |
- snapshot.typeCounts?.first { $0.typeIdentifier == typeIdentifier }
|
|
| 228 |
- } |
|
| 229 |
- |
|
| 230 |
- private func countText(for typeCount: TypeCount?) -> String {
|
|
| 231 |
- guard let typeCount else { return "Not tracked" }
|
|
| 232 |
- if typeCount.isUnsupported { return "Unsupported" }
|
|
| 233 |
- if typeCount.count < 0 { return "Unavailable" }
|
|
| 234 |
- return "\(typeCount.count)" |
|
| 235 |
- } |
|
| 236 |
- |
|
| 237 |
- private func countValue(for typeCount: TypeCount?) -> Int? {
|
|
| 238 |
- guard let typeCount else { return 0 }
|
|
| 239 |
- guard !typeCount.isUnsupported, typeCount.count >= 0 else { return nil }
|
|
| 240 |
- return typeCount.count |
|
| 241 |
- } |
|
| 242 |
- |
|
| 243 |
- @ViewBuilder |
|
| 244 |
- private var snapshotToolbarTitle: some View {
|
|
| 245 |
- if #available(iOS 26.0, *) {
|
|
| 246 |
- Text(displayName) |
|
| 247 |
- .font(.headline.weight(.semibold)) |
|
| 248 |
- .lineLimit(1) |
|
| 249 |
- .padding(.horizontal, 18) |
|
| 250 |
- .frame(height: 36) |
|
| 251 |
- .background(Color(.systemBackground).opacity(0.08), in: Capsule()) |
|
| 252 |
- .glassEffect( |
|
| 253 |
- .regular.tint(Color(.systemBackground).opacity(0.12)), |
|
| 254 |
- in: Capsule() |
|
| 255 |
- ) |
|
| 256 |
- } else {
|
|
| 257 |
- Text(displayName) |
|
| 258 |
- .font(.headline.weight(.semibold)) |
|
| 259 |
- .lineLimit(1) |
|
| 260 |
- .padding(.horizontal, 18) |
|
| 261 |
- .frame(height: 36) |
|
| 262 |
- .background(.ultraThinMaterial, in: Capsule()) |
|
| 263 |
- } |
|
| 264 |
- } |
|
| 265 |
- |
|
| 266 |
- @ViewBuilder |
|
| 267 |
- private var dataRangeSection: some View {
|
|
| 268 |
- if currentTypeCount != nil || currentCachedTypeSummary != nil {
|
|
| 269 |
- DataTypeRangeIndicator( |
|
| 270 |
- earliestDate: currentCachedTypeSummary?.earliestStartDate ?? currentTypeCount?.earliestDate, |
|
| 271 |
- latestDate: currentCachedTypeSummary?.latestEndDate ?? currentTypeCount?.latestDate, |
|
| 272 |
- quality: currentTypeCount?.quality ?? .complete |
|
| 273 |
- ) |
|
| 274 |
- } |
|
| 275 |
- } |
|
| 276 |
- |
|
| 277 |
- private var recordChangeComparisonSection: some View {
|
|
| 278 |
- Group {
|
|
| 279 |
- if previousSnapshot != nil {
|
|
| 280 |
- switch diffState {
|
|
| 281 |
- case .loaded(let diff): |
|
| 282 |
- RecordChangeComparisonCard( |
|
| 283 |
- displayName: displayName, |
|
| 284 |
- currentCount: quickCurrentCountValue, |
|
| 285 |
- previousCount: previousCachedTypeSummary?.visibleRecordCount ?? previousTypeCount?.count, |
|
| 286 |
- addedCount: diff.addedCount, |
|
| 287 |
- disappearedCount: diff.disappearedCount, |
|
| 288 |
- isCurrentValid: quickCurrentCountValue >= 0, |
|
| 289 |
- isPreviousTracked: previousTypeCount != nil || previousCachedTypeSummary != nil, |
|
| 290 |
- onAddedTap: {
|
|
| 291 |
- if diff.addedCount > 0 {
|
|
| 292 |
- showAddedRecords = true |
|
| 293 |
- } |
|
| 294 |
- }, |
|
| 295 |
- onDisappearedTap: {
|
|
| 296 |
- if diff.disappearedCount > 0 {
|
|
| 297 |
- showDisappearedRecords = true |
|
| 298 |
- } |
|
| 299 |
- } |
|
| 300 |
- ) |
|
| 301 |
- .navigationDestination(isPresented: $showAddedRecords) {
|
|
| 302 |
- if let previous = previousSnapshot {
|
|
| 303 |
- DataTypeRecordListView( |
|
| 304 |
- title: "New Records", |
|
| 305 |
- displayName: displayName, |
|
| 306 |
- totalCount: diff.addedCount, |
|
| 307 |
- mode: addedRecordListMode(previous: previous), |
|
| 308 |
- previewRecords: diff.addedRecords, |
|
| 309 |
- tint: Color.healthyGreen |
|
| 310 |
- ) |
|
| 311 |
- } |
|
| 312 |
- } |
|
| 313 |
- .navigationDestination(isPresented: $showDisappearedRecords) {
|
|
| 314 |
- DataTypeRecordListView( |
|
| 315 |
- title: "Missing Records", |
|
| 316 |
- displayName: displayName, |
|
| 317 |
- totalCount: diff.disappearedCount, |
|
| 318 |
- mode: disappearedRecordListMode(), |
|
| 319 |
- previewRecords: diff.disappearedRecords, |
|
| 320 |
- tint: Color.criticalRed |
|
| 321 |
- ) |
|
| 322 |
- } |
|
| 323 |
- |
|
| 324 |
- case .idle: |
|
| 325 |
- VStack(alignment: .leading, spacing: 10) {
|
|
| 326 |
- let quick = quickAddedDisappeared |
|
| 327 |
- |
|
| 328 |
- VStack(alignment: .leading, spacing: 8) {
|
|
| 329 |
- Text("Quick Counts")
|
|
| 330 |
- .font(.subheadline.weight(.semibold)) |
|
| 331 |
- |
|
| 332 |
- HStack {
|
|
| 333 |
- quickStat(label: "Current", value: "\(quickCurrentCountValue)") |
|
| 334 |
- quickStat(label: "Previous", value: "\(quickPreviousCountValue)") |
|
| 335 |
- } |
|
| 336 |
- |
|
| 337 |
- HStack {
|
|
| 338 |
- quickStat(label: "New", value: "\(quick.added)", color: .healthyGreen) |
|
| 339 |
- quickStat(label: "Missing", value: "\(quick.disappeared)", color: .criticalRed) |
|
| 340 |
- } |
|
| 341 |
- |
|
| 342 |
- if !quick.exact {
|
|
| 343 |
- Text("New/Missing are net values from observation delta. Exact split needs deep record analysis.")
|
|
| 344 |
- .font(.caption2) |
|
| 345 |
- .foregroundStyle(.secondary) |
|
| 346 |
- } |
|
| 347 |
- } |
|
| 348 |
- .padding(12) |
|
| 349 |
- .background(Color(.systemBackground).opacity(0.35), in: RoundedRectangle(cornerRadius: 8)) |
|
| 350 |
- } |
|
| 351 |
- .padding(12) |
|
| 352 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 353 |
- .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) |
|
| 354 |
- |
|
| 355 |
- case .unavailable: |
|
| 356 |
- VStack(alignment: .leading, spacing: 8) {
|
|
| 357 |
- Label( |
|
| 358 |
- "Record detail cache is unavailable for this snapshot pair.", |
|
| 359 |
- systemImage: "exclamationmark.triangle.fill" |
|
| 360 |
- ) |
|
| 361 |
- .font(.subheadline) |
|
| 362 |
- .foregroundStyle(Color.warningAmber) |
|
| 363 |
- |
|
| 364 |
- #if DEBUG |
|
| 365 |
- if let detailCacheDiagnostic {
|
|
| 366 |
- Text(detailCacheDiagnostic) |
|
| 367 |
- .font(.caption2.monospaced()) |
|
| 368 |
- .foregroundStyle(.secondary) |
|
| 369 |
- .textSelection(.enabled) |
|
| 370 |
- } |
|
| 371 |
- #endif |
|
| 372 |
- } |
|
| 373 |
- .padding(12) |
|
| 374 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 375 |
- .background(Color.warningAmber.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) |
|
| 376 |
- |
|
| 377 |
- case .failed(let message): |
|
| 378 |
- Label(message, systemImage: "exclamationmark.triangle.fill") |
|
| 379 |
- .font(.subheadline) |
|
| 380 |
- .foregroundStyle(Color.warningAmber) |
|
| 381 |
- .padding(12) |
|
| 382 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 383 |
- .background(Color.warningAmber.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) |
|
| 384 |
- |
|
| 385 |
- case .loading: |
|
| 386 |
- HStack(spacing: 8) {
|
|
| 387 |
- ProgressView() |
|
| 388 |
- Text("Analyzing record changes...")
|
|
| 389 |
- .font(.subheadline) |
|
| 390 |
- .foregroundStyle(.secondary) |
|
| 391 |
- } |
|
| 392 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 393 |
- .padding(12) |
|
| 394 |
- .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) |
|
| 395 |
- } |
|
| 396 |
- } |
|
| 397 |
- } |
|
| 398 |
- } |
|
| 399 |
- |
|
| 400 |
- @ViewBuilder |
|
| 401 |
- private var recordChangeEvolutionSection: some View {
|
|
| 402 |
- if previousSnapshot != nil, currentTypeCount != nil || currentCachedTypeSummary != nil {
|
|
| 403 |
- RecordChangeEvolutionChart( |
|
| 404 |
- snapshots: recordEvolutionSnapshots, |
|
| 405 |
- currentSnapshotID: currentSnapshot.id, |
|
| 406 |
- typeIdentifier: typeIdentifier, |
|
| 407 |
- displayName: displayName |
|
| 408 |
- ) |
|
| 409 |
- } |
|
| 410 |
- } |
|
| 411 |
- |
|
| 412 |
- private var simplifiedDetailSection: some View {
|
|
| 413 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 414 |
- Text("Summary")
|
|
| 415 |
- .font(.headline.weight(.semibold)) |
|
| 416 |
- |
|
| 417 |
- HStack(spacing: 12) {
|
|
| 418 |
- quickStat(label: "Current", value: "\(quickCurrentCountValue)") |
|
| 419 |
- quickStat(label: "Previous", value: "\(quickPreviousCountValue)") |
|
| 420 |
- } |
|
| 421 |
- |
|
| 422 |
- let quick = simplifiedRecordCounts |
|
| 423 |
- HStack(spacing: 12) {
|
|
| 424 |
- quickStat(label: "New", value: "\(quick.added)", color: .healthyGreen) |
|
| 425 |
- quickStat(label: "Missing", value: "\(quick.disappeared)", color: .criticalRed) |
|
| 426 |
- } |
|
| 427 |
- |
|
| 428 |
- if !quick.exact {
|
|
| 429 |
- Text("Exact split needs record analysis.")
|
|
| 430 |
- .font(.caption2) |
|
| 431 |
- .foregroundStyle(.secondary) |
|
| 432 |
- } |
|
| 433 |
- } |
|
| 434 |
- .padding(12) |
|
| 435 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 436 |
- .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) |
|
| 437 |
- .accessibilityElement(children: .combine) |
|
| 438 |
- .accessibilityLabel("Summary. Current \(quickCurrentCountValue). Previous \(quickPreviousCountValue). New \(simplifiedRecordCounts.added). Missing \(simplifiedRecordCounts.disappeared).")
|
|
| 439 |
- } |
|
| 440 |
- |
|
| 441 |
- @ViewBuilder |
|
| 442 |
- private var temporalDistributionSection: some View {
|
|
| 443 |
- if previousSnapshot != nil, currentTypeCount != nil, !isCurrentTypeContentAliasToPrevious {
|
|
| 444 |
- if !hasTemporalDistributionCache {
|
|
| 445 |
- VStack(alignment: .leading, spacing: 8) {
|
|
| 446 |
- Label( |
|
| 447 |
- "Temporal distribution is available only when precomputed cache exists for this snapshot pair.", |
|
| 448 |
- systemImage: "info.circle" |
|
| 449 |
- ) |
|
| 450 |
- .font(.caption) |
|
| 451 |
- .foregroundStyle(.secondary) |
|
| 452 |
- } |
|
| 453 |
- .padding(12) |
|
| 454 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 455 |
- .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) |
|
| 456 |
- } else {
|
|
| 457 |
- Button {
|
|
| 458 |
- showTemporalDistribution = true |
|
| 459 |
- } label: {
|
|
| 460 |
- HStack(spacing: 10) {
|
|
| 461 |
- Image(systemName: "chart.bar.xaxis") |
|
| 462 |
- .font(.system(size: 16, weight: .semibold)) |
|
| 463 |
- .foregroundStyle(.secondary) |
|
| 464 |
- |
|
| 465 |
- VStack(alignment: .leading, spacing: 2) {
|
|
| 466 |
- Text("Temporal Distribution")
|
|
| 467 |
- .font(.headline.weight(.semibold)) |
|
| 468 |
- .foregroundStyle(.primary) |
|
| 469 |
- Text("New / missing by time bucket")
|
|
| 470 |
- .font(.caption) |
|
| 471 |
- .foregroundStyle(.secondary) |
|
| 472 |
- } |
|
| 473 |
- |
|
| 474 |
- Spacer() |
|
| 475 |
- |
|
| 476 |
- Image(systemName: "chevron.right") |
|
| 477 |
- .font(.system(size: 12, weight: .semibold)) |
|
| 478 |
- .foregroundStyle(.secondary) |
|
| 479 |
- } |
|
| 480 |
- .padding(12) |
|
| 481 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 482 |
- .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) |
|
| 483 |
- } |
|
| 484 |
- .buttonStyle(.plain) |
|
| 485 |
- } |
|
| 486 |
- } |
|
| 487 |
- } |
|
| 488 |
- |
|
| 489 |
- private func emptyStateContent(_ message: String, icon: String) -> some View {
|
|
| 490 |
- VStack(spacing: 12) {
|
|
| 491 |
- Image(systemName: icon) |
|
| 492 |
- .font(.system(size: 32, weight: .semibold)) |
|
| 493 |
- .foregroundStyle(.secondary) |
|
| 494 |
- |
|
| 495 |
- Text(message) |
|
| 496 |
- .font(.subheadline) |
|
| 497 |
- .foregroundStyle(.secondary) |
|
| 498 |
- .multilineTextAlignment(.center) |
|
| 499 |
- } |
|
| 500 |
- .padding(24) |
|
| 501 |
- .frame(maxWidth: .infinity) |
|
| 502 |
- .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12)) |
|
| 503 |
- } |
|
| 504 |
- |
|
| 505 |
- private func quickStat(label: String, value: String, color: Color = .primary) -> some View {
|
|
| 506 |
- VStack(alignment: .leading, spacing: 2) {
|
|
| 507 |
- Text(label) |
|
| 508 |
- .font(.caption2) |
|
| 509 |
- .foregroundStyle(.secondary) |
|
| 510 |
- Text(value) |
|
| 511 |
- .font(.subheadline.weight(.semibold).monospacedDigit()) |
|
| 512 |
- .foregroundStyle(color) |
|
| 513 |
- } |
|
| 514 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 515 |
- } |
|
| 516 |
- |
|
| 517 |
- private func recordEvolutionCachedFallback(for snapshot: HealthSnapshot) -> (added: Int, disappeared: Int, exact: Bool) {
|
|
| 518 |
- guard let previous = snapshot.previousInTimeline(timelineSnapshots) else {
|
|
| 519 |
- return (0, 0, false) |
|
| 520 |
- } |
|
| 521 |
- |
|
| 522 |
- if let cache = typeCount(in: snapshot)?.detailCache, |
|
| 523 |
- cache.matchesBaseline(previous.id) {
|
|
| 524 |
- return (cache.addedCount, cache.disappearedCount, true) |
|
| 525 |
- } |
|
| 526 |
- |
|
| 527 |
- return (0, 0, false) |
|
| 528 |
- } |
|
| 529 |
- |
|
| 530 |
- private func addedRecordListMode(previous: HealthSnapshot) -> RecordListMode {
|
|
| 531 |
- if let fromObservationID = previous.archiveObservationID, |
|
| 532 |
- let toObservationID = currentArchiveObservationID {
|
|
| 533 |
- return .addedDiff( |
|
| 534 |
- typeIdentifier: typeIdentifier, |
|
| 535 |
- afterDate: previous.timestamp, |
|
| 536 |
- beforeDate: currentSnapshot.timestamp, |
|
| 537 |
- fromObservationID: fromObservationID, |
|
| 538 |
- toObservationID: toObservationID |
|
| 539 |
- ) |
|
| 540 |
- } |
|
| 541 |
- |
|
| 542 |
- return .added( |
|
| 543 |
- typeIdentifier: typeIdentifier, |
|
| 544 |
- afterDate: previous.timestamp, |
|
| 545 |
- beforeDate: currentSnapshot.timestamp |
|
| 546 |
- ) |
|
| 547 |
- } |
|
| 548 |
- |
|
| 549 |
- private func disappearedRecordListMode() -> RecordListMode {
|
|
| 550 |
- if let fromObservationID = previousArchiveObservationID, |
|
| 551 |
- let toObservationID = currentArchiveObservationID {
|
|
| 552 |
- return .disappearedDiff( |
|
| 553 |
- typeIdentifier: typeIdentifier, |
|
| 554 |
- fromObservationID: fromObservationID, |
|
| 555 |
- toObservationID: toObservationID |
|
| 556 |
- ) |
|
| 557 |
- } |
|
| 558 |
- |
|
| 559 |
- return .disappeared(typeIdentifier: typeIdentifier) |
|
| 560 |
- } |
|
| 561 |
- |
|
| 562 |
- @MainActor |
|
| 563 |
- private func loadRecordDiff() async {
|
|
| 564 |
- guard previousSnapshot != nil else {
|
|
| 565 |
- detailCacheDiagnostic = nil |
|
| 566 |
- diffState = .loaded(.empty) |
|
| 567 |
- return |
|
| 568 |
- } |
|
| 569 |
- |
|
| 570 |
- if isCurrentTypeContentAliasToPrevious {
|
|
| 571 |
- detailCacheDiagnostic = nil |
|
| 572 |
- diffState = .loaded(.empty) |
|
| 573 |
- return |
|
| 574 |
- } |
|
| 575 |
- |
|
| 576 |
- let currentCount = currentCachedTypeSummary?.visibleRecordCount ?? currentTypeCount?.count ?? 0 |
|
| 577 |
- let previousCount = previousCachedTypeSummary?.visibleRecordCount ?? previousTypeCount?.count ?? 0 |
|
| 578 |
- |
|
| 579 |
- guard currentCount >= 0, previousCount >= 0 else {
|
|
| 580 |
- detailCacheDiagnostic = "counts-invalid current=\(currentCount) previous=\(previousCount)" |
|
| 581 |
- diffState = .unavailable |
|
| 582 |
- return |
|
| 583 |
- } |
|
| 584 |
- |
|
| 585 |
- if let previousArchiveObservationID, |
|
| 586 |
- let currentArchiveObservationID {
|
|
| 587 |
- do {
|
|
| 588 |
- let cache = try CoreDataArchiveCacheStore() |
|
| 589 |
- if let cached = try cache.diffSummary( |
|
| 590 |
- fromObservationID: previousArchiveObservationID, |
|
| 591 |
- toObservationID: currentArchiveObservationID, |
|
| 592 |
- sampleTypeIdentifier: typeIdentifier |
|
| 593 |
- ) {
|
|
| 594 |
- detailCacheDiagnostic = "resolver-v6 phase=core-data-diff-cache" |
|
| 595 |
- diffState = .loaded(DataTypeRecordDiff(cached: cached)) |
|
| 596 |
- return |
|
| 597 |
- } |
|
| 598 |
- } catch {
|
|
| 599 |
- detailCacheDiagnostic = "core-data-diff-cache-failed \(error.localizedDescription)" |
|
| 600 |
- } |
|
| 601 |
- |
|
| 602 |
- do {
|
|
| 603 |
- let summary = try await SQLiteHealthArchiveStore.shared.diffSummary(HealthArchiveDiffRequest( |
|
| 604 |
- fromObservationID: previousArchiveObservationID, |
|
| 605 |
- toObservationID: currentArchiveObservationID, |
|
| 606 |
- sampleTypeIdentifier: typeIdentifier |
|
| 607 |
- )) |
|
| 608 |
- detailCacheDiagnostic = "resolver-v5 phase=archive-diff" |
|
| 609 |
- diffState = .loaded(DataTypeRecordDiff(summary: summary)) |
|
| 610 |
- return |
|
| 611 |
- } catch {
|
|
| 612 |
- detailCacheDiagnostic = "archive-diff-failed \(error.localizedDescription)" |
|
| 613 |
- } |
|
| 614 |
- } |
|
| 615 |
- |
|
| 616 |
- if let cache = currentTypeCount?.detailCache, |
|
| 617 |
- cache.matchesBaseline(previousSnapshot?.id) {
|
|
| 618 |
- detailCacheDiagnostic = "resolver-v7 phase=legacy-detail-cache-read-only" |
|
| 619 |
- diffState = .loaded(DataTypeRecordDiff(cache: cache)) |
|
| 620 |
- return |
|
| 621 |
- } |
|
| 622 |
- |
|
| 623 |
- diffState = .unavailable |
|
| 624 |
- } |
|
| 625 |
- |
|
| 626 |
- @MainActor |
|
| 627 |
- private func loadArchiveTypeSummaries() async {
|
|
| 628 |
- guard currentArchiveObservationID != nil || previousArchiveObservationID != nil else {
|
|
| 629 |
- currentCachedTypeSummary = nil |
|
| 630 |
- previousCachedTypeSummary = nil |
|
| 631 |
- return |
|
| 632 |
- } |
|
| 633 |
- |
|
| 634 |
- do {
|
|
| 635 |
- let cache = try CoreDataArchiveCacheStore() |
|
| 636 |
- if let currentArchiveObservationID {
|
|
| 637 |
- currentCachedTypeSummary = try cache.typeSummaries(observationID: currentArchiveObservationID) |
|
| 638 |
- .first { $0.sampleTypeIdentifier == typeIdentifier }
|
|
| 639 |
- } else {
|
|
| 640 |
- currentCachedTypeSummary = nil |
|
| 641 |
- } |
|
| 642 |
- |
|
| 643 |
- if let previousArchiveObservationID {
|
|
| 644 |
- previousCachedTypeSummary = try cache.typeSummaries(observationID: previousArchiveObservationID) |
|
| 645 |
- .first { $0.sampleTypeIdentifier == typeIdentifier }
|
|
| 646 |
- } else {
|
|
| 647 |
- previousCachedTypeSummary = nil |
|
| 648 |
- } |
|
| 649 |
- } catch {
|
|
| 650 |
- currentCachedTypeSummary = nil |
|
| 651 |
- previousCachedTypeSummary = nil |
|
| 652 |
- detailCacheDiagnostic = "core-data-type-cache-failed \(error.localizedDescription)" |
|
| 653 |
- } |
|
| 654 |
- } |
|
| 655 |
- |
|
| 656 |
-} |
|
| 657 |
- |
|
| 658 |
-private enum RecordDiffState: Equatable {
|
|
| 659 |
- case idle |
|
| 660 |
- case loading |
|
| 661 |
- case unavailable |
|
| 662 |
- case failed(String) |
|
| 663 |
- case loaded(DataTypeRecordDiff) |
|
| 664 |
-} |
|
| 665 |
- |
|
| 666 |
-private struct DataTypeRecordDiff: Equatable, Sendable {
|
|
| 667 |
- static let previewLimit = 1_000 |
|
| 668 |
- static let empty = DataTypeRecordDiff( |
|
| 669 |
- addedCount: 0, |
|
| 670 |
- disappearedCount: 0, |
|
| 671 |
- addedRecords: [], |
|
| 672 |
- disappearedRecords: [] |
|
| 673 |
- ) |
|
| 674 |
- |
|
| 675 |
- let addedCount: Int |
|
| 676 |
- let disappearedCount: Int |
|
| 677 |
- let addedRecords: [HealthRecordValue] |
|
| 678 |
- let disappearedRecords: [HealthRecordValue] |
|
| 679 |
- |
|
| 680 |
- init( |
|
| 681 |
- addedCount: Int, |
|
| 682 |
- disappearedCount: Int, |
|
| 683 |
- addedRecords: [HealthRecordValue], |
|
| 684 |
- disappearedRecords: [HealthRecordValue] |
|
| 685 |
- ) {
|
|
| 686 |
- self.addedCount = addedCount |
|
| 687 |
- self.disappearedCount = disappearedCount |
|
| 688 |
- self.addedRecords = addedRecords |
|
| 689 |
- self.disappearedRecords = disappearedRecords |
|
| 690 |
- } |
|
| 691 |
- |
|
| 692 |
- init(cache: TypeCountDetailCache) {
|
|
| 693 |
- self.addedCount = cache.addedCount |
|
| 694 |
- self.disappearedCount = cache.disappearedCount |
|
| 695 |
- self.addedRecords = cache.addedPreviewRecords |
|
| 696 |
- self.disappearedRecords = cache.disappearedPreviewRecords |
|
| 697 |
- } |
|
| 698 |
- |
|
| 699 |
- init(summary: HealthArchiveDiffSummary) {
|
|
| 700 |
- self.addedCount = summary.appearedCount |
|
| 701 |
- self.disappearedCount = summary.disappearedCount |
|
| 702 |
- self.addedRecords = [] |
|
| 703 |
- self.disappearedRecords = [] |
|
| 704 |
- } |
|
| 705 |
- |
|
| 706 |
- init(cached: CachedArchiveDiffSummary) {
|
|
| 707 |
- self.addedCount = cached.appearedCount |
|
| 708 |
- self.disappearedCount = cached.disappearedCount |
|
| 709 |
- self.addedRecords = [] |
|
| 710 |
- self.disappearedRecords = [] |
|
| 711 |
- } |
|
| 712 |
- |
|
| 713 |
- var isPreviewLimited: Bool {
|
|
| 714 |
- addedCount > Self.previewLimit || disappearedCount > Self.previewLimit |
|
| 715 |
- } |
|
| 716 |
-} |
|
| 717 |
- |
|
| 718 |
-#Preview {
|
|
| 719 |
- NavigationStack {
|
|
| 720 |
- DataTypeSnapshotDetailView( |
|
| 721 |
- snapshot: HealthSnapshot( |
|
| 722 |
- timestamp: .now, |
|
| 723 |
- osVersion: "iOS 26.4", |
|
| 724 |
- deviceName: "Preview iPhone", |
|
| 725 |
- deviceID: "preview-device" |
|
| 726 |
- ), |
|
| 727 |
- typeIdentifier: "HKQuantityTypeIdentifierStepCount", |
|
| 728 |
- displayName: "Step Count" |
|
| 729 |
- ) |
|
| 730 |
- } |
|
| 731 |
- .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self], inMemory: true) |
|
| 732 |
- .environment(AppSettings()) |
|
| 733 |
-} |
|
@@ -1,705 +0,0 @@ |
||
| 1 |
-import SwiftUI |
|
| 2 |
-import SwiftData |
|
| 3 |
-import UIKit |
|
| 4 |
- |
|
| 5 |
-struct SnapshotDetailView: View {
|
|
| 6 |
- let snapshot: HealthSnapshot |
|
| 7 |
- let baseline: HealthSnapshot? |
|
| 8 |
- let profile: LocalDeviceProfile? |
|
| 9 |
- |
|
| 10 |
- @Query(sort: \HealthSnapshot.timestamp) private var allSnapshots: [HealthSnapshot] |
|
| 11 |
- @State private var displayedSnapshot: HealthSnapshot? |
|
| 12 |
- @State private var archiveTypeRows: [SnapshotArchiveTypeRow]? |
|
| 13 |
- @State private var archiveTypeError: String? |
|
| 14 |
- |
|
| 15 |
- private var currentSnapshot: HealthSnapshot {
|
|
| 16 |
- displayedSnapshot ?? snapshot |
|
| 17 |
- } |
|
| 18 |
- |
|
| 19 |
- private var archiveReloadID: String {
|
|
| 20 |
- [ |
|
| 21 |
- currentSnapshot.id.uuidString, |
|
| 22 |
- String(currentSnapshot.archiveObservationID ?? -1), |
|
| 23 |
- String(baseline?.archiveObservationID ?? -1) |
|
| 24 |
- ].joined(separator: "|") |
|
| 25 |
- } |
|
| 26 |
- |
|
| 27 |
- private var summaryTypeCount: Int? {
|
|
| 28 |
- if let archiveTypeRows {
|
|
| 29 |
- return archiveTypeRows.count |
|
| 30 |
- } |
|
| 31 |
- guard currentSnapshot.hasCurrentCachedSummary else { return nil }
|
|
| 32 |
- return currentSnapshot.cachedTypeCount |
|
| 33 |
- } |
|
| 34 |
- |
|
| 35 |
- private var summaryRecordCount: Int? {
|
|
| 36 |
- if let archiveTypeRows {
|
|
| 37 |
- return archiveTypeRows.reduce(0) { $0 + $1.currentCount }
|
|
| 38 |
- } |
|
| 39 |
- guard currentSnapshot.hasCurrentCachedSummary else { return nil }
|
|
| 40 |
- return currentSnapshot.cachedRecordCount |
|
| 41 |
- } |
|
| 42 |
- |
|
| 43 |
- private var summaryEarliestRecordDate: Date? {
|
|
| 44 |
- archiveTypeRows?.compactMap(\.earliestStartDate).min() ?? currentSnapshot.cachedEarliestRecordDate |
|
| 45 |
- } |
|
| 46 |
- |
|
| 47 |
- private var summaryLatestRecordDate: Date? {
|
|
| 48 |
- archiveTypeRows?.compactMap(\.latestEndDate).max() ?? currentSnapshot.cachedLatestRecordDate |
|
| 49 |
- } |
|
| 50 |
- |
|
| 51 |
- private var archiveRecordChangeCount: Int? {
|
|
| 52 |
- archiveTypeRows?.reduce(0) { $0 + $1.recordChangeCount }
|
|
| 53 |
- } |
|
| 54 |
- |
|
| 55 |
- private var archiveAffectedMetricCount: Int? {
|
|
| 56 |
- archiveTypeRows?.filter(\.hasChanges).count |
|
| 57 |
- } |
|
| 58 |
- |
|
| 59 |
- private var deviceDisplayName: String {
|
|
| 60 |
- if let name = profile?.name, !name.isEmpty { return name }
|
|
| 61 |
- return currentSnapshot.deviceName.isEmpty ? "Unknown device" : currentSnapshot.deviceName |
|
| 62 |
- } |
|
| 63 |
- |
|
| 64 |
- private var timelineSnapshots: [HealthSnapshot] {
|
|
| 65 |
- allSnapshots.filter { candidate in
|
|
| 66 |
- if currentSnapshot.deviceID.isEmpty {
|
|
| 67 |
- return candidate.deviceID.isEmpty |
|
| 68 |
- } |
|
| 69 |
- return candidate.deviceID == currentSnapshot.deviceID |
|
| 70 |
- } |
|
| 71 |
- } |
|
| 72 |
- |
|
| 73 |
- @State private var showShareSheet = false |
|
| 74 |
- @State private var pdfExportURL: URL? |
|
| 75 |
- @State private var isExporting = false |
|
| 76 |
- @State private var showMetadataSheet = false |
|
| 77 |
- |
|
| 78 |
- var body: some View {
|
|
| 79 |
- List {
|
|
| 80 |
- evolutionSection |
|
| 81 |
- } |
|
| 82 |
- .navigationTitle("Snapshot")
|
|
| 83 |
- .navigationBarTitleDisplayMode(.inline) |
|
| 84 |
- .safeAreaInset(edge: .top, spacing: 0) {
|
|
| 85 |
- SnapshotNavigationHeader( |
|
| 86 |
- snapshots: timelineSnapshots, |
|
| 87 |
- currentSnapshot: currentSnapshot, |
|
| 88 |
- onSnapshotSelected: { displayedSnapshot = $0 }
|
|
| 89 |
- ) |
|
| 90 |
- .frame(height: 64) |
|
| 91 |
- } |
|
| 92 |
- .toolbar {
|
|
| 93 |
- ToolbarItem(placement: .principal) {
|
|
| 94 |
- snapshotToolbarTitle |
|
| 95 |
- } |
|
| 96 |
- ToolbarItem(placement: .navigationBarTrailing) {
|
|
| 97 |
- HStack(spacing: 12) {
|
|
| 98 |
- Button {
|
|
| 99 |
- showMetadataSheet = true |
|
| 100 |
- } label: {
|
|
| 101 |
- Image(systemName: "info.circle") |
|
| 102 |
- } |
|
| 103 |
- .accessibilityLabel("View snapshot details")
|
|
| 104 |
- |
|
| 105 |
- if isExporting {
|
|
| 106 |
- ProgressView() |
|
| 107 |
- .accessibilityLabel("Generating PDF")
|
|
| 108 |
- } else {
|
|
| 109 |
- Button {
|
|
| 110 |
- exportAsPDF() |
|
| 111 |
- } label: {
|
|
| 112 |
- Image(systemName: "square.and.arrow.up") |
|
| 113 |
- } |
|
| 114 |
- .accessibilityLabel("Export snapshot as PDF")
|
|
| 115 |
- } |
|
| 116 |
- } |
|
| 117 |
- } |
|
| 118 |
- } |
|
| 119 |
- .sheet(isPresented: $showMetadataSheet) {
|
|
| 120 |
- metadataSheetContent |
|
| 121 |
- } |
|
| 122 |
- .sheet(isPresented: $showShareSheet) {
|
|
| 123 |
- if let url = pdfExportURL {
|
|
| 124 |
- ShareSheet(items: [url]) |
|
| 125 |
- .ignoresSafeArea() |
|
| 126 |
- } |
|
| 127 |
- } |
|
| 128 |
- .task(id: archiveReloadID) {
|
|
| 129 |
- await loadArchiveTypeRows() |
|
| 130 |
- } |
|
| 131 |
- } |
|
| 132 |
- |
|
| 133 |
- private func exportAsPDF() {
|
|
| 134 |
- isExporting = true |
|
| 135 |
- let reportData = SnapshotPDFExporter.extractReportData( |
|
| 136 |
- snapshot: currentSnapshot, |
|
| 137 |
- baseline: baseline, |
|
| 138 |
- profile: profile |
|
| 139 |
- ) |
|
| 140 |
- let timestamp = currentSnapshot.timestamp |
|
| 141 |
- Task(priority: .userInitiated) {
|
|
| 142 |
- let pdfData = SnapshotPDFExporter.generatePDF(from: reportData) |
|
| 143 |
- let formatter = DateFormatter() |
|
| 144 |
- formatter.dateFormat = "yyyy-MM-dd-HH-mm" |
|
| 145 |
- let name = "HealthProbe-Snapshot-\(formatter.string(from: timestamp)).pdf" |
|
| 146 |
- let url = FileManager.default.temporaryDirectory.appendingPathComponent(name) |
|
| 147 |
- try? pdfData.write(to: url) |
|
| 148 |
- isExporting = false |
|
| 149 |
- pdfExportURL = url |
|
| 150 |
- showShareSheet = true |
|
| 151 |
- } |
|
| 152 |
- } |
|
| 153 |
- |
|
| 154 |
- @MainActor |
|
| 155 |
- private func loadArchiveTypeRows() async {
|
|
| 156 |
- guard let currentObservationID = currentSnapshot.archiveObservationID else {
|
|
| 157 |
- archiveTypeRows = nil |
|
| 158 |
- archiveTypeError = nil |
|
| 159 |
- return |
|
| 160 |
- } |
|
| 161 |
- |
|
| 162 |
- do {
|
|
| 163 |
- let cache = try CoreDataArchiveCacheStore() |
|
| 164 |
- let currentSummaries = try cache.typeSummaries(observationID: currentObservationID) |
|
| 165 |
- let baselineObservationID = baseline?.archiveObservationID |
|
| 166 |
- let baselineSummaries = try baselineObservationID.map {
|
|
| 167 |
- try cache.typeSummaries(observationID: $0) |
|
| 168 |
- } ?? [] |
|
| 169 |
- |
|
| 170 |
- let currentByType = Dictionary(uniqueKeysWithValues: currentSummaries.map { ($0.sampleTypeIdentifier, $0) })
|
|
| 171 |
- let baselineByType = Dictionary(uniqueKeysWithValues: baselineSummaries.map { ($0.sampleTypeIdentifier, $0) })
|
|
| 172 |
- let allTypeIdentifiers = Set(currentByType.keys).union(baselineByType.keys) |
|
| 173 |
- |
|
| 174 |
- var rows: [SnapshotArchiveTypeRow] = [] |
|
| 175 |
- rows.reserveCapacity(allTypeIdentifiers.count) |
|
| 176 |
- |
|
| 177 |
- for typeIdentifier in allTypeIdentifiers {
|
|
| 178 |
- let current = currentByType[typeIdentifier] |
|
| 179 |
- let baselineSummary = baselineByType[typeIdentifier] |
|
| 180 |
- let diff: HealthArchiveDiffSummary |
|
| 181 |
- if let baselineObservationID {
|
|
| 182 |
- diff = try await SQLiteHealthArchiveStore.shared.diffSummary(HealthArchiveDiffRequest( |
|
| 183 |
- fromObservationID: baselineObservationID, |
|
| 184 |
- toObservationID: currentObservationID, |
|
| 185 |
- sampleTypeIdentifier: typeIdentifier |
|
| 186 |
- )) |
|
| 187 |
- } else {
|
|
| 188 |
- diff = HealthArchiveDiffSummary( |
|
| 189 |
- fromObservationID: currentObservationID, |
|
| 190 |
- toObservationID: currentObservationID, |
|
| 191 |
- sampleTypeIdentifier: typeIdentifier, |
|
| 192 |
- appearedCount: 0, |
|
| 193 |
- disappearedCount: 0, |
|
| 194 |
- representationChangedCount: 0 |
|
| 195 |
- ) |
|
| 196 |
- } |
|
| 197 |
- |
|
| 198 |
- rows.append(SnapshotArchiveTypeRow( |
|
| 199 |
- typeIdentifier: typeIdentifier, |
|
| 200 |
- displayName: current?.displayName ?? baselineSummary?.displayName ?? typeIdentifier, |
|
| 201 |
- currentCount: current?.visibleRecordCount ?? 0, |
|
| 202 |
- previousCount: baselineSummary?.visibleRecordCount, |
|
| 203 |
- appearedCount: diff.appearedCount, |
|
| 204 |
- disappearedCount: diff.disappearedCount, |
|
| 205 |
- representationChangedCount: diff.representationChangedCount, |
|
| 206 |
- earliestStartDate: current?.earliestStartDate, |
|
| 207 |
- latestEndDate: current?.latestEndDate |
|
| 208 |
- )) |
|
| 209 |
- } |
|
| 210 |
- |
|
| 211 |
- archiveTypeRows = rows.sorted {
|
|
| 212 |
- $0.displayName.localizedCompare($1.displayName) == .orderedAscending |
|
| 213 |
- } |
|
| 214 |
- archiveTypeError = nil |
|
| 215 |
- } catch {
|
|
| 216 |
- archiveTypeRows = nil |
|
| 217 |
- archiveTypeError = error.localizedDescription |
|
| 218 |
- } |
|
| 219 |
- } |
|
| 220 |
- |
|
| 221 |
- @ViewBuilder |
|
| 222 |
- private var snapshotToolbarTitle: some View {
|
|
| 223 |
- if #available(iOS 26.0, *) {
|
|
| 224 |
- Text("Snapshot")
|
|
| 225 |
- .font(.headline.weight(.semibold)) |
|
| 226 |
- .padding(.horizontal, 18) |
|
| 227 |
- .frame(height: 36) |
|
| 228 |
- .background(Color(.systemBackground).opacity(0.08), in: Capsule()) |
|
| 229 |
- .glassEffect( |
|
| 230 |
- .regular.tint(Color(.systemBackground).opacity(0.12)), |
|
| 231 |
- in: Capsule() |
|
| 232 |
- ) |
|
| 233 |
- } else {
|
|
| 234 |
- Text(currentSnapshot.timestamp, format: .dateTime.month().day().hour().minute()) |
|
| 235 |
- .font(.headline.weight(.semibold)) |
|
| 236 |
- .padding(.horizontal, 18) |
|
| 237 |
- .frame(height: 36) |
|
| 238 |
- .background(.ultraThinMaterial, in: Capsule()) |
|
| 239 |
- } |
|
| 240 |
- } |
|
| 241 |
- |
|
| 242 |
- @ViewBuilder |
|
| 243 |
- private var metadataSheetContent: some View {
|
|
| 244 |
- NavigationStack {
|
|
| 245 |
- ScrollView {
|
|
| 246 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 247 |
- // Title with Date |
|
| 248 |
- VStack(spacing: 4) {
|
|
| 249 |
- Text("Snapshot")
|
|
| 250 |
- .font(.headline.weight(.semibold)) |
|
| 251 |
- Text(currentSnapshot.timestamp, format: .dateTime.month().day().hour().minute()) |
|
| 252 |
- .font(.subheadline) |
|
| 253 |
- .foregroundStyle(.secondary) |
|
| 254 |
- } |
|
| 255 |
- .frame(maxWidth: .infinity, alignment: .center) |
|
| 256 |
- .padding(12) |
|
| 257 |
- .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12)) |
|
| 258 |
- |
|
| 259 |
- // Data Range |
|
| 260 |
- SnapshotDataRangeIndicator( |
|
| 261 |
- oldestRecordDate: summaryEarliestRecordDate, |
|
| 262 |
- newestRecordDate: summaryLatestRecordDate, |
|
| 263 |
- quality: currentSnapshot.snapshotQuality |
|
| 264 |
- ) |
|
| 265 |
- |
|
| 266 |
- // Summary Stats (compact) |
|
| 267 |
- VStack(spacing: 12) {
|
|
| 268 |
- if let summaryTypeCount, |
|
| 269 |
- let summaryRecordCount {
|
|
| 270 |
- HStack(spacing: 16) {
|
|
| 271 |
- statCompact(label: "Types", value: "\(summaryTypeCount)") |
|
| 272 |
- Divider() |
|
| 273 |
- statCompact(label: "Records", value: "\(summaryRecordCount)") |
|
| 274 |
- } |
|
| 275 |
- .font(.caption) |
|
| 276 |
- .foregroundStyle(.secondary) |
|
| 277 |
- } else {
|
|
| 278 |
- Text("Snapshot summary unavailable")
|
|
| 279 |
- .font(.caption) |
|
| 280 |
- .foregroundStyle(.secondary) |
|
| 281 |
- .frame(maxWidth: .infinity, alignment: .center) |
|
| 282 |
- } |
|
| 283 |
- } |
|
| 284 |
- .padding(12) |
|
| 285 |
- .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12)) |
|
| 286 |
- |
|
| 287 |
- // Device (collapsible) |
|
| 288 |
- DisclosureGroup {
|
|
| 289 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 290 |
- DetailRow(label: "Version") {
|
|
| 291 |
- Text(extractOSVersion(currentSnapshot.osVersion)) |
|
| 292 |
- .foregroundStyle(.secondary) |
|
| 293 |
- .font(.caption.monospacedDigit()) |
|
| 294 |
- } |
|
| 295 |
- Divider() |
|
| 296 |
- DetailRow(label: "Build") {
|
|
| 297 |
- Text(extractBuildNumber(currentSnapshot.osVersion)) |
|
| 298 |
- .foregroundStyle(.secondary) |
|
| 299 |
- .font(.caption.monospacedDigit()) |
|
| 300 |
- } |
|
| 301 |
- } |
|
| 302 |
- .padding(.top, 8) |
|
| 303 |
- } label: {
|
|
| 304 |
- HStack(spacing: 8) {
|
|
| 305 |
- Image(systemName: "iphone") |
|
| 306 |
- .font(.system(size: 16, weight: .semibold)) |
|
| 307 |
- Text(deviceDisplayName) |
|
| 308 |
- .font(.subheadline.weight(.semibold)) |
|
| 309 |
- } |
|
| 310 |
- } |
|
| 311 |
- .padding(12) |
|
| 312 |
- .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12)) |
|
| 313 |
- |
|
| 314 |
- // Comparison (if baseline exists) |
|
| 315 |
- if let baseline {
|
|
| 316 |
- comparisonSection(baseline: baseline) |
|
| 317 |
- } |
|
| 318 |
- |
|
| 319 |
- Spacer() |
|
| 320 |
- } |
|
| 321 |
- .padding(16) |
|
| 322 |
- } |
|
| 323 |
- .navigationTitle("Snapshot")
|
|
| 324 |
- .navigationBarTitleDisplayMode(.inline) |
|
| 325 |
- } |
|
| 326 |
- } |
|
| 327 |
- |
|
| 328 |
- @ViewBuilder |
|
| 329 |
- private func comparisonSection(baseline: HealthSnapshot) -> some View {
|
|
| 330 |
- let delta = archiveRecordChangeCount ?? 0 |
|
| 331 |
- let deltaPercent = computeDeltaPercent(delta: delta, baseline: baseline) |
|
| 332 |
- let affectedMetricCount = archiveAffectedMetricCount ?? 0 |
|
| 333 |
- let isSignificant = delta > 0 || affectedMetricCount > 0 || (deltaPercent > 10) |
|
| 334 |
- |
|
| 335 |
- DisclosureGroup {
|
|
| 336 |
- VStack(alignment: .leading, spacing: 12) {
|
|
| 337 |
- DetailRow(label: "Baseline") {
|
|
| 338 |
- Text(baseline.timestamp, format: .dateTime.month().day().hour().minute()) |
|
| 339 |
- .foregroundStyle(.secondary) |
|
| 340 |
- } |
|
| 341 |
- Divider() |
|
| 342 |
- DetailRow(label: "Time Span") {
|
|
| 343 |
- let days = Calendar.current.dateComponents([.day], from: baseline.timestamp, to: currentSnapshot.timestamp).day ?? 0 |
|
| 344 |
- Text(days == 0 ? "Same day" : "\(days) days") |
|
| 345 |
- .foregroundStyle(.secondary) |
|
| 346 |
- } |
|
| 347 |
- if archiveTypeRows != nil {
|
|
| 348 |
- Divider() |
|
| 349 |
- DetailRow(label: "Changed Metrics") {
|
|
| 350 |
- Text("\(affectedMetricCount)")
|
|
| 351 |
- .foregroundStyle(.secondary) |
|
| 352 |
- } |
|
| 353 |
- Divider() |
|
| 354 |
- DetailRow(label: "Record Changes") {
|
|
| 355 |
- Text("\(delta)")
|
|
| 356 |
- .foregroundStyle(.secondary) |
|
| 357 |
- } |
|
| 358 |
- } |
|
| 359 |
- } |
|
| 360 |
- .padding(.top, 8) |
|
| 361 |
- } label: {
|
|
| 362 |
- HStack(spacing: 8) {
|
|
| 363 |
- Image(systemName: "arrow.left.and.right.square") |
|
| 364 |
- .font(.system(size: 16, weight: .semibold)) |
|
| 365 |
- Text("Comparison")
|
|
| 366 |
- .font(.subheadline.weight(.semibold)) |
|
| 367 |
- Spacer() |
|
| 368 |
- if isSignificant {
|
|
| 369 |
- SeverityBadge(delta: delta) |
|
| 370 |
- .frame(height: 24) |
|
| 371 |
- } else {
|
|
| 372 |
- Text("–")
|
|
| 373 |
- .font(.caption2.weight(.semibold)) |
|
| 374 |
- .foregroundStyle(.secondary) |
|
| 375 |
- } |
|
| 376 |
- } |
|
| 377 |
- } |
|
| 378 |
- .padding(12) |
|
| 379 |
- .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12)) |
|
| 380 |
- } |
|
| 381 |
- |
|
| 382 |
- private func shortOSVersion(_ full: String) -> Text {
|
|
| 383 |
- if full.hasPrefix("iOS ") {
|
|
| 384 |
- let version = full.dropFirst(4).prefix(while: { $0 != " " })
|
|
| 385 |
- return Text("iOS \(version)")
|
|
| 386 |
- } |
|
| 387 |
- return Text(full) |
|
| 388 |
- } |
|
| 389 |
- |
|
| 390 |
- private func extractOSVersion(_ full: String) -> String {
|
|
| 391 |
- if full.hasPrefix("iOS ") {
|
|
| 392 |
- let versionPart = full.dropFirst(4).prefix(while: { $0 != " " && $0 != "(" })
|
|
| 393 |
- return String(versionPart) |
|
| 394 |
- } |
|
| 395 |
- return full |
|
| 396 |
- } |
|
| 397 |
- |
|
| 398 |
- private func extractBuildNumber(_ full: String) -> String {
|
|
| 399 |
- if let start = full.firstIndex(of: "("), let end = full.firstIndex(of: ")") {
|
|
| 400 |
- let buildPart = String(full[full.index(after: start)..<end]) |
|
| 401 |
- return buildPart.hasPrefix("Build ") ? String(buildPart.dropFirst(6)) : buildPart
|
|
| 402 |
- } |
|
| 403 |
- return full |
|
| 404 |
- } |
|
| 405 |
- |
|
| 406 |
- private func computeDeltaPercent(delta: Int, baseline: HealthSnapshot) -> Double {
|
|
| 407 |
- if let archiveTypeRows {
|
|
| 408 |
- let baselineTotal = archiveTypeRows.reduce(0) { $0 + ($1.previousCount ?? 0) }
|
|
| 409 |
- return baselineTotal > 0 ? Double(delta) / Double(baselineTotal) * 100 : 0 |
|
| 410 |
- } |
|
| 411 |
- |
|
| 412 |
- let baselineTotal = baseline.hasCurrentCachedSummary ? baseline.cachedRecordCount : 0 |
|
| 413 |
- return baselineTotal > 0 ? Double(delta) / Double(baselineTotal) * 100 : 0 |
|
| 414 |
- } |
|
| 415 |
- |
|
| 416 |
- private func statCompact(label: String, value: String) -> some View {
|
|
| 417 |
- VStack(alignment: .center, spacing: 2) {
|
|
| 418 |
- Text(label) |
|
| 419 |
- .font(.caption2.weight(.medium)) |
|
| 420 |
- Text(value) |
|
| 421 |
- .font(.subheadline.weight(.semibold).monospacedDigit()) |
|
| 422 |
- .foregroundStyle(.primary) |
|
| 423 |
- } |
|
| 424 |
- .frame(maxWidth: .infinity) |
|
| 425 |
- } |
|
| 426 |
- |
|
| 427 |
- private var evolutionSection: some View {
|
|
| 428 |
- Section("Data Types") {
|
|
| 429 |
- if let archiveTypeRows {
|
|
| 430 |
- if archiveTypeRows.isEmpty {
|
|
| 431 |
- Text("No data types are available for this snapshot.")
|
|
| 432 |
- .foregroundStyle(.secondary) |
|
| 433 |
- } else {
|
|
| 434 |
- ForEach(archiveTypeRows) { row in
|
|
| 435 |
- NavigationLink {
|
|
| 436 |
- DataTypeSnapshotDetailView( |
|
| 437 |
- snapshot: currentSnapshot, |
|
| 438 |
- typeIdentifier: row.typeIdentifier, |
|
| 439 |
- displayName: row.displayName |
|
| 440 |
- ) |
|
| 441 |
- } label: {
|
|
| 442 |
- SnapshotArchiveTypeRowView(row: row, hasBaseline: baseline != nil) |
|
| 443 |
- } |
|
| 444 |
- } |
|
| 445 |
- } |
|
| 446 |
- } else if baseline == nil {
|
|
| 447 |
- Text("This snapshot starts the chain, so no baseline comparison is available.")
|
|
| 448 |
- .foregroundStyle(.secondary) |
|
| 449 |
- } else {
|
|
| 450 |
- Text("Cached metric summary unavailable for this snapshot.")
|
|
| 451 |
- .foregroundStyle(.secondary) |
|
| 452 |
- } |
|
| 453 |
- } |
|
| 454 |
- } |
|
| 455 |
-} |
|
| 456 |
- |
|
| 457 |
-private struct SnapshotArchiveTypeRow: Identifiable {
|
|
| 458 |
- let typeIdentifier: String |
|
| 459 |
- let displayName: String |
|
| 460 |
- let currentCount: Int |
|
| 461 |
- let previousCount: Int? |
|
| 462 |
- let appearedCount: Int |
|
| 463 |
- let disappearedCount: Int |
|
| 464 |
- let representationChangedCount: Int |
|
| 465 |
- let earliestStartDate: Date? |
|
| 466 |
- let latestEndDate: Date? |
|
| 467 |
- |
|
| 468 |
- var id: String { typeIdentifier }
|
|
| 469 |
- |
|
| 470 |
- var recordChangeCount: Int {
|
|
| 471 |
- appearedCount + disappearedCount + representationChangedCount |
|
| 472 |
- } |
|
| 473 |
- |
|
| 474 |
- var hasChanges: Bool {
|
|
| 475 |
- currentDelta != 0 || recordChangeCount > 0 |
|
| 476 |
- } |
|
| 477 |
- |
|
| 478 |
- var currentDelta: Int {
|
|
| 479 |
- guard let previousCount else { return currentCount }
|
|
| 480 |
- return currentCount - previousCount |
|
| 481 |
- } |
|
| 482 |
-} |
|
| 483 |
- |
|
| 484 |
-private struct SnapshotArchiveTypeRowView: View {
|
|
| 485 |
- let row: SnapshotArchiveTypeRow |
|
| 486 |
- let hasBaseline: Bool |
|
| 487 |
- |
|
| 488 |
- private var countText: String {
|
|
| 489 |
- "\(row.currentCount)" |
|
| 490 |
- } |
|
| 491 |
- |
|
| 492 |
- private var changeLabel: String {
|
|
| 493 |
- guard hasBaseline else { return "Stored" }
|
|
| 494 |
- if row.disappearedCount > 0 {
|
|
| 495 |
- return "\(row.disappearedCount) missing" |
|
| 496 |
- } |
|
| 497 |
- if row.appearedCount > 0 {
|
|
| 498 |
- return "\(row.appearedCount) new" |
|
| 499 |
- } |
|
| 500 |
- if row.representationChangedCount > 0 {
|
|
| 501 |
- return "\(row.representationChangedCount) changed" |
|
| 502 |
- } |
|
| 503 |
- if row.currentDelta != 0 {
|
|
| 504 |
- let prefix = row.currentDelta > 0 ? "+" : "" |
|
| 505 |
- return "\(prefix)\(row.currentDelta) records" |
|
| 506 |
- } |
|
| 507 |
- return "No changes" |
|
| 508 |
- } |
|
| 509 |
- |
|
| 510 |
- private var changeColor: Color {
|
|
| 511 |
- guard hasBaseline else { return .secondary }
|
|
| 512 |
- if row.disappearedCount > 0 { return .criticalRed }
|
|
| 513 |
- if row.hasChanges { return .warningAmber }
|
|
| 514 |
- return .secondary |
|
| 515 |
- } |
|
| 516 |
- |
|
| 517 |
- var body: some View {
|
|
| 518 |
- HStack(spacing: 12) {
|
|
| 519 |
- VStack(alignment: .leading, spacing: 3) {
|
|
| 520 |
- Text(row.displayName) |
|
| 521 |
- .font(.subheadline) |
|
| 522 |
- Text(row.typeIdentifier) |
|
| 523 |
- .font(.caption2) |
|
| 524 |
- .foregroundStyle(.secondary) |
|
| 525 |
- .lineLimit(1) |
|
| 526 |
- .truncationMode(.middle) |
|
| 527 |
- } |
|
| 528 |
- |
|
| 529 |
- Spacer() |
|
| 530 |
- |
|
| 531 |
- VStack(alignment: .trailing, spacing: 4) {
|
|
| 532 |
- Text(countText) |
|
| 533 |
- .font(.subheadline.monospacedDigit()) |
|
| 534 |
- .foregroundStyle(.primary) |
|
| 535 |
- Text(changeLabel) |
|
| 536 |
- .font(.caption.weight(.semibold)) |
|
| 537 |
- .foregroundStyle(changeColor) |
|
| 538 |
- } |
|
| 539 |
- } |
|
| 540 |
- .accessibilityElement(children: .combine) |
|
| 541 |
- } |
|
| 542 |
-} |
|
| 543 |
- |
|
| 544 |
-private struct SnapshotDataRangeIndicator: View {
|
|
| 545 |
- let oldestRecordDate: Date? |
|
| 546 |
- let newestRecordDate: Date? |
|
| 547 |
- let quality: SnapshotQuality |
|
| 548 |
- |
|
| 549 |
- private var hasDateRange: Bool {
|
|
| 550 |
- oldestRecordDate != nil && newestRecordDate != nil |
|
| 551 |
- } |
|
| 552 |
- |
|
| 553 |
- private var daySpan: Int? {
|
|
| 554 |
- guard let oldest = oldestRecordDate, let newest = newestRecordDate else { return nil }
|
|
| 555 |
- return Calendar.current.dateComponents([.day], from: oldest, to: newest).day ?? 0 |
|
| 556 |
- } |
|
| 557 |
- |
|
| 558 |
- var body: some View {
|
|
| 559 |
- VStack(spacing: 12) {
|
|
| 560 |
- HStack(spacing: 8) {
|
|
| 561 |
- Text("Data Range")
|
|
| 562 |
- .font(.headline.weight(.semibold)) |
|
| 563 |
- |
|
| 564 |
- Spacer() |
|
| 565 |
- |
|
| 566 |
- qualityBadge |
|
| 567 |
- } |
|
| 568 |
- |
|
| 569 |
- if hasDateRange {
|
|
| 570 |
- dateRangeVisualization |
|
| 571 |
- } else {
|
|
| 572 |
- Text("No dated records available")
|
|
| 573 |
- .font(.subheadline) |
|
| 574 |
- .foregroundStyle(.secondary) |
|
| 575 |
- .frame(maxWidth: .infinity, alignment: .center) |
|
| 576 |
- .padding(.vertical, 16) |
|
| 577 |
- } |
|
| 578 |
- } |
|
| 579 |
- .padding(16) |
|
| 580 |
- .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12)) |
|
| 581 |
- } |
|
| 582 |
- |
|
| 583 |
- @ViewBuilder |
|
| 584 |
- private var qualityBadge: some View {
|
|
| 585 |
- if quality != .complete {
|
|
| 586 |
- Label("Incomplete", systemImage: "exclamationmark.triangle.fill")
|
|
| 587 |
- .font(.caption.weight(.medium)) |
|
| 588 |
- .foregroundStyle(Color.warningAmber) |
|
| 589 |
- .padding(.horizontal, 8) |
|
| 590 |
- .padding(.vertical, 4) |
|
| 591 |
- .background(Color.warningAmber.opacity(0.12), in: Capsule()) |
|
| 592 |
- } |
|
| 593 |
- } |
|
| 594 |
- |
|
| 595 |
- @ViewBuilder |
|
| 596 |
- private var dateRangeVisualization: some View {
|
|
| 597 |
- if let oldest = oldestRecordDate, let newest = newestRecordDate, let span = daySpan {
|
|
| 598 |
- VStack(spacing: 12) {
|
|
| 599 |
- HStack(alignment: .top, spacing: 12) {
|
|
| 600 |
- VStack(alignment: .center, spacing: 4) {
|
|
| 601 |
- Image(systemName: "calendar.badge.clock") |
|
| 602 |
- .font(.system(size: 16, weight: .semibold)) |
|
| 603 |
- .foregroundStyle(Color.healthyGreen) |
|
| 604 |
- |
|
| 605 |
- VStack(alignment: .center, spacing: 2) {
|
|
| 606 |
- Text("Oldest record")
|
|
| 607 |
- .font(.caption2.weight(.medium)) |
|
| 608 |
- .foregroundStyle(.secondary) |
|
| 609 |
- Text(oldest, format: .dateTime.month().day().year()) |
|
| 610 |
- .font(.caption.weight(.semibold)) |
|
| 611 |
- } |
|
| 612 |
- } |
|
| 613 |
- .frame(maxWidth: .infinity) |
|
| 614 |
- |
|
| 615 |
- VStack(alignment: .center, spacing: 4) {
|
|
| 616 |
- Text("\(span)")
|
|
| 617 |
- .font(.system(size: 18, weight: .semibold).monospacedDigit()) |
|
| 618 |
- .foregroundStyle(.primary) |
|
| 619 |
- |
|
| 620 |
- Text("days")
|
|
| 621 |
- .font(.caption2.weight(.medium)) |
|
| 622 |
- .foregroundStyle(.secondary) |
|
| 623 |
- } |
|
| 624 |
- |
|
| 625 |
- VStack(alignment: .center, spacing: 4) {
|
|
| 626 |
- Image(systemName: "calendar.badge.clock") |
|
| 627 |
- .font(.system(size: 16, weight: .semibold)) |
|
| 628 |
- .foregroundStyle(Color.accentColor) |
|
| 629 |
- |
|
| 630 |
- VStack(alignment: .center, spacing: 2) {
|
|
| 631 |
- Text("Newest record")
|
|
| 632 |
- .font(.caption2.weight(.medium)) |
|
| 633 |
- .foregroundStyle(.secondary) |
|
| 634 |
- Text(newest, format: .dateTime.month().day().year()) |
|
| 635 |
- .font(.caption.weight(.semibold)) |
|
| 636 |
- } |
|
| 637 |
- } |
|
| 638 |
- .frame(maxWidth: .infinity) |
|
| 639 |
- } |
|
| 640 |
- |
|
| 641 |
- timelineBar |
|
| 642 |
- } |
|
| 643 |
- } |
|
| 644 |
- } |
|
| 645 |
- |
|
| 646 |
- @ViewBuilder |
|
| 647 |
- private var timelineBar: some View {
|
|
| 648 |
- if oldestRecordDate != nil, newestRecordDate != nil {
|
|
| 649 |
- ZStack(alignment: .leading) {
|
|
| 650 |
- RoundedRectangle(cornerRadius: 3) |
|
| 651 |
- .fill(Color(.systemGray5)) |
|
| 652 |
- |
|
| 653 |
- RoundedRectangle(cornerRadius: 3) |
|
| 654 |
- .fill( |
|
| 655 |
- LinearGradient( |
|
| 656 |
- gradient: Gradient(colors: [Color.healthyGreen, Color.accentColor]), |
|
| 657 |
- startPoint: .leading, |
|
| 658 |
- endPoint: .trailing |
|
| 659 |
- ) |
|
| 660 |
- ) |
|
| 661 |
- .opacity(0.7) |
|
| 662 |
- } |
|
| 663 |
- .frame(height: 4) |
|
| 664 |
- } |
|
| 665 |
- } |
|
| 666 |
-} |
|
| 667 |
- |
|
| 668 |
-private struct DetailRow<Content: View>: View {
|
|
| 669 |
- let label: String |
|
| 670 |
- @ViewBuilder let content: () -> Content |
|
| 671 |
- |
|
| 672 |
- var body: some View {
|
|
| 673 |
- HStack {
|
|
| 674 |
- Text(label) |
|
| 675 |
- Spacer() |
|
| 676 |
- content() |
|
| 677 |
- } |
|
| 678 |
- } |
|
| 679 |
-} |
|
| 680 |
- |
|
| 681 |
-private struct ShareSheet: UIViewControllerRepresentable {
|
|
| 682 |
- let items: [Any] |
|
| 683 |
- |
|
| 684 |
- func makeUIViewController(context: Context) -> UIActivityViewController {
|
|
| 685 |
- UIActivityViewController(activityItems: items, applicationActivities: nil) |
|
| 686 |
- } |
|
| 687 |
- |
|
| 688 |
- func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
|
| 689 |
-} |
|
| 690 |
- |
|
| 691 |
-#Preview {
|
|
| 692 |
- NavigationStack {
|
|
| 693 |
- SnapshotDetailView( |
|
| 694 |
- snapshot: HealthSnapshot( |
|
| 695 |
- timestamp: .now, |
|
| 696 |
- osVersion: "iOS 26.4", |
|
| 697 |
- deviceName: "Preview iPhone", |
|
| 698 |
- deviceID: "preview-device" |
|
| 699 |
- ), |
|
| 700 |
- baseline: nil, |
|
| 701 |
- profile: LocalDeviceProfile(deviceID: "preview-device") |
|
| 702 |
- ) |
|
| 703 |
- } |
|
| 704 |
- .modelContainer(for: [HealthSnapshot.self], inMemory: true) |
|
| 705 |
-} |
|