@@ -6,11 +6,6 @@ |
||
| 6 | 6 |
objectVersion = 77; |
| 7 | 7 |
objects = {
|
| 8 | 8 |
|
| 9 |
-/* Begin PBXFileReference section */ |
|
| 10 |
- 439832792FA4933E003C0182 /* HealthProbe.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HealthProbe.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
| 11 |
- 43A100012FA5000000000001 /* HealthProbeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HealthProbeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
| 12 |
-/* End PBXFileReference section */ |
|
| 13 |
- |
|
| 14 | 9 |
/* Begin PBXContainerItemProxy section */ |
| 15 | 10 |
43A1000B2FA5000000000001 /* PBXContainerItemProxy */ = {
|
| 16 | 11 |
isa = PBXContainerItemProxy; |
@@ -21,6 +16,11 @@ |
||
| 21 | 16 |
}; |
| 22 | 17 |
/* End PBXContainerItemProxy section */ |
| 23 | 18 |
|
| 19 |
+/* Begin PBXFileReference section */ |
|
| 20 |
+ 439832792FA4933E003C0182 /* HealthProbe.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HealthProbe.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
| 21 |
+ 43A100012FA5000000000001 /* HealthProbeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HealthProbeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
| 22 |
+/* End PBXFileReference section */ |
|
| 23 |
+ |
|
| 24 | 24 |
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ |
| 25 | 25 |
439832862FA4933F003C0182 /* Exceptions for "HealthProbe" folder in "HealthProbe" target */ = {
|
| 26 | 26 |
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; |
@@ -309,60 +309,6 @@ |
||
| 309 | 309 |
}; |
| 310 | 310 |
name = Release; |
| 311 | 311 |
}; |
| 312 |
- 43A100072FA5000000000001 /* Debug */ = {
|
|
| 313 |
- isa = XCBuildConfiguration; |
|
| 314 |
- buildSettings = {
|
|
| 315 |
- BUNDLE_LOADER = "$(TEST_HOST)"; |
|
| 316 |
- CODE_SIGN_STYLE = Automatic; |
|
| 317 |
- CURRENT_PROJECT_VERSION = 1; |
|
| 318 |
- DEVELOPMENT_TEAM = 9K2U3V9GZF; |
|
| 319 |
- GENERATE_INFOPLIST_FILE = YES; |
|
| 320 |
- IPHONEOS_DEPLOYMENT_TARGET = 26.4; |
|
| 321 |
- LD_RUNPATH_SEARCH_PATHS = ( |
|
| 322 |
- "$(inherited)", |
|
| 323 |
- "@executable_path/Frameworks", |
|
| 324 |
- "@loader_path/Frameworks", |
|
| 325 |
- ); |
|
| 326 |
- MARKETING_VERSION = 1.0; |
|
| 327 |
- PRODUCT_BUNDLE_IDENTIFIER = ro.xdev.HealthProbeTests; |
|
| 328 |
- PRODUCT_NAME = "$(TARGET_NAME)"; |
|
| 329 |
- SDKROOT = iphoneos; |
|
| 330 |
- SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; |
|
| 331 |
- SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; |
|
| 332 |
- SWIFT_VERSION = 5.0; |
|
| 333 |
- TARGETED_DEVICE_FAMILY = 1; |
|
| 334 |
- TEST_HOST = "$(BUILT_PRODUCTS_DIR)/HealthProbe.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/HealthProbe"; |
|
| 335 |
- TEST_TARGET_NAME = HealthProbe; |
|
| 336 |
- }; |
|
| 337 |
- name = Debug; |
|
| 338 |
- }; |
|
| 339 |
- 43A100082FA5000000000001 /* Release */ = {
|
|
| 340 |
- isa = XCBuildConfiguration; |
|
| 341 |
- buildSettings = {
|
|
| 342 |
- BUNDLE_LOADER = "$(TEST_HOST)"; |
|
| 343 |
- CODE_SIGN_STYLE = Automatic; |
|
| 344 |
- CURRENT_PROJECT_VERSION = 1; |
|
| 345 |
- DEVELOPMENT_TEAM = 9K2U3V9GZF; |
|
| 346 |
- GENERATE_INFOPLIST_FILE = YES; |
|
| 347 |
- IPHONEOS_DEPLOYMENT_TARGET = 26.4; |
|
| 348 |
- LD_RUNPATH_SEARCH_PATHS = ( |
|
| 349 |
- "$(inherited)", |
|
| 350 |
- "@executable_path/Frameworks", |
|
| 351 |
- "@loader_path/Frameworks", |
|
| 352 |
- ); |
|
| 353 |
- MARKETING_VERSION = 1.0; |
|
| 354 |
- PRODUCT_BUNDLE_IDENTIFIER = ro.xdev.HealthProbeTests; |
|
| 355 |
- PRODUCT_NAME = "$(TARGET_NAME)"; |
|
| 356 |
- SDKROOT = iphoneos; |
|
| 357 |
- SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; |
|
| 358 |
- SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; |
|
| 359 |
- SWIFT_VERSION = 5.0; |
|
| 360 |
- TARGETED_DEVICE_FAMILY = 1; |
|
| 361 |
- TEST_HOST = "$(BUILT_PRODUCTS_DIR)/HealthProbe.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/HealthProbe"; |
|
| 362 |
- TEST_TARGET_NAME = HealthProbe; |
|
| 363 |
- }; |
|
| 364 |
- name = Release; |
|
| 365 |
- }; |
|
| 366 | 312 |
4398328A2FA4933F003C0182 /* Debug */ = {
|
| 367 | 313 |
isa = XCBuildConfiguration; |
| 368 | 314 |
buildSettings = {
|
@@ -486,6 +432,60 @@ |
||
| 486 | 432 |
}; |
| 487 | 433 |
name = Release; |
| 488 | 434 |
}; |
| 435 |
+ 43A100072FA5000000000001 /* Debug */ = {
|
|
| 436 |
+ isa = XCBuildConfiguration; |
|
| 437 |
+ buildSettings = {
|
|
| 438 |
+ BUNDLE_LOADER = "$(TEST_HOST)"; |
|
| 439 |
+ CODE_SIGN_STYLE = Automatic; |
|
| 440 |
+ CURRENT_PROJECT_VERSION = 1; |
|
| 441 |
+ DEVELOPMENT_TEAM = 9K2U3V9GZF; |
|
| 442 |
+ GENERATE_INFOPLIST_FILE = YES; |
|
| 443 |
+ IPHONEOS_DEPLOYMENT_TARGET = 26.4; |
|
| 444 |
+ LD_RUNPATH_SEARCH_PATHS = ( |
|
| 445 |
+ "$(inherited)", |
|
| 446 |
+ "@executable_path/Frameworks", |
|
| 447 |
+ "@loader_path/Frameworks", |
|
| 448 |
+ ); |
|
| 449 |
+ MARKETING_VERSION = 1.0; |
|
| 450 |
+ PRODUCT_BUNDLE_IDENTIFIER = ro.xdev.HealthProbeTests; |
|
| 451 |
+ PRODUCT_NAME = "$(TARGET_NAME)"; |
|
| 452 |
+ SDKROOT = iphoneos; |
|
| 453 |
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; |
|
| 454 |
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; |
|
| 455 |
+ SWIFT_VERSION = 5.0; |
|
| 456 |
+ TARGETED_DEVICE_FAMILY = 1; |
|
| 457 |
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/HealthProbe.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/HealthProbe"; |
|
| 458 |
+ TEST_TARGET_NAME = HealthProbe; |
|
| 459 |
+ }; |
|
| 460 |
+ name = Debug; |
|
| 461 |
+ }; |
|
| 462 |
+ 43A100082FA5000000000001 /* Release */ = {
|
|
| 463 |
+ isa = XCBuildConfiguration; |
|
| 464 |
+ buildSettings = {
|
|
| 465 |
+ BUNDLE_LOADER = "$(TEST_HOST)"; |
|
| 466 |
+ CODE_SIGN_STYLE = Automatic; |
|
| 467 |
+ CURRENT_PROJECT_VERSION = 1; |
|
| 468 |
+ DEVELOPMENT_TEAM = 9K2U3V9GZF; |
|
| 469 |
+ GENERATE_INFOPLIST_FILE = YES; |
|
| 470 |
+ IPHONEOS_DEPLOYMENT_TARGET = 26.4; |
|
| 471 |
+ LD_RUNPATH_SEARCH_PATHS = ( |
|
| 472 |
+ "$(inherited)", |
|
| 473 |
+ "@executable_path/Frameworks", |
|
| 474 |
+ "@loader_path/Frameworks", |
|
| 475 |
+ ); |
|
| 476 |
+ MARKETING_VERSION = 1.0; |
|
| 477 |
+ PRODUCT_BUNDLE_IDENTIFIER = ro.xdev.HealthProbeTests; |
|
| 478 |
+ PRODUCT_NAME = "$(TARGET_NAME)"; |
|
| 479 |
+ SDKROOT = iphoneos; |
|
| 480 |
+ SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; |
|
| 481 |
+ SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; |
|
| 482 |
+ SWIFT_VERSION = 5.0; |
|
| 483 |
+ TARGETED_DEVICE_FAMILY = 1; |
|
| 484 |
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/HealthProbe.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/HealthProbe"; |
|
| 485 |
+ TEST_TARGET_NAME = HealthProbe; |
|
| 486 |
+ }; |
|
| 487 |
+ name = Release; |
|
| 488 |
+ }; |
|
| 489 | 489 |
/* End XCBuildConfiguration section */ |
| 490 | 490 |
|
| 491 | 491 |
/* Begin XCConfigurationList section */ |
@@ -1,6 +1,6 @@ |
||
| 1 | 1 |
<?xml version="1.0" encoding="UTF-8"?> |
| 2 | 2 |
<Scheme |
| 3 |
- LastUpgradeVersion = "2640" |
|
| 3 |
+ LastUpgradeVersion = "2650" |
|
| 4 | 4 |
version = "1.7"> |
| 5 | 5 |
<BuildAction |
| 6 | 6 |
parallelizeBuildables = "YES" |
@@ -9,6 +9,11 @@ |
||
| 9 | 9 |
<key>orderHint</key> |
| 10 | 10 |
<integer>0</integer> |
| 11 | 11 |
</dict> |
| 12 |
+ <key>HealthProbeTests.xcscheme_^#shared#^_</key> |
|
| 13 |
+ <dict> |
|
| 14 |
+ <key>orderHint</key> |
|
| 15 |
+ <integer>1</integer> |
|
| 16 |
+ </dict> |
|
| 12 | 17 |
</dict> |
| 13 | 18 |
<key>SuppressBuildableAutocreation</key> |
| 14 | 19 |
<dict> |
@@ -28,7 +28,7 @@ There are no real deployments, only test installations. Existing prototype datab |
||
| 28 | 28 |
| SQLite archive | Archive v2 schema, 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 | Start Core Data cache work | |
| 29 | 29 |
| Core Data cache | Initial programmatic Core Data model and full-cache rebuild service are in place for observation rows, type summaries, daily aggregates, diff summaries, export manifest rows, and archive health | Wire cache reads into UI-facing view models and add targeted partial invalidation | |
| 30 | 30 |
| SwiftData cache | Exists | Treat as disposable prototype data; reset/ignore during v2 transition | |
| 31 |
-| UI | Prototype exists | Reframe screens around observations, diffs, export, archive status | |
|
| 31 |
+| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker, and record-change detail uses a separate preview/paged list view with archive-value enrichment and scoped export action | Reframe remaining screens around observations, diffs, export, archive status | |
|
| 32 | 32 |
| Diff/change explanation | Prototype/legacy anomaly logic exists | Move heavy diffing into SQLite and use neutral change classifications | |
| 33 | 33 |
| Export | Prototype scoped JSON export exists | Add recovery-compatible manifests and streaming/paged export | |
| 34 | 34 |
| Legacy device support | Not implemented | Remove SwiftData dependency and simplify heavy views for low-memory devices | |
@@ -1,10 +1,8 @@ |
||
| 1 | 1 |
import Foundation |
| 2 |
-import UIKit |
|
| 3 | 2 |
|
| 4 | 3 |
@Observable |
| 5 | 4 |
final class AppSettings {
|
| 6 | 5 |
private static let selectedTypeIDsKey = "hp_selectedTypeIDs" |
| 7 |
- private static let selectedDeviceIDsKey = "hp_selectedDeviceIDs" |
|
| 8 | 6 |
static let adaptiveTimeoutsEnabledKey = "hp_adaptiveTimeoutsEnabled" |
| 9 | 7 |
static let typeDetailCacheBackfillVersionKey = "hp_typeDetailCacheBackfillVersion" |
| 10 | 8 |
static let currentTypeDetailCacheBackfillVersion = 2 |
@@ -13,10 +11,6 @@ final class AppSettings {
|
||
| 13 | 11 |
didSet { persistTypes() }
|
| 14 | 12 |
} |
| 15 | 13 |
|
| 16 |
- var selectedDeviceIDs: Set<String> {
|
|
| 17 |
- didSet { persistDevices() }
|
|
| 18 |
- } |
|
| 19 |
- |
|
| 20 | 14 |
var adaptiveTimeoutsEnabled: Bool {
|
| 21 | 15 |
didSet {
|
| 22 | 16 |
UserDefaults.standard.set(adaptiveTimeoutsEnabled, forKey: Self.adaptiveTimeoutsEnabledKey) |
@@ -35,27 +29,11 @@ final class AppSettings {
|
||
| 35 | 29 |
selectedTypeIDs = Set(HealthKitService.allTypes.filter { $0.isEnabledByDefault }.map { $0.id })
|
| 36 | 30 |
} |
| 37 | 31 |
|
| 38 |
- if let data = UserDefaults.standard.data(forKey: Self.selectedDeviceIDsKey), |
|
| 39 |
- let ids = try? JSONDecoder().decode([String].self, from: data) {
|
|
| 40 |
- let storedIDs = Set(ids) |
|
| 41 |
- let oldCurrentID = UIDevice.current.identifierForVendor?.uuidString |
|
| 42 |
- if let oldCurrentID, storedIDs == [oldCurrentID] {
|
|
| 43 |
- selectedDeviceIDs = [Self.currentDeviceID] |
|
| 44 |
- } else {
|
|
| 45 |
- selectedDeviceIDs = storedIDs |
|
| 46 |
- } |
|
| 47 |
- } else {
|
|
| 48 |
- let currentID = Self.currentDeviceID |
|
| 49 |
- selectedDeviceIDs = currentID.isEmpty ? [] : [currentID] |
|
| 50 |
- } |
|
| 51 |
- |
|
| 52 | 32 |
if UserDefaults.standard.object(forKey: Self.adaptiveTimeoutsEnabledKey) == nil {
|
| 53 | 33 |
adaptiveTimeoutsEnabled = true |
| 54 | 34 |
} else {
|
| 55 | 35 |
adaptiveTimeoutsEnabled = UserDefaults.standard.bool(forKey: Self.adaptiveTimeoutsEnabledKey) |
| 56 | 36 |
} |
| 57 |
- |
|
| 58 |
- persistDevices() |
|
| 59 | 37 |
} |
| 60 | 38 |
|
| 61 | 39 |
func isEnabled(_ type: MonitoredType) -> Bool { selectedTypeIDs.contains(type.id) }
|
@@ -65,18 +43,8 @@ final class AppSettings {
|
||
| 65 | 43 |
else { selectedTypeIDs.insert(type.id) }
|
| 66 | 44 |
} |
| 67 | 45 |
|
| 68 |
- func toggleDevice(_ id: String) {
|
|
| 69 |
- if selectedDeviceIDs.contains(id) { selectedDeviceIDs.remove(id) }
|
|
| 70 |
- else { selectedDeviceIDs.insert(id) }
|
|
| 71 |
- } |
|
| 72 |
- |
|
| 73 | 46 |
private func persistTypes() {
|
| 74 | 47 |
UserDefaults.standard.set(try? JSONEncoder().encode(Array(selectedTypeIDs)), |
| 75 | 48 |
forKey: Self.selectedTypeIDsKey) |
| 76 | 49 |
} |
| 77 |
- |
|
| 78 |
- private func persistDevices() {
|
|
| 79 |
- UserDefaults.standard.set(try? JSONEncoder().encode(Array(selectedDeviceIDs)), |
|
| 80 |
- forKey: Self.selectedDeviceIDsKey) |
|
| 81 |
- } |
|
| 82 | 50 |
} |
@@ -26,13 +26,6 @@ enum DeviceColor: String, CaseIterable, Identifiable {
|
||
| 26 | 26 |
} |
| 27 | 27 |
} |
| 28 | 28 |
|
| 29 |
-struct DeviceEntry: Identifiable {
|
|
| 30 |
- let id: String // deviceID; "" = unidentified |
|
| 31 |
- let displayName: String |
|
| 32 |
- let color: Color |
|
| 33 |
- let isCurrent: Bool |
|
| 34 |
-} |
|
| 35 |
- |
|
| 36 | 29 |
struct SeverityBadge: View {
|
| 37 | 30 |
let delta: Int |
| 38 | 31 |
var dimmed: Bool = false |
@@ -0,0 +1,143 @@ |
||
| 1 |
+import Foundation |
|
| 2 |
+import Observation |
|
| 3 |
+ |
|
| 4 |
+enum RecordListMode {
|
|
| 5 |
+ case disappeared(typeIdentifier: String) |
|
| 6 |
+ case added(typeIdentifier: String, afterDate: Date, beforeDate: Date) |
|
| 7 |
+ |
|
| 8 |
+ var typeIdentifier: String {
|
|
| 9 |
+ switch self {
|
|
| 10 |
+ case .disappeared(let typeID): |
|
| 11 |
+ return typeID |
|
| 12 |
+ case .added(let typeID, _, _): |
|
| 13 |
+ return typeID |
|
| 14 |
+ } |
|
| 15 |
+ } |
|
| 16 |
+} |
|
| 17 |
+ |
|
| 18 |
+enum ExportState: Equatable {
|
|
| 19 |
+ case idle |
|
| 20 |
+ case loading |
|
| 21 |
+ case ready(URL) |
|
| 22 |
+ case failed(String) |
|
| 23 |
+} |
|
| 24 |
+ |
|
| 25 |
+@MainActor |
|
| 26 |
+@Observable |
|
| 27 |
+final class DataTypeRecordListViewModel {
|
|
| 28 |
+ let typeIdentifier: String |
|
| 29 |
+ let displayName: String |
|
| 30 |
+ let totalCount: Int |
|
| 31 |
+ let mode: RecordListMode |
|
| 32 |
+ let previewRecords: [HealthRecordValue] |
|
| 33 |
+ |
|
| 34 |
+ var displayRecords: [HealthRecordValue] = [] |
|
| 35 |
+ var isLoadingMore = false |
|
| 36 |
+ var hasMore: Bool { displayRecords.count < min(totalCount, previewRecords.count) }
|
|
| 37 |
+ var exportState: ExportState = .idle |
|
| 38 |
+ |
|
| 39 |
+ private var pageIndex = 0 |
|
| 40 |
+ private let pageSize = 50 |
|
| 41 |
+ private let archiveStore: HealthArchiveStore |
|
| 42 |
+ |
|
| 43 |
+ init( |
|
| 44 |
+ typeIdentifier: String, |
|
| 45 |
+ displayName: String, |
|
| 46 |
+ totalCount: Int, |
|
| 47 |
+ mode: RecordListMode, |
|
| 48 |
+ previewRecords: [HealthRecordValue] = [], |
|
| 49 |
+ archiveStore: HealthArchiveStore |
|
| 50 |
+ ) {
|
|
| 51 |
+ self.typeIdentifier = typeIdentifier |
|
| 52 |
+ self.displayName = displayName |
|
| 53 |
+ self.totalCount = totalCount |
|
| 54 |
+ self.mode = mode |
|
| 55 |
+ self.previewRecords = previewRecords |
|
| 56 |
+ self.archiveStore = archiveStore |
|
| 57 |
+ } |
|
| 58 |
+ |
|
| 59 |
+ func loadFirstPage() async {
|
|
| 60 |
+ guard displayRecords.isEmpty else { return }
|
|
| 61 |
+ pageIndex = 0 |
|
| 62 |
+ await loadNextPage() |
|
| 63 |
+ } |
|
| 64 |
+ |
|
| 65 |
+ func loadNextPage() async {
|
|
| 66 |
+ guard !isLoadingMore, hasMore else { return }
|
|
| 67 |
+ isLoadingMore = true |
|
| 68 |
+ defer { isLoadingMore = false }
|
|
| 69 |
+ |
|
| 70 |
+ let batchStart = pageIndex |
|
| 71 |
+ let batchEnd = min(pageIndex + pageSize, previewRecords.count) |
|
| 72 |
+ |
|
| 73 |
+ guard batchStart < previewRecords.count else {
|
|
| 74 |
+ return |
|
| 75 |
+ } |
|
| 76 |
+ |
|
| 77 |
+ let batch = Array(previewRecords[batchStart..<batchEnd]) |
|
| 78 |
+ |
|
| 79 |
+ // Enrich batch with actual values from archive store |
|
| 80 |
+ var enrichedBatch = batch |
|
| 81 |
+ do {
|
|
| 82 |
+ let fingerprints = Set(batch.map { $0.recordFingerprint })
|
|
| 83 |
+ let request = HealthArchiveRecordRequest( |
|
| 84 |
+ sampleTypeIdentifier: typeIdentifier, |
|
| 85 |
+ fingerprints: fingerprints, |
|
| 86 |
+ limit: nil |
|
| 87 |
+ ) |
|
| 88 |
+ let archivedRecords = try await archiveStore.records(for: request) |
|
| 89 |
+ |
|
| 90 |
+ let archiveByFingerprint = Dictionary(grouping: archivedRecords, by: { $0.strictFingerprint })
|
|
| 91 |
+ enrichedBatch = batch.map { cached in
|
|
| 92 |
+ guard let archived = archiveByFingerprint[cached.recordFingerprint]?.first, |
|
| 93 |
+ cached.displayValue == nil else { return cached }
|
|
| 94 |
+ return HealthRecordValue( |
|
| 95 |
+ typeIdentifier: cached.typeIdentifier, |
|
| 96 |
+ sampleUUIDHash: cached.sampleUUIDHash, |
|
| 97 |
+ recordFingerprint: cached.recordFingerprint, |
|
| 98 |
+ startDate: cached.startDate, |
|
| 99 |
+ endDate: cached.endDate, |
|
| 100 |
+ displayValue: archived.displayValue |
|
| 101 |
+ ) |
|
| 102 |
+ } |
|
| 103 |
+ } catch {
|
|
| 104 |
+ // Cached preview records remain usable when the archive cannot enrich display values. |
|
| 105 |
+ } |
|
| 106 |
+ |
|
| 107 |
+ displayRecords.append(contentsOf: enrichedBatch) |
|
| 108 |
+ pageIndex = batchEnd |
|
| 109 |
+ } |
|
| 110 |
+ |
|
| 111 |
+ func exportAllRecords() async {
|
|
| 112 |
+ exportState = .loading |
|
| 113 |
+ |
|
| 114 |
+ do {
|
|
| 115 |
+ let reportRequest = buildExportRequest() |
|
| 116 |
+ let exportURL = try await archiveStore.exportReport(reportRequest) |
|
| 117 |
+ exportState = .ready(exportURL) |
|
| 118 |
+ } catch {
|
|
| 119 |
+ exportState = .failed(error.localizedDescription) |
|
| 120 |
+ } |
|
| 121 |
+ } |
|
| 122 |
+ |
|
| 123 |
+ |
|
| 124 |
+ private func buildExportRequest() -> HealthArchiveReportRequest {
|
|
| 125 |
+ switch mode {
|
|
| 126 |
+ case .disappeared(let typeID): |
|
| 127 |
+ return HealthArchiveReportRequest( |
|
| 128 |
+ reportID: UUID(), |
|
| 129 |
+ title: "Disappeared Records - \(displayName)", |
|
| 130 |
+ typeIdentifierFilter: typeID, |
|
| 131 |
+ disappearedOnly: true |
|
| 132 |
+ ) |
|
| 133 |
+ case .added(let typeID, let afterDate, let beforeDate): |
|
| 134 |
+ return HealthArchiveReportRequest( |
|
| 135 |
+ reportID: UUID(), |
|
| 136 |
+ title: "Added Records - \(displayName)", |
|
| 137 |
+ typeIdentifierFilter: typeID, |
|
| 138 |
+ firstSeenAfter: afterDate, |
|
| 139 |
+ firstSeenBefore: beforeDate |
|
| 140 |
+ ) |
|
| 141 |
+ } |
|
| 142 |
+ } |
|
| 143 |
+} |
|
@@ -2,41 +2,23 @@ import SwiftUI |
||
| 2 | 2 |
import SwiftData |
| 3 | 3 |
|
| 4 | 4 |
struct DataTypesView: View {
|
| 5 |
- @Environment(AppSettings.self) private var appSettings |
|
| 6 | 5 |
@Query(sort: \HealthSnapshot.timestamp, order: .reverse) private var allSnapshots: [HealthSnapshot] |
| 7 |
- @Query private var deviceProfiles: [DeviceProfile] |
|
| 8 | 6 |
@State private var viewModel = DataTypesViewModel() |
| 9 | 7 |
|
| 10 |
- private var profileMap: [String: DeviceProfile] {
|
|
| 11 |
- Dictionary(uniqueKeysWithValues: deviceProfiles.compactMap {
|
|
| 12 |
- $0.deviceID.isEmpty ? nil : ($0.deviceID, $0) |
|
| 13 |
- }) |
|
| 14 |
- } |
|
| 15 |
- |
|
| 16 | 8 |
private var displayedSnapshots: [HealthSnapshot] {
|
| 17 |
- let selected = appSettings.selectedDeviceIDs |
|
| 18 |
- guard !selected.isEmpty else { return [] }
|
|
| 19 |
- return allSnapshots.filter { selected.contains($0.deviceID) }
|
|
| 9 |
+ guard let deviceID = localDeviceID else { return [] }
|
|
| 10 |
+ return allSnapshots.filter { $0.deviceID == deviceID }
|
|
| 20 | 11 |
} |
| 21 | 12 |
|
| 22 | 13 |
private var latest: HealthSnapshot? { displayedSnapshots.first }
|
| 23 | 14 |
|
| 24 |
- private var knownDevices: [DeviceEntry] {
|
|
| 15 |
+ private var localDeviceID: String? {
|
|
| 25 | 16 |
let currentID = AppSettings.currentDeviceID |
| 26 |
- var ids = Set(allSnapshots.map { $0.deviceID })
|
|
| 27 |
- if !currentID.isEmpty { ids.insert(currentID) }
|
|
| 28 |
- return ids.map { id in
|
|
| 29 |
- let profile = profileMap[id] |
|
| 30 |
- let name: String |
|
| 31 |
- if let n = profile?.name, !n.isEmpty { name = n }
|
|
| 32 |
- else if id.isEmpty { name = "Unidentified" }
|
|
| 33 |
- else { name = "Unknown Device" }
|
|
| 34 |
- let color = DeviceColor(rawValue: profile?.colorTag ?? "")?.color ?? .neutralGray |
|
| 35 |
- return DeviceEntry(id: id, displayName: name, color: color, isCurrent: id == currentID) |
|
| 36 |
- }.sorted {
|
|
| 37 |
- if $0.isCurrent != $1.isCurrent { return $0.isCurrent }
|
|
| 38 |
- return $0.displayName < $1.displayName |
|
| 17 |
+ if allSnapshots.contains(where: { $0.deviceID == currentID }) {
|
|
| 18 |
+ return currentID |
|
| 39 | 19 |
} |
| 20 |
+ |
|
| 21 |
+ return allSnapshots.first?.deviceID |
|
| 40 | 22 |
} |
| 41 | 23 |
|
| 42 | 24 |
var body: some View {
|
@@ -45,10 +27,8 @@ struct DataTypesView: View {
|
||
| 45 | 27 |
if displayedSnapshots.count < 2 {
|
| 46 | 28 |
EmptyStateView( |
| 47 | 29 |
icon: "waveform.path.ecg", |
| 48 |
- title: appSettings.selectedDeviceIDs.isEmpty ? "No Devices Selected" : "Not Enough Data", |
|
| 49 |
- message: appSettings.selectedDeviceIDs.isEmpty |
|
| 50 |
- ? "Select at least one device using the filter above." |
|
| 51 |
- : "Create at least two snapshots to compare data types." |
|
| 30 |
+ title: "Not Enough Data", |
|
| 31 |
+ message: "Create at least two local snapshots to compare data types." |
|
| 52 | 32 |
) |
| 53 | 33 |
} else {
|
| 54 | 34 |
typeList |
@@ -106,9 +86,6 @@ struct DataTypesView: View {
|
||
| 106 | 86 |
|
| 107 | 87 |
@ToolbarContentBuilder |
| 108 | 88 |
private var filterPicker: some ToolbarContent {
|
| 109 |
- ToolbarItem(placement: .topBarLeading) {
|
|
| 110 |
- devicePickerMenu |
|
| 111 |
- } |
|
| 112 | 89 |
ToolbarItem(placement: .navigationBarTrailing) {
|
| 113 | 90 |
Menu {
|
| 114 | 91 |
Picker("Filter", selection: $viewModel.filter) {
|
@@ -122,32 +99,6 @@ struct DataTypesView: View {
|
||
| 122 | 99 |
} |
| 123 | 100 |
} |
| 124 | 101 |
} |
| 125 |
- |
|
| 126 |
- private var devicePickerMenu: some View {
|
|
| 127 |
- let selected = appSettings.selectedDeviceIDs |
|
| 128 |
- let isMulti = selected.count > 1 |
|
| 129 |
- return Menu {
|
|
| 130 |
- ForEach(knownDevices) { entry in
|
|
| 131 |
- Button {
|
|
| 132 |
- appSettings.toggleDevice(entry.id) |
|
| 133 |
- } label: {
|
|
| 134 |
- Label {
|
|
| 135 |
- Text(entry.isCurrent |
|
| 136 |
- ? "\(entry.displayName) (This Device)" |
|
| 137 |
- : entry.displayName) |
|
| 138 |
- } icon: {
|
|
| 139 |
- Image(systemName: selected.contains(entry.id) |
|
| 140 |
- ? "checkmark.circle.fill" : "circle.fill") |
|
| 141 |
- .foregroundStyle(entry.color) |
|
| 142 |
- } |
|
| 143 |
- } |
|
| 144 |
- } |
|
| 145 |
- } label: {
|
|
| 146 |
- Image(systemName: "iphone") |
|
| 147 |
- } |
|
| 148 |
- .tint(isMulti ? .orange : .accentColor) |
|
| 149 |
- .accessibilityLabel("Select devices – \(selected.count) selected")
|
|
| 150 |
- } |
|
| 151 | 102 |
} |
| 152 | 103 |
|
| 153 | 104 |
// MARK: - Row |
@@ -35,42 +35,65 @@ struct RecordChangeEvolutionChart: View {
|
||
| 35 | 35 |
return counts.max() ?? 1 |
| 36 | 36 |
} |
| 37 | 37 |
|
| 38 |
- private var maxNegativeCount: Int {
|
|
| 39 |
- var max = 0 |
|
| 40 |
- for snapshot in contextSnapshots {
|
|
| 38 |
+ private var chartPoints: [ChartPoint] {
|
|
| 39 |
+ contextSnapshots.map { snapshot in
|
|
| 40 |
+ let typeCount = snapshot.typeCounts?.first { $0.typeIdentifier == typeIdentifier }
|
|
| 41 | 41 |
let previousSnapshot = snapshot.previousInTimeline(sortedSnapshots) |
| 42 |
- let diff = recordDiff(current: snapshot, previous: previousSnapshot) |
|
| 43 |
- max = Swift.max(max, diff.disappeared) |
|
| 42 |
+ return ChartPoint( |
|
| 43 |
+ snapshot: snapshot, |
|
| 44 |
+ count: max(typeCount?.count ?? 0, 0), |
|
| 45 |
+ diff: recordDiff(current: snapshot, previous: previousSnapshot) |
|
| 46 |
+ ) |
|
| 44 | 47 |
} |
| 45 |
- return max |
|
| 46 | 48 |
} |
| 47 | 49 |
|
| 48 |
- private func recordDiff(current: HealthSnapshot, previous: HealthSnapshot?) -> (added: Int, disappeared: Int) {
|
|
| 49 |
- guard let previous = previous else { return (0, 0) }
|
|
| 50 |
+ private var maxChangeCount: Int {
|
|
| 51 |
+ let maxChange = chartPoints |
|
| 52 |
+ .map { max($0.diff.added, $0.diff.disappeared) }
|
|
| 53 |
+ .max() ?? 0 |
|
| 54 |
+ return max(maxChange, 1) |
|
| 55 |
+ } |
|
| 56 |
+ |
|
| 57 |
+ private var hasAnyRecordChanges: Bool {
|
|
| 58 |
+ chartPoints.contains { $0.diff.added > 0 || $0.diff.disappeared > 0 }
|
|
| 59 |
+ } |
|
| 60 |
+ |
|
| 61 |
+ private func recordDiff(current: HealthSnapshot, previous: HealthSnapshot?) -> RecordChangeDiff {
|
|
| 62 |
+ guard let previous = previous else { return RecordChangeDiff() }
|
|
| 63 |
+ |
|
| 64 |
+ if let typeCount = current.typeCounts?.first(where: { $0.typeIdentifier == typeIdentifier }),
|
|
| 65 |
+ let cache = typeCount.detailCache, |
|
| 66 |
+ cache.matchesBaseline(previous.id) {
|
|
| 67 |
+ return RecordChangeDiff( |
|
| 68 |
+ added: cache.addedCount, |
|
| 69 |
+ disappeared: cache.disappearedCount, |
|
| 70 |
+ isExact: true |
|
| 71 |
+ ) |
|
| 72 |
+ } |
|
| 50 | 73 |
|
| 51 | 74 |
guard let delta = allDeltas.first(where: {
|
| 52 | 75 |
$0.fromSnapshotID == previous.id && |
| 53 | 76 |
$0.toSnapshotID == current.id |
| 54 | 77 |
}), |
| 55 | 78 |
let typeDelta = delta.typeDeltas?.first(where: { $0.typeIdentifier == typeIdentifier }) else {
|
| 56 |
- return (0, 0) |
|
| 79 |
+ return RecordChangeDiff() |
|
| 57 | 80 |
} |
| 58 | 81 |
|
| 59 | 82 |
switch typeDelta.transition {
|
| 60 | 83 |
case .unchanged: |
| 61 |
- return (0, 0) |
|
| 84 |
+ return RecordChangeDiff() |
|
| 62 | 85 |
case .changed: |
| 63 | 86 |
if typeDelta.countDelta > 0 {
|
| 64 |
- return (typeDelta.countDelta, 0) |
|
| 87 |
+ return RecordChangeDiff(added: typeDelta.countDelta) |
|
| 65 | 88 |
} |
| 66 | 89 |
if typeDelta.countDelta < 0 {
|
| 67 |
- return (0, abs(typeDelta.countDelta)) |
|
| 90 |
+ return RecordChangeDiff(disappeared: abs(typeDelta.countDelta)) |
|
| 68 | 91 |
} |
| 69 |
- return (0, 0) |
|
| 92 |
+ return RecordChangeDiff() |
|
| 70 | 93 |
case .appeared: |
| 71 |
- return (max(typeDelta.countDelta, 1), 0) |
|
| 94 |
+ return RecordChangeDiff(added: max(typeDelta.countDelta, 1)) |
|
| 72 | 95 |
case .disappeared: |
| 73 |
- return (0, max(abs(typeDelta.countDelta), 1)) |
|
| 96 |
+ return RecordChangeDiff(disappeared: max(abs(typeDelta.countDelta), 1)) |
|
| 74 | 97 |
} |
| 75 | 98 |
} |
| 76 | 99 |
|
@@ -113,6 +136,10 @@ struct RecordChangeEvolutionChart: View {
|
||
| 113 | 136 |
|
| 114 | 137 |
evolutionChart |
| 115 | 138 |
|
| 139 |
+ if hasAnyRecordChanges {
|
|
| 140 |
+ changeLegend |
|
| 141 |
+ } |
|
| 142 |
+ |
|
| 116 | 143 |
HStack(spacing: 12) {
|
| 117 | 144 |
VStack(alignment: .leading, spacing: 4) {
|
| 118 | 145 |
Text("Min")
|
@@ -159,30 +186,21 @@ struct RecordChangeEvolutionChart: View {
|
||
| 159 | 186 |
@ViewBuilder |
| 160 | 187 |
private var evolutionChart: some View {
|
| 161 | 188 |
HStack(alignment: .bottom, spacing: 4) {
|
| 162 |
- ForEach(Array(contextSnapshots.enumerated()), id: \.element.id) { idx, snapshot in
|
|
| 163 |
- chartBar(at: idx, snapshot: snapshot) |
|
| 189 |
+ ForEach(Array(chartPoints.enumerated()), id: \.element.snapshot.id) { idx, point in
|
|
| 190 |
+ chartBar(at: idx, point: point) |
|
| 164 | 191 |
} |
| 165 | 192 |
} |
| 166 |
- .frame(height: 100) |
|
| 193 |
+ .frame(height: 132) |
|
| 167 | 194 |
.padding(12) |
| 168 | 195 |
.background(Color(.systemBackground).opacity(0.3), in: RoundedRectangle(cornerRadius: 8)) |
| 169 | 196 |
} |
| 170 | 197 |
|
| 171 | 198 |
@ViewBuilder |
| 172 |
- private func chartBar(at index: Int, snapshot: HealthSnapshot) -> some View {
|
|
| 173 |
- let typeCount = snapshot.typeCounts?.first { $0.typeIdentifier == typeIdentifier }
|
|
| 174 |
- let count = typeCount?.count ?? 0 |
|
| 175 |
- let isCurrent = snapshot.id == currentSnapshotID |
|
| 176 |
- let previousSnapshot = snapshot.previousInTimeline(sortedSnapshots) |
|
| 177 |
- let diff = recordDiff(current: snapshot, previous: previousSnapshot) |
|
| 178 |
- let unchanged = count - diff.added |
|
| 179 |
- |
|
| 180 |
- let barTotalRange = CGFloat(diff.disappeared + count) |
|
| 181 |
- let redHeight = barTotalRange > 0 ? CGFloat(diff.disappeared) / barTotalRange * 80 : 0 |
|
| 182 |
- let blueHeight = barTotalRange > 0 ? CGFloat(unchanged) / barTotalRange * 80 : 0 |
|
| 183 |
- let greenHeight = barTotalRange > 0 ? CGFloat(diff.added) / barTotalRange * 80 : 0 |
|
| 184 |
- |
|
| 185 |
- let minVisibleHeight: CGFloat = 3 |
|
| 199 |
+ private func chartBar(at index: Int, point: ChartPoint) -> some View {
|
|
| 200 |
+ let isCurrent = point.snapshot.id == currentSnapshotID |
|
| 201 |
+ let totalBarHeight = CGFloat(point.count) / CGFloat(max(maxCount, 1)) * 54 |
|
| 202 |
+ let addedHeight = changeHeight(for: point.diff.added) |
|
| 203 |
+ let disappearedHeight = changeHeight(for: point.diff.disappeared) |
|
| 186 | 204 |
|
| 187 | 205 |
VStack(spacing: 0) {
|
| 188 | 206 |
if isCurrent {
|
@@ -192,29 +210,37 @@ struct RecordChangeEvolutionChart: View {
|
||
| 192 | 210 |
.padding(.bottom, 4) |
| 193 | 211 |
} |
| 194 | 212 |
|
| 195 |
- VStack(spacing: 0) {
|
|
| 196 |
- if diff.added > 0 {
|
|
| 197 |
- RoundedRectangle(cornerRadius: 1) |
|
| 198 |
- .fill(Color.healthyGreen) |
|
| 199 |
- .frame(height: max(minVisibleHeight, greenHeight)) |
|
| 200 |
- } |
|
| 201 |
- |
|
| 202 |
- if unchanged > 0 {
|
|
| 203 |
- RoundedRectangle(cornerRadius: 1) |
|
| 204 |
- .fill(Color.neutralGray.opacity(0.6)) |
|
| 205 |
- .frame(height: max(1, blueHeight)) |
|
| 206 |
- } |
|
| 207 |
- |
|
| 208 |
- if diff.disappeared > 0 {
|
|
| 209 |
- RoundedRectangle(cornerRadius: 1) |
|
| 210 |
- .fill(Color.criticalRed) |
|
| 211 |
- .frame(height: max(minVisibleHeight, redHeight)) |
|
| 213 |
+ VStack(spacing: 3) {
|
|
| 214 |
+ changeSegment( |
|
| 215 |
+ count: point.diff.added, |
|
| 216 |
+ height: addedHeight, |
|
| 217 |
+ color: Color.healthyGreen, |
|
| 218 |
+ pinsToBottom: true |
|
| 219 |
+ ) |
|
| 220 |
+ |
|
| 221 |
+ RoundedRectangle(cornerRadius: 2) |
|
| 222 |
+ .fill(Color.neutralGray.opacity(0.6)) |
|
| 223 |
+ .frame(height: max(4, totalBarHeight)) |
|
| 224 |
+ .frame(maxWidth: .infinity) |
|
| 225 |
+ |
|
| 226 |
+ changeSegment( |
|
| 227 |
+ count: point.diff.disappeared, |
|
| 228 |
+ height: disappearedHeight, |
|
| 229 |
+ color: Color.criticalRed, |
|
| 230 |
+ pinsToBottom: false |
|
| 231 |
+ ) |
|
| 232 |
+ } |
|
| 233 |
+ .frame(height: 96, alignment: .bottom) |
|
| 234 |
+ .overlay(alignment: .bottom) {
|
|
| 235 |
+ if point.diff.isFallback, point.diff.disappeared > 0 || point.diff.added > 0 {
|
|
| 236 |
+ Image(systemName: "tilde") |
|
| 237 |
+ .font(.system(size: 9, weight: .bold)) |
|
| 238 |
+ .foregroundStyle(.secondary) |
|
| 239 |
+ .offset(y: 12) |
|
| 212 | 240 |
} |
| 213 | 241 |
} |
| 214 |
- .frame(height: 80) |
|
| 215 |
- .frame(maxWidth: .infinity) |
|
| 216 | 242 |
|
| 217 |
- Text(snapshot.timestamp.formatted(.dateTime.month().day())) |
|
| 243 |
+ Text(point.snapshot.timestamp.formatted(.dateTime.month().day())) |
|
| 218 | 244 |
.font(.caption2) |
| 219 | 245 |
.foregroundStyle(.secondary) |
| 220 | 246 |
.lineLimit(1) |
@@ -223,6 +249,81 @@ struct RecordChangeEvolutionChart: View {
|
||
| 223 | 249 |
.frame(maxWidth: .infinity) |
| 224 | 250 |
} |
| 225 | 251 |
|
| 252 |
+ private func changeHeight(for count: Int) -> CGFloat {
|
|
| 253 |
+ guard count > 0 else { return 0 }
|
|
| 254 |
+ let scaled = CGFloat(count) / CGFloat(maxChangeCount) * 18 |
|
| 255 |
+ return max(4, scaled) |
|
| 256 |
+ } |
|
| 257 |
+ |
|
| 258 |
+ @ViewBuilder |
|
| 259 |
+ private func changeSegment( |
|
| 260 |
+ count: Int, |
|
| 261 |
+ height: CGFloat, |
|
| 262 |
+ color: Color, |
|
| 263 |
+ pinsToBottom: Bool |
|
| 264 |
+ ) -> some View {
|
|
| 265 |
+ VStack {
|
|
| 266 |
+ if pinsToBottom {
|
|
| 267 |
+ Spacer(minLength: 0) |
|
| 268 |
+ } |
|
| 269 |
+ |
|
| 270 |
+ if count > 0 {
|
|
| 271 |
+ RoundedRectangle(cornerRadius: 2) |
|
| 272 |
+ .fill(color) |
|
| 273 |
+ .frame(height: height) |
|
| 274 |
+ .frame(maxWidth: .infinity) |
|
| 275 |
+ .accessibilityLabel("\(count) records")
|
|
| 276 |
+ } else {
|
|
| 277 |
+ Color.clear.frame(height: 0) |
|
| 278 |
+ } |
|
| 279 |
+ |
|
| 280 |
+ if !pinsToBottom {
|
|
| 281 |
+ Spacer(minLength: 0) |
|
| 282 |
+ } |
|
| 283 |
+ } |
|
| 284 |
+ .frame(height: 18) |
|
| 285 |
+ } |
|
| 286 |
+ |
|
| 287 |
+ private var changeLegend: some View {
|
|
| 288 |
+ HStack(spacing: 10) {
|
|
| 289 |
+ legendItem(color: Color.healthyGreen, label: "Added") |
|
| 290 |
+ legendItem(color: Color.criticalRed, label: "Disappeared") |
|
| 291 |
+ Spacer(minLength: 0) |
|
| 292 |
+ } |
|
| 293 |
+ .font(.caption2) |
|
| 294 |
+ .foregroundStyle(.secondary) |
|
| 295 |
+ } |
|
| 296 |
+ |
|
| 297 |
+ private func legendItem(color: Color, label: String) -> some View {
|
|
| 298 |
+ HStack(spacing: 4) {
|
|
| 299 |
+ RoundedRectangle(cornerRadius: 2) |
|
| 300 |
+ .fill(color) |
|
| 301 |
+ .frame(width: 12, height: 4) |
|
| 302 |
+ Text(label) |
|
| 303 |
+ } |
|
| 304 |
+ } |
|
| 305 |
+} |
|
| 306 |
+ |
|
| 307 |
+private struct ChartPoint {
|
|
| 308 |
+ let snapshot: HealthSnapshot |
|
| 309 |
+ let count: Int |
|
| 310 |
+ let diff: RecordChangeDiff |
|
| 311 |
+} |
|
| 312 |
+ |
|
| 313 |
+private struct RecordChangeDiff {
|
|
| 314 |
+ let added: Int |
|
| 315 |
+ let disappeared: Int |
|
| 316 |
+ let isExact: Bool |
|
| 317 |
+ |
|
| 318 |
+ init(added: Int = 0, disappeared: Int = 0, isExact: Bool = false) {
|
|
| 319 |
+ self.added = added |
|
| 320 |
+ self.disappeared = disappeared |
|
| 321 |
+ self.isExact = isExact |
|
| 322 |
+ } |
|
| 323 |
+ |
|
| 324 |
+ var isFallback: Bool {
|
|
| 325 |
+ !isExact |
|
| 326 |
+ } |
|
| 226 | 327 |
} |
| 227 | 328 |
|
| 228 | 329 |
#Preview {
|
@@ -0,0 +1,214 @@ |
||
| 1 |
+import SwiftUI |
|
| 2 |
+import UIKit |
|
| 3 |
+ |
|
| 4 |
+struct DataTypeRecordListView: View {
|
|
| 5 |
+ let title: String |
|
| 6 |
+ let displayName: String |
|
| 7 |
+ let totalCount: Int |
|
| 8 |
+ let tint: Color |
|
| 9 |
+ |
|
| 10 |
+ @State private var viewModel: DataTypeRecordListViewModel? |
|
| 11 |
+ |
|
| 12 |
+ private let recordListMode: RecordListMode |
|
| 13 |
+ private let previewRecords: [HealthRecordValue] |
|
| 14 |
+ |
|
| 15 |
+ init( |
|
| 16 |
+ title: String, |
|
| 17 |
+ displayName: String, |
|
| 18 |
+ totalCount: Int, |
|
| 19 |
+ mode: RecordListMode, |
|
| 20 |
+ previewRecords: [HealthRecordValue] = [], |
|
| 21 |
+ tint: Color |
|
| 22 |
+ ) {
|
|
| 23 |
+ self.title = title |
|
| 24 |
+ self.displayName = displayName |
|
| 25 |
+ self.totalCount = totalCount |
|
| 26 |
+ self.tint = tint |
|
| 27 |
+ self.recordListMode = mode |
|
| 28 |
+ self.previewRecords = previewRecords |
|
| 29 |
+ } |
|
| 30 |
+ |
|
| 31 |
+ |
|
| 32 |
+ private var isTruncated: Bool {
|
|
| 33 |
+ guard let viewModel else { return totalCount > 0 }
|
|
| 34 |
+ return viewModel.displayRecords.count < totalCount |
|
| 35 |
+ } |
|
| 36 |
+ |
|
| 37 |
+ var body: some View {
|
|
| 38 |
+ Group {
|
|
| 39 |
+ if let viewModel = viewModel {
|
|
| 40 |
+ List {
|
|
| 41 |
+ Section {
|
|
| 42 |
+ DataTypeDetailRow(label: "Data Type") {
|
|
| 43 |
+ Text(displayName) |
|
| 44 |
+ .foregroundStyle(.secondary) |
|
| 45 |
+ .lineLimit(1) |
|
| 46 |
+ } |
|
| 47 |
+ DataTypeDetailRow(label: "Records") {
|
|
| 48 |
+ Text("\(totalCount)")
|
|
| 49 |
+ .foregroundStyle(tint) |
|
| 50 |
+ .monospacedDigit() |
|
| 51 |
+ } |
|
| 52 |
+ if isTruncated {
|
|
| 53 |
+ HStack(spacing: 4) {
|
|
| 54 |
+ Image(systemName: "exclamationmark.circle.fill") |
|
| 55 |
+ .font(.caption) |
|
| 56 |
+ .foregroundStyle(Color.warningAmber) |
|
| 57 |
+ Text("Showing \(viewModel.displayRecords.count) of \(totalCount)")
|
|
| 58 |
+ .font(.caption) |
|
| 59 |
+ .foregroundStyle(.secondary) |
|
| 60 |
+ } |
|
| 61 |
+ .padding(.vertical, 4) |
|
| 62 |
+ } |
|
| 63 |
+ Text("HealthProbe stores timestamps, fingerprints, and metadata. Export includes complete record details.")
|
|
| 64 |
+ .font(.caption) |
|
| 65 |
+ .foregroundStyle(.secondary) |
|
| 66 |
+ } |
|
| 67 |
+ |
|
| 68 |
+ if viewModel.displayRecords.isEmpty {
|
|
| 69 |
+ Section(title) {
|
|
| 70 |
+ Text("No records.")
|
|
| 71 |
+ .foregroundStyle(.secondary) |
|
| 72 |
+ } |
|
| 73 |
+ } else {
|
|
| 74 |
+ Section(title) {
|
|
| 75 |
+ ForEach(viewModel.displayRecords) { record in
|
|
| 76 |
+ HealthRecordValueRow(record: record, tint: tint) |
|
| 77 |
+ .onAppear {
|
|
| 78 |
+ if record.id == viewModel.displayRecords.last?.id {
|
|
| 79 |
+ Task { await viewModel.loadNextPage() }
|
|
| 80 |
+ } |
|
| 81 |
+ } |
|
| 82 |
+ } |
|
| 83 |
+ |
|
| 84 |
+ if viewModel.isLoadingMore {
|
|
| 85 |
+ HStack(spacing: 8) {
|
|
| 86 |
+ ProgressView() |
|
| 87 |
+ .scaleEffect(0.8) |
|
| 88 |
+ Text("Loading more...")
|
|
| 89 |
+ .font(.caption) |
|
| 90 |
+ .foregroundStyle(.secondary) |
|
| 91 |
+ } |
|
| 92 |
+ .frame(maxWidth: .infinity, alignment: .center) |
|
| 93 |
+ .padding(.vertical, 8) |
|
| 94 |
+ } |
|
| 95 |
+ } |
|
| 96 |
+ } |
|
| 97 |
+ } |
|
| 98 |
+ .navigationTitle(title) |
|
| 99 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 100 |
+ .toolbar {
|
|
| 101 |
+ ToolbarItem(placement: .topBarTrailing) {
|
|
| 102 |
+ Menu {
|
|
| 103 |
+ Button(action: { Task { await viewModel.exportAllRecords() } }) {
|
|
| 104 |
+ Label("Export as JSON", systemImage: "square.and.arrow.up")
|
|
| 105 |
+ } |
|
| 106 |
+ } label: {
|
|
| 107 |
+ Image(systemName: "ellipsis.circle") |
|
| 108 |
+ .foregroundStyle(.primary) |
|
| 109 |
+ } |
|
| 110 |
+ } |
|
| 111 |
+ } |
|
| 112 |
+ .onChange(of: viewModel.exportState) { _, newState in
|
|
| 113 |
+ if case .ready(let url) = newState {
|
|
| 114 |
+ shareExportURL(url) |
|
| 115 |
+ } |
|
| 116 |
+ } |
|
| 117 |
+ } else {
|
|
| 118 |
+ ProgressView() |
|
| 119 |
+ .navigationTitle(title) |
|
| 120 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 121 |
+ } |
|
| 122 |
+ } |
|
| 123 |
+ .task {
|
|
| 124 |
+ guard viewModel == nil else { return }
|
|
| 125 |
+ let vm = DataTypeRecordListViewModel( |
|
| 126 |
+ typeIdentifier: recordListMode.typeIdentifier, |
|
| 127 |
+ displayName: displayName, |
|
| 128 |
+ totalCount: totalCount, |
|
| 129 |
+ mode: recordListMode, |
|
| 130 |
+ previewRecords: previewRecords, |
|
| 131 |
+ archiveStore: SQLiteHealthArchiveStore.shared |
|
| 132 |
+ ) |
|
| 133 |
+ viewModel = vm |
|
| 134 |
+ await vm.loadFirstPage() |
|
| 135 |
+ } |
|
| 136 |
+ } |
|
| 137 |
+ |
|
| 138 |
+ private func shareExportURL(_ url: URL) {
|
|
| 139 |
+ let activity = UIActivityViewController(activityItems: [url], applicationActivities: nil) |
|
| 140 |
+ if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
|
|
| 141 |
+ scene.windows.first?.rootViewController?.present(activity, animated: true) |
|
| 142 |
+ } |
|
| 143 |
+ } |
|
| 144 |
+} |
|
| 145 |
+ |
|
| 146 |
+private struct HealthRecordValueRow: View {
|
|
| 147 |
+ let record: HealthRecordValue |
|
| 148 |
+ let tint: Color |
|
| 149 |
+ |
|
| 150 |
+ var body: some View {
|
|
| 151 |
+ let recordDate = record.startDate.formatted(.dateTime.year().month().day()) |
|
| 152 |
+ let startTime = record.startDate.formatted(.dateTime.hour().minute()) |
|
| 153 |
+ let endTime = record.endDate.formatted(.dateTime.hour().minute()) |
|
| 154 |
+ let timeRange = (startTime == endTime) ? startTime : "\(startTime)–\(endTime)" |
|
| 155 |
+ |
|
| 156 |
+ VStack(alignment: .leading, spacing: 6) {
|
|
| 157 |
+ HStack(spacing: 12) {
|
|
| 158 |
+ VStack(alignment: .leading, spacing: 2) {
|
|
| 159 |
+ Text("\(recordDate) • \(timeRange)")
|
|
| 160 |
+ .font(.subheadline.weight(.semibold)) |
|
| 161 |
+ .foregroundStyle(.primary) |
|
| 162 |
+ |
|
| 163 |
+ if let displayValue = record.displayValue, !displayValue.isEmpty {
|
|
| 164 |
+ Text(displayValue) |
|
| 165 |
+ .font(.caption) |
|
| 166 |
+ .foregroundStyle(.secondary) |
|
| 167 |
+ } else {
|
|
| 168 |
+ Text("No value stored")
|
|
| 169 |
+ .font(.caption) |
|
| 170 |
+ .foregroundStyle(.secondary) |
|
| 171 |
+ } |
|
| 172 |
+ } |
|
| 173 |
+ |
|
| 174 |
+ Spacer() |
|
| 175 |
+ |
|
| 176 |
+ Image(systemName: "xmark.circle.fill") |
|
| 177 |
+ .font(.caption) |
|
| 178 |
+ .foregroundStyle(Color.criticalRed) |
|
| 179 |
+ } |
|
| 180 |
+ |
|
| 181 |
+ Text(record.recordFingerprint.suffix(8)) |
|
| 182 |
+ .font(.caption.monospaced()) |
|
| 183 |
+ .foregroundStyle(.tertiary) |
|
| 184 |
+ .lineLimit(1) |
|
| 185 |
+ } |
|
| 186 |
+ .padding(.vertical, 2) |
|
| 187 |
+ .accessibilityElement(children: .combine) |
|
| 188 |
+ } |
|
| 189 |
+} |
|
| 190 |
+ |
|
| 191 |
+private struct DataTypeDetailRow<Content: View>: View {
|
|
| 192 |
+ let label: String |
|
| 193 |
+ @ViewBuilder let content: () -> Content |
|
| 194 |
+ |
|
| 195 |
+ var body: some View {
|
|
| 196 |
+ HStack {
|
|
| 197 |
+ Text(label) |
|
| 198 |
+ Spacer() |
|
| 199 |
+ content() |
|
| 200 |
+ } |
|
| 201 |
+ } |
|
| 202 |
+} |
|
| 203 |
+ |
|
| 204 |
+#Preview {
|
|
| 205 |
+ NavigationStack {
|
|
| 206 |
+ DataTypeRecordListView( |
|
| 207 |
+ title: "Disappeared Records", |
|
| 208 |
+ displayName: "Step Count", |
|
| 209 |
+ totalCount: 1500, |
|
| 210 |
+ mode: .disappeared(typeIdentifier: "HKQuantityTypeIdentifierStepCount"), |
|
| 211 |
+ tint: .criticalRed |
|
| 212 |
+ ) |
|
| 213 |
+ } |
|
| 214 |
+} |
|
@@ -257,20 +257,24 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 257 | 257 |
} |
| 258 | 258 |
) |
| 259 | 259 |
.navigationDestination(isPresented: $showAddedRecords) {
|
| 260 |
- DataTypeRecordListView( |
|
| 261 |
- title: "Added Records", |
|
| 262 |
- displayName: displayName, |
|
| 263 |
- records: diff.addedRecords, |
|
| 264 |
- totalCount: diff.addedCount, |
|
| 265 |
- tint: Color.healthyGreen |
|
| 266 |
- ) |
|
| 260 |
+ if let previous = previousSnapshot {
|
|
| 261 |
+ DataTypeRecordListView( |
|
| 262 |
+ title: "Added Records", |
|
| 263 |
+ displayName: displayName, |
|
| 264 |
+ totalCount: diff.addedCount, |
|
| 265 |
+ mode: .added(typeIdentifier: typeIdentifier, afterDate: previous.timestamp, beforeDate: currentSnapshot.timestamp), |
|
| 266 |
+ previewRecords: diff.addedRecords, |
|
| 267 |
+ tint: Color.healthyGreen |
|
| 268 |
+ ) |
|
| 269 |
+ } |
|
| 267 | 270 |
} |
| 268 | 271 |
.navigationDestination(isPresented: $showDisappearedRecords) {
|
| 269 | 272 |
DataTypeRecordListView( |
| 270 | 273 |
title: "Disappeared Records", |
| 271 | 274 |
displayName: displayName, |
| 272 |
- records: diff.disappearedRecords, |
|
| 273 | 275 |
totalCount: diff.disappearedCount, |
| 276 |
+ mode: .disappeared(typeIdentifier: typeIdentifier), |
|
| 277 |
+ previewRecords: diff.disappearedRecords, |
|
| 274 | 278 |
tint: Color.criticalRed |
| 275 | 279 |
) |
| 276 | 280 |
} |
@@ -520,57 +524,6 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 520 | 524 |
} |
| 521 | 525 |
} |
| 522 | 526 |
|
| 523 |
-private struct DataTypeRecordListView: View {
|
|
| 524 |
- let title: String |
|
| 525 |
- let displayName: String |
|
| 526 |
- let records: [HealthRecordValue] |
|
| 527 |
- let totalCount: Int |
|
| 528 |
- let tint: Color |
|
| 529 |
- private var showsValueDisclaimer: Bool {
|
|
| 530 |
- records.contains { ($0.displayValue?.isEmpty ?? true) }
|
|
| 531 |
- } |
|
| 532 |
- |
|
| 533 |
- var body: some View {
|
|
| 534 |
- List {
|
|
| 535 |
- Section {
|
|
| 536 |
- DataTypeDetailRow(label: "Data Type") {
|
|
| 537 |
- Text(displayName) |
|
| 538 |
- .foregroundStyle(.secondary) |
|
| 539 |
- .lineLimit(1) |
|
| 540 |
- } |
|
| 541 |
- DataTypeDetailRow(label: "Records") {
|
|
| 542 |
- Text("\(totalCount)")
|
|
| 543 |
- .foregroundStyle(tint) |
|
| 544 |
- .monospacedDigit() |
|
| 545 |
- } |
|
| 546 |
- if totalCount > records.count {
|
|
| 547 |
- Text("Showing newest \(records.count) records.")
|
|
| 548 |
- .font(.caption) |
|
| 549 |
- .foregroundStyle(.secondary) |
|
| 550 |
- } |
|
| 551 |
- if showsValueDisclaimer {
|
|
| 552 |
- Text("For privacy, HealthProbe stores only timestamps and a record fingerprint here (not the raw HealthKit value).")
|
|
| 553 |
- .font(.caption) |
|
| 554 |
- .foregroundStyle(.secondary) |
|
| 555 |
- } |
|
| 556 |
- } |
|
| 557 |
- |
|
| 558 |
- Section(title) {
|
|
| 559 |
- if records.isEmpty {
|
|
| 560 |
- Text("No records.")
|
|
| 561 |
- .foregroundStyle(.secondary) |
|
| 562 |
- } else {
|
|
| 563 |
- ForEach(records) { record in
|
|
| 564 |
- DataTypeRecordRow(record: record, tint: tint) |
|
| 565 |
- } |
|
| 566 |
- } |
|
| 567 |
- } |
|
| 568 |
- } |
|
| 569 |
- .navigationTitle(title) |
|
| 570 |
- .navigationBarTitleDisplayMode(.inline) |
|
| 571 |
- } |
|
| 572 |
-} |
|
| 573 |
- |
|
| 574 | 527 |
private enum RecordDiffState: Equatable {
|
| 575 | 528 |
case idle |
| 576 | 529 |
case loading |
@@ -617,58 +570,6 @@ private struct DataTypeRecordDiff: Equatable, Sendable {
|
||
| 617 | 570 |
} |
| 618 | 571 |
} |
| 619 | 572 |
|
| 620 |
-private struct DataTypeRecordRow: View {
|
|
| 621 |
- let record: HealthRecordValue |
|
| 622 |
- let tint: Color |
|
| 623 |
- |
|
| 624 |
- var body: some View {
|
|
| 625 |
- let recordDate = record.startDate.formatted(.dateTime.year().month().day()) |
|
| 626 |
- let startTime = record.startDate.formatted(.dateTime.hour().minute()) |
|
| 627 |
- let endTime = record.endDate.formatted(.dateTime.hour().minute()) |
|
| 628 |
- let timeRange = (startTime == endTime) ? startTime : "\(startTime)–\(endTime)" |
|
| 629 |
- |
|
| 630 |
- HStack(spacing: 12) {
|
|
| 631 |
- VStack(alignment: .leading, spacing: 2) {
|
|
| 632 |
- if let displayValue = record.displayValue, !displayValue.isEmpty {
|
|
| 633 |
- Text(displayValue) |
|
| 634 |
- .font(.subheadline) |
|
| 635 |
- .foregroundStyle(.primary) |
|
| 636 |
- } else {
|
|
| 637 |
- Text("No value stored")
|
|
| 638 |
- .font(.subheadline) |
|
| 639 |
- .foregroundStyle(.secondary) |
|
| 640 |
- } |
|
| 641 |
- Text("\(recordDate) • \(timeRange)")
|
|
| 642 |
- .font(.caption) |
|
| 643 |
- .foregroundStyle(.secondary) |
|
| 644 |
- } |
|
| 645 |
- |
|
| 646 |
- Spacer() |
|
| 647 |
- if startTime != endTime {
|
|
| 648 |
- Text(endTime) |
|
| 649 |
- .font(.caption) |
|
| 650 |
- .foregroundStyle(tint) |
|
| 651 |
- .accessibilityLabel("End \(endTime)")
|
|
| 652 |
- } |
|
| 653 |
- } |
|
| 654 |
- .padding(.vertical, 2) |
|
| 655 |
- .accessibilityElement(children: .combine) |
|
| 656 |
- } |
|
| 657 |
-} |
|
| 658 |
- |
|
| 659 |
-private struct DataTypeDetailRow<Content: View>: View {
|
|
| 660 |
- let label: String |
|
| 661 |
- @ViewBuilder let content: () -> Content |
|
| 662 |
- |
|
| 663 |
- var body: some View {
|
|
| 664 |
- HStack {
|
|
| 665 |
- Text(label) |
|
| 666 |
- Spacer() |
|
| 667 |
- content() |
|
| 668 |
- } |
|
| 669 |
- } |
|
| 670 |
-} |
|
| 671 |
- |
|
| 672 | 573 |
#Preview {
|
| 673 | 574 |
NavigationStack {
|
| 674 | 575 |
DataTypeSnapshotDetailView( |
@@ -1,10 +1,8 @@ |
||
| 1 | 1 |
import SwiftUI |
| 2 | 2 |
import SwiftData |
| 3 |
-import UIKit |
|
| 4 | 3 |
|
| 5 | 4 |
struct SnapshotsView: View {
|
| 6 | 5 |
@Environment(\.modelContext) private var modelContext |
| 7 |
- @Environment(AppSettings.self) private var appSettings |
|
| 8 | 6 |
@Query(sort: \HealthSnapshot.timestamp, order: .reverse) private var allSnapshots: [HealthSnapshot] |
| 9 | 7 |
@Query private var allDeltas: [SnapshotDelta] |
| 10 | 8 |
@Query private var deviceProfiles: [DeviceProfile] |
@@ -17,9 +15,8 @@ struct SnapshotsView: View {
|
||
| 17 | 15 |
} |
| 18 | 16 |
|
| 19 | 17 |
private var displayedSnapshots: [HealthSnapshot] {
|
| 20 |
- let selected = appSettings.selectedDeviceIDs |
|
| 21 |
- guard !selected.isEmpty else { return [] }
|
|
| 22 |
- return allSnapshots.filter { selected.contains($0.deviceID) }
|
|
| 18 |
+ guard let deviceID = localDeviceID else { return [] }
|
|
| 19 |
+ return allSnapshots.filter { $0.deviceID == deviceID }
|
|
| 23 | 20 |
} |
| 24 | 21 |
|
| 25 | 22 |
private var snapshotItems: [SnapshotListItem] {
|
@@ -38,23 +35,13 @@ struct SnapshotsView: View {
|
||
| 38 | 35 |
} |
| 39 | 36 |
} |
| 40 | 37 |
|
| 41 |
- private var knownDevices: [DeviceEntry] {
|
|
| 38 |
+ private var localDeviceID: String? {
|
|
| 42 | 39 |
let currentID = AppSettings.currentDeviceID |
| 43 |
- var ids = Set(allSnapshots.map { $0.deviceID })
|
|
| 44 |
- ids.insert(currentID) |
|
| 45 |
- return ids.map { id in
|
|
| 46 |
- let profile = profileMap[id] |
|
| 47 |
- let name: String |
|
| 48 |
- if let n = profile?.name, !n.isEmpty { name = n }
|
|
| 49 |
- else if id.isEmpty { name = "Unidentified" }
|
|
| 50 |
- else { name = "Unknown Device" }
|
|
| 51 |
- let color = DeviceColor(rawValue: profile?.colorTag ?? "")?.color ?? .neutralGray |
|
| 52 |
- return DeviceEntry(id: id, displayName: name, color: color, isCurrent: id == currentID) |
|
| 53 |
- } |
|
| 54 |
- .sorted {
|
|
| 55 |
- if $0.isCurrent != $1.isCurrent { return $0.isCurrent }
|
|
| 56 |
- return $0.displayName < $1.displayName |
|
| 40 |
+ if allSnapshots.contains(where: { $0.deviceID == currentID }) {
|
|
| 41 |
+ return currentID |
|
| 57 | 42 |
} |
| 43 |
+ |
|
| 44 |
+ return allSnapshots.first?.deviceID |
|
| 58 | 45 |
} |
| 59 | 46 |
|
| 60 | 47 |
var body: some View {
|
@@ -63,10 +50,8 @@ struct SnapshotsView: View {
|
||
| 63 | 50 |
if displayedSnapshots.isEmpty {
|
| 64 | 51 |
EmptyStateView( |
| 65 | 52 |
icon: "clock.arrow.circlepath", |
| 66 |
- title: appSettings.selectedDeviceIDs.isEmpty ? "No Devices Selected" : "No Snapshots", |
|
| 67 |
- message: appSettings.selectedDeviceIDs.isEmpty |
|
| 68 |
- ? "Select at least one device using the filter above." |
|
| 69 |
- : "Use the Dashboard to create your first snapshot." |
|
| 53 |
+ title: "No Snapshots", |
|
| 54 |
+ message: "Use the Dashboard to create your first local snapshot." |
|
| 70 | 55 |
) |
| 71 | 56 |
} else {
|
| 72 | 57 |
snapshotList |
@@ -77,13 +62,6 @@ struct SnapshotsView: View {
|
||
| 77 | 62 |
.task(id: allDeltas.count) {
|
| 78 | 63 |
repairDeltaListSummariesIfNeeded() |
| 79 | 64 |
} |
| 80 |
- .onChange(of: appSettings.selectedDeviceIDs) {
|
|
| 81 |
- if let baseline = viewModel.selectedBaseline, |
|
| 82 |
- !displayedSnapshots.contains(where: { $0.id == baseline.id }) {
|
|
| 83 |
- viewModel.selectedBaseline = nil |
|
| 84 |
- viewModel.comparisonMode = .previous |
|
| 85 |
- } |
|
| 86 |
- } |
|
| 87 | 65 |
} |
| 88 | 66 |
} |
| 89 | 67 |
|
@@ -137,9 +115,6 @@ struct SnapshotsView: View {
|
||
| 137 | 115 |
|
| 138 | 116 |
@ToolbarContentBuilder |
| 139 | 117 |
private var toolbarContent: some ToolbarContent {
|
| 140 |
- ToolbarItem(placement: .topBarLeading) {
|
|
| 141 |
- devicePickerMenu |
|
| 142 |
- } |
|
| 143 | 118 |
ToolbarItem(placement: .navigationBarTrailing) {
|
| 144 | 119 |
Menu {
|
| 145 | 120 |
Picker("Compare Against", selection: $viewModel.comparisonMode) {
|
@@ -158,32 +133,6 @@ struct SnapshotsView: View {
|
||
| 158 | 133 |
} |
| 159 | 134 |
} |
| 160 | 135 |
|
| 161 |
- private var devicePickerMenu: some View {
|
|
| 162 |
- let selected = appSettings.selectedDeviceIDs |
|
| 163 |
- let isMulti = selected.count > 1 |
|
| 164 |
- return Menu {
|
|
| 165 |
- ForEach(knownDevices) { entry in
|
|
| 166 |
- Button {
|
|
| 167 |
- appSettings.toggleDevice(entry.id) |
|
| 168 |
- } label: {
|
|
| 169 |
- Label {
|
|
| 170 |
- Text(entry.isCurrent |
|
| 171 |
- ? "\(entry.displayName) (This Device)" |
|
| 172 |
- : entry.displayName) |
|
| 173 |
- } icon: {
|
|
| 174 |
- Image(systemName: selected.contains(entry.id) |
|
| 175 |
- ? "checkmark.circle.fill" : "circle.fill") |
|
| 176 |
- .foregroundStyle(entry.color) |
|
| 177 |
- } |
|
| 178 |
- } |
|
| 179 |
- } |
|
| 180 |
- } label: {
|
|
| 181 |
- Image(systemName: "iphone") |
|
| 182 |
- } |
|
| 183 |
- .tint(isMulti ? .orange : .accentColor) |
|
| 184 |
- .accessibilityLabel("Select devices – \(selected.count) selected")
|
|
| 185 |
- } |
|
| 186 |
- |
|
| 187 | 136 |
private func repairDeltaListSummariesIfNeeded() {
|
| 188 | 137 |
do {
|
| 189 | 138 |
_ = try DeltaService.rebuildMissingListSummaries(context: modelContext, maxCount: 64) |