@@ -23,6 +23,18 @@ struct MonitoredType: Identifiable, @unchecked Sendable {
|
||
| 23 | 23 |
let objectType: HKObjectType? // nil = unsupported on this OS/device |
| 24 | 24 |
} |
| 25 | 25 |
|
| 26 |
+extension Array where Element == MonitoredType {
|
|
| 27 |
+ func sortedByFetchDisplayNameDescending() -> [MonitoredType] {
|
|
| 28 |
+ sorted {
|
|
| 29 |
+ let displayNameOrder = $0.displayName.localizedCaseInsensitiveCompare($1.displayName) |
|
| 30 |
+ if displayNameOrder == .orderedSame {
|
|
| 31 |
+ return $0.id > $1.id |
|
| 32 |
+ } |
|
| 33 |
+ return displayNameOrder == .orderedDescending |
|
| 34 |
+ } |
|
| 35 |
+ } |
|
| 36 |
+} |
|
| 37 |
+ |
|
| 26 | 38 |
final class HealthKitService {
|
| 27 | 39 |
static let shared = HealthKitService() |
| 28 | 40 |
let store = HKHealthStore() |
@@ -66,7 +78,9 @@ final class HealthKitService {
|
||
| 66 | 78 |
timeoutMultiplier: Double = 1, |
| 67 | 79 |
progress: SnapshotFetchProgress? = nil |
| 68 | 80 |
) async throws -> HealthSnapshot {
|
| 69 |
- let active = Self.allTypes.filter { selectedTypeIDs.contains($0.id) }
|
|
| 81 |
+ let active = Self.allTypes |
|
| 82 |
+ .filter { selectedTypeIDs.contains($0.id) }
|
|
| 83 |
+ .sortedByFetchDisplayNameDescending() |
|
| 70 | 84 |
let deviceResolution = KeychainService.resolveDeviceID(swiftDataStoreIsEmpty: isStoreEmpty(context: context)) |
| 71 | 85 |
|
| 72 | 86 |
let snapshot = HealthSnapshot( |
@@ -79,8 +93,6 @@ final class HealthKitService {
|
||
| 79 | 93 |
snapshot.triggerReason = triggerReason |
| 80 | 94 |
snapshot.retryOfSnapshotID = retryOfSnapshotID |
| 81 | 95 |
snapshot.yearlyCountTimezoneIdentifier = TimeZone.current.identifier |
| 82 |
- context.insert(snapshot) |
|
| 83 |
- |
|
| 84 | 96 |
// Fetch raw HealthKit data off the main actor, then assemble SwiftData models here |
| 85 | 97 |
// on the main actor to prevent data races on managed object context. |
| 86 | 98 |
let fetchResults = await fetchAllTypeCounts( |
@@ -92,13 +104,10 @@ final class HealthKitService {
|
||
| 92 | 104 |
) |
| 93 | 105 |
let typeCounts: [TypeCount] = fetchResults.map { result in
|
| 94 | 106 |
let tc = result.makeTypeCount() |
| 95 |
- context.insert(tc) |
|
| 96 |
- for yearlyCount in tc.yearlyCounts ?? [] {
|
|
| 97 |
- context.insert(yearlyCount) |
|
| 98 |
- } |
|
| 99 | 107 |
tc.snapshot = snapshot |
| 100 | 108 |
return tc |
| 101 | 109 |
} |
| 110 |
+ snapshot.typeCounts = typeCounts |
|
| 102 | 111 |
|
| 103 | 112 |
// Invariant assertions before save — debug asserts + release silent correction |
| 104 | 113 |
for tc in typeCounts {
|
@@ -119,6 +128,66 @@ final class HealthKitService {
|
||
| 119 | 128 |
|
| 120 | 129 |
snapshot.snapshotQuality = deriveSnapshotQuality(from: typeCounts) |
| 121 | 130 |
|
| 131 |
+ configureSnapshotMetadata( |
|
| 132 |
+ snapshot, |
|
| 133 |
+ typeCounts: typeCounts, |
|
| 134 |
+ intendedTypeIDs: active.map { $0.id },
|
|
| 135 |
+ context: context |
|
| 136 |
+ ) |
|
| 137 |
+ |
|
| 138 |
+ if snapshot.snapshotQuality == .complete {
|
|
| 139 |
+ try await persistSnapshot(snapshot, typeCounts: typeCounts, context: context) |
|
| 140 |
+ } |
|
| 141 |
+ |
|
| 142 |
+ return snapshot |
|
| 143 |
+ } |
|
| 144 |
+ |
|
| 145 |
+ @MainActor |
|
| 146 |
+ func savePartialSnapshot(_ snapshot: HealthSnapshot, in context: ModelContext) async throws -> HealthSnapshot {
|
|
| 147 |
+ let typeCounts = snapshot.typeCounts ?? [] |
|
| 148 |
+ guard snapshot.snapshotQuality != .complete else {
|
|
| 149 |
+ return snapshot |
|
| 150 |
+ } |
|
| 151 |
+ |
|
| 152 |
+ configureSnapshotMetadata( |
|
| 153 |
+ snapshot, |
|
| 154 |
+ typeCounts: typeCounts, |
|
| 155 |
+ intendedTypeIDs: typeCounts.map(\.typeIdentifier), |
|
| 156 |
+ context: context |
|
| 157 |
+ ) |
|
| 158 |
+ try await persistSnapshot(snapshot, typeCounts: typeCounts, context: context) |
|
| 159 |
+ return snapshot |
|
| 160 |
+ } |
|
| 161 |
+ |
|
| 162 |
+ // MARK: - Snapshot persistence |
|
| 163 |
+ |
|
| 164 |
+ private func persistSnapshot( |
|
| 165 |
+ _ snapshot: HealthSnapshot, |
|
| 166 |
+ typeCounts: [TypeCount], |
|
| 167 |
+ context: ModelContext |
|
| 168 |
+ ) async throws {
|
|
| 169 |
+ context.insert(snapshot) |
|
| 170 |
+ for typeCount in typeCounts {
|
|
| 171 |
+ context.insert(typeCount) |
|
| 172 |
+ for yearlyCount in typeCount.yearlyCounts ?? [] {
|
|
| 173 |
+ context.insert(yearlyCount) |
|
| 174 |
+ } |
|
| 175 |
+ typeCount.snapshot = snapshot |
|
| 176 |
+ } |
|
| 177 |
+ snapshot.typeCounts = typeCounts |
|
| 178 |
+ |
|
| 179 |
+ try context.save() |
|
| 180 |
+ |
|
| 181 |
+ // Post-save pipeline: delta computation + anomaly detection |
|
| 182 |
+ try await runPostSavePipeline(snapshot: snapshot, typeCounts: typeCounts, context: context) |
|
| 183 |
+ } |
|
| 184 |
+ |
|
| 185 |
+ private func configureSnapshotMetadata( |
|
| 186 |
+ _ snapshot: HealthSnapshot, |
|
| 187 |
+ typeCounts: [TypeCount], |
|
| 188 |
+ intendedTypeIDs: [String], |
|
| 189 |
+ context: ModelContext |
|
| 190 |
+ ) {
|
|
| 122 | 191 |
// Chain metadata — set BEFORE context.save() |
| 123 | 192 |
// localSequenceNumber is used here solely to find the latest local candidate during |
| 124 | 193 |
// snapshot creation. Once previousSnapshotID is set, all chain reconstruction must use |
@@ -129,8 +198,7 @@ final class HealthKitService {
|
||
| 129 | 198 |
snapshot.localSequenceNumber = previous.localSequenceNumber + 1 |
| 130 | 199 |
snapshot.isChainStart = false |
| 131 | 200 |
|
| 132 |
- let intentedTypeIDs = active.map { $0.id }
|
|
| 133 |
- snapshot.monitoredTypeSetHash = HashService.typeSetHash(typeIDs: intentedTypeIDs) |
|
| 201 |
+ snapshot.monitoredTypeSetHash = HashService.typeSetHash(typeIDs: intendedTypeIDs) |
|
| 134 | 202 |
if snapshot.monitoredTypeSetHash != previous.monitoredTypeSetHash {
|
| 135 | 203 |
snapshot.monitoredRegistryVersion = previous.monitoredRegistryVersion + 1 |
| 136 | 204 |
} else {
|
@@ -146,7 +214,7 @@ final class HealthKitService {
|
||
| 146 | 214 |
snapshot.previousSnapshotID = nil |
| 147 | 215 |
snapshot.localSequenceNumber = 0 |
| 148 | 216 |
snapshot.isChainStart = true |
| 149 |
- snapshot.monitoredTypeSetHash = HashService.typeSetHash(typeIDs: active.map { $0.id })
|
|
| 217 |
+ snapshot.monitoredTypeSetHash = HashService.typeSetHash(typeIDs: intendedTypeIDs) |
|
| 150 | 218 |
snapshot.monitoredRegistryVersion = 0 |
| 151 | 219 |
|
| 152 | 220 |
// Auto-detect post-restore on chain start with significant data |
@@ -161,13 +229,6 @@ final class HealthKitService {
|
||
| 161 | 229 |
// Device metadata — informational only, never used for chain linkage |
| 162 | 230 |
snapshot.hardwareModel = hardwareModel() |
| 163 | 231 |
snapshot.appBuildVersion = appBuildVersion() |
| 164 |
- |
|
| 165 |
- try context.save() |
|
| 166 |
- |
|
| 167 |
- // Post-save pipeline: delta computation + anomaly detection |
|
| 168 |
- try await runPostSavePipeline(snapshot: snapshot, typeCounts: typeCounts, context: context) |
|
| 169 |
- |
|
| 170 |
- return snapshot |
|
| 171 | 232 |
} |
| 172 | 233 |
|
| 173 | 234 |
// MARK: - Post-save pipeline |
@@ -101,7 +101,8 @@ final class SnapshotFetchProgress {
|
||
| 101 | 101 |
var successCount: Int = 0 |
| 102 | 102 |
} |
| 103 | 103 |
|
| 104 |
- var types: [TypeProgress] |
|
| 104 |
+ let totalTypeCount: Int |
|
| 105 |
+ var types: [TypeProgress] = [] |
|
| 105 | 106 |
var perTypeTimeoutSeconds: TimeInterval = 0 |
| 106 | 107 |
var maxConcurrentTypeFetches: Int = 0 |
| 107 | 108 |
var adaptiveTimeoutsEnabled: Bool = false |
@@ -110,6 +111,7 @@ final class SnapshotFetchProgress {
|
||
| 110 | 111 |
var snapshotChecksum: String = "" |
| 111 | 112 |
var monitoredTypeSetHash: String = "" |
| 112 | 113 |
var monitoredRegistryVersion: Int? |
| 114 |
+ private let displayNamesByID: [String: String] |
|
| 113 | 115 |
|
| 114 | 116 |
var visibleTypes: [TypeProgress] { types }
|
| 115 | 117 |
var completedCount: Int { types.filter { $0.status == .complete }.count }
|
@@ -124,9 +126,8 @@ final class SnapshotFetchProgress {
|
||
| 124 | 126 |
} |
| 125 | 127 |
|
| 126 | 128 |
init(monitoredTypes: [(id: String, displayName: String)]) {
|
| 127 |
- self.types = monitoredTypes.map {
|
|
| 128 |
- TypeProgress(id: $0.id, displayName: $0.displayName) |
|
| 129 |
- } |
|
| 129 |
+ self.totalTypeCount = monitoredTypes.count |
|
| 130 |
+ self.displayNamesByID = Dictionary(uniqueKeysWithValues: monitoredTypes.map { ($0.id, $0.displayName) })
|
|
| 130 | 131 |
} |
| 131 | 132 |
|
| 132 | 133 |
func updateConfiguration( |
@@ -154,7 +155,7 @@ final class SnapshotFetchProgress {
|
||
| 154 | 155 |
} |
| 155 | 156 |
|
| 156 | 157 |
func updateStatus(_ id: String, status: TypeProgress.FetchStatus, recordCount: Int? = nil) {
|
| 157 |
- guard let index = types.firstIndex(where: { $0.id == id }) else { return }
|
|
| 158 |
+ let index = visibleTypeIndex(for: id) |
|
| 158 | 159 |
types[index].status = status |
| 159 | 160 |
if let recordCount {
|
| 160 | 161 |
types[index].recordCount = recordCount |
@@ -180,7 +181,7 @@ final class SnapshotFetchProgress {
|
||
| 180 | 181 |
timeoutCount: Int, |
| 181 | 182 |
successCount: Int |
| 182 | 183 |
) {
|
| 183 |
- guard let index = types.firstIndex(where: { $0.id == id }) else { return }
|
|
| 184 |
+ let index = visibleTypeIndex(for: id) |
|
| 184 | 185 |
types[index].quality = quality |
| 185 | 186 |
types[index].recordCount = recordCount |
| 186 | 187 |
types[index].isUnsupported = isUnsupported |
@@ -208,7 +209,7 @@ final class SnapshotFetchProgress {
|
||
| 208 | 209 |
timeoutCount: Int, |
| 209 | 210 |
successCount: Int |
| 210 | 211 |
) {
|
| 211 |
- guard let index = types.firstIndex(where: { $0.id == id }) else { return }
|
|
| 212 |
+ let index = visibleTypeIndex(for: id) |
|
| 212 | 213 |
types[index].timeoutMode = timeoutMode |
| 213 | 214 |
types[index].lastSuccessfulElapsed = lastSuccessfulElapsed |
| 214 | 215 |
types[index].learnedTimeout = learnedTimeout |
@@ -216,4 +217,16 @@ final class SnapshotFetchProgress {
|
||
| 216 | 217 |
types[index].timeoutCount = timeoutCount |
| 217 | 218 |
types[index].successCount = successCount |
| 218 | 219 |
} |
| 220 |
+ |
|
| 221 |
+ private func visibleTypeIndex(for id: String) -> Int {
|
|
| 222 |
+ if let index = types.firstIndex(where: { $0.id == id }) {
|
|
| 223 |
+ return index |
|
| 224 |
+ } |
|
| 225 |
+ |
|
| 226 |
+ types.insert( |
|
| 227 |
+ TypeProgress(id: id, displayName: displayNamesByID[id] ?? id), |
|
| 228 |
+ at: 0 |
|
| 229 |
+ ) |
|
| 230 |
+ return 0 |
|
| 231 |
+ } |
|
| 219 | 232 |
} |
@@ -25,6 +25,7 @@ final class DashboardViewModel {
|
||
| 25 | 25 |
|
| 26 | 26 |
private let healthKit = HealthKitService.shared |
| 27 | 27 |
private let diffService = SnapshotDiffService.shared |
| 28 |
+ private var pendingPartialSnapshot: HealthSnapshot? |
|
| 28 | 29 |
|
| 29 | 30 |
func requestAuthorization() async {
|
| 30 | 31 |
isRequestingAuth = true |
@@ -62,6 +63,7 @@ final class DashboardViewModel {
|
||
| 62 | 63 |
completedSnapshotDeviceID = nil |
| 63 | 64 |
completedSnapshotTriggerReason = nil |
| 64 | 65 |
completedSnapshotRetryOfSnapshotID = nil |
| 66 |
+ pendingPartialSnapshot = nil |
|
| 65 | 67 |
snapshotProgressMessage = "" |
| 66 | 68 |
snapshotProgressDetail = "" |
| 67 | 69 |
canRetryWithPermissions = false |
@@ -69,6 +71,7 @@ final class DashboardViewModel {
|
||
| 69 | 71 |
|
| 70 | 72 |
let monitoredTypes = HealthKitService.allTypes |
| 71 | 73 |
.filter { selectedTypeIDs.contains($0.id) }
|
| 74 |
+ .sortedByFetchDisplayNameDescending() |
|
| 72 | 75 |
.map { (id: $0.id, displayName: $0.displayName) }
|
| 73 | 76 |
fetchProgress = SnapshotFetchProgress(monitoredTypes: monitoredTypes) |
| 74 | 77 |
fetchProgress?.updateConfiguration( |
@@ -93,13 +96,6 @@ final class DashboardViewModel {
|
||
| 93 | 96 |
) |
| 94 | 97 |
} |
| 95 | 98 |
|
| 96 |
- let allSnapshots = try context.fetch(FetchDescriptor<HealthSnapshot>()) |
|
| 97 |
- let exists = allSnapshots.contains { $0.id == snapshot.id }
|
|
| 98 |
- |
|
| 99 |
- if !exists {
|
|
| 100 |
- throw SnapshotCreationError.snapshotNotSaved |
|
| 101 |
- } |
|
| 102 |
- |
|
| 103 | 99 |
fetchDurationSeconds = fetchStartDate.map { Date().timeIntervalSince($0) }
|
| 104 | 100 |
completedSnapshotID = snapshot.id |
| 105 | 101 |
completedSnapshotTimestamp = snapshot.timestamp |
@@ -115,6 +111,8 @@ final class DashboardViewModel {
|
||
| 115 | 111 |
) |
| 116 | 112 |
|
| 117 | 113 |
if snapshot.snapshotQuality != SnapshotQuality.complete {
|
| 114 |
+ pendingPartialSnapshot = snapshot |
|
| 115 |
+ |
|
| 118 | 116 |
let typeCounts = snapshot.typeCounts ?? [] |
| 119 | 117 |
let unauthorizedCount = typeCounts.filter { $0.quality == SnapshotQuality.unauthorized }.count
|
| 120 | 118 |
let failedCount = typeCounts.filter { $0.quality == SnapshotQuality.failed }.count
|
@@ -159,6 +157,13 @@ final class DashboardViewModel {
|
||
| 159 | 157 |
return |
| 160 | 158 |
} |
| 161 | 159 |
|
| 160 |
+ let allSnapshots = try context.fetch(FetchDescriptor<HealthSnapshot>()) |
|
| 161 |
+ let exists = allSnapshots.contains { $0.id == snapshot.id }
|
|
| 162 |
+ |
|
| 163 |
+ if !exists {
|
|
| 164 |
+ throw SnapshotCreationError.snapshotNotSaved |
|
| 165 |
+ } |
|
| 166 |
+ |
|
| 162 | 167 |
snapshotProgress = .complete |
| 163 | 168 |
} catch is CancellationError {
|
| 164 | 169 |
snapshotError = "Snapshot creation exceeded the operation timeout. Individual metric timeouts are adaptive; retry failed metrics with an extended timeout when available." |
@@ -188,31 +193,59 @@ final class DashboardViewModel {
|
||
| 188 | 193 |
} |
| 189 | 194 |
} |
| 190 | 195 |
|
| 191 |
- func retryFailedMetricsWithExtendedTimeout(context: ModelContext) async {
|
|
| 196 |
+ func retryFailedMetricsWithExtendedTimeout( |
|
| 197 |
+ context: ModelContext, |
|
| 198 |
+ selectedTypeIDs: Set<String>, |
|
| 199 |
+ adaptiveTimeoutsEnabled: Bool |
|
| 200 |
+ ) async {
|
|
| 192 | 201 |
guard let progress = fetchProgress else { return }
|
| 193 |
- let retryTypeIDs = Set(progress.types.compactMap { type -> String? in
|
|
| 194 |
- guard case .failed(let reason) = type.status else { return nil }
|
|
| 195 |
- return reason == "Timeout" ? type.id : nil |
|
| 196 |
- }) |
|
| 197 |
- guard !retryTypeIDs.isEmpty else { return }
|
|
| 202 |
+ let hasTimeout = progress.types.contains { type in
|
|
| 203 |
+ guard case .failed(let reason) = type.status else { return false }
|
|
| 204 |
+ return reason == "Timeout" |
|
| 205 |
+ } |
|
| 206 |
+ guard hasTimeout else { return }
|
|
| 198 | 207 |
|
| 199 | 208 |
await createSnapshot( |
| 200 | 209 |
context: context, |
| 201 |
- selectedTypeIDs: retryTypeIDs, |
|
| 202 |
- adaptiveTimeoutsEnabled: true, |
|
| 210 |
+ selectedTypeIDs: selectedTypeIDs, |
|
| 211 |
+ adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled, |
|
| 203 | 212 |
triggerReason: "retryFailedMetrics", |
| 204 |
- retryOfSnapshotID: completedSnapshotID, |
|
| 205 | 213 |
timeoutMultiplier: 2 |
| 206 | 214 |
) |
| 207 | 215 |
} |
| 208 | 216 |
|
| 209 |
- func acceptPartialSnapshot() {
|
|
| 217 |
+ func savePartialSnapshot(context: ModelContext) async {
|
|
| 218 |
+ guard let snapshot = pendingPartialSnapshot else {
|
|
| 219 |
+ fetchProgress = nil |
|
| 220 |
+ showProgressSheet = false |
|
| 221 |
+ snapshotProgress = .idle |
|
| 222 |
+ return |
|
| 223 |
+ } |
|
| 224 |
+ |
|
| 225 |
+ do {
|
|
| 226 |
+ let saved = try await healthKit.savePartialSnapshot(snapshot, in: context) |
|
| 227 |
+ completedSnapshotID = saved.id |
|
| 228 |
+ pendingPartialSnapshot = nil |
|
| 229 |
+ fetchProgress?.updateChainContext( |
|
| 230 |
+ previousSnapshotID: saved.previousSnapshotID, |
|
| 231 |
+ isChainStart: saved.isChainStart, |
|
| 232 |
+ snapshotChecksum: HashService.snapshotChecksum(typeCounts: saved.typeCounts ?? []), |
|
| 233 |
+ monitoredTypeSetHash: saved.monitoredTypeSetHash, |
|
| 234 |
+ monitoredRegistryVersion: saved.monitoredRegistryVersion |
|
| 235 |
+ ) |
|
| 236 |
+ } catch {
|
|
| 237 |
+ snapshotError = "Failed to save partial snapshot: \(error.localizedDescription)" |
|
| 238 |
+ showProgressSheet = true |
|
| 239 |
+ return |
|
| 240 |
+ } |
|
| 241 |
+ |
|
| 210 | 242 |
fetchProgress = nil |
| 211 | 243 |
showProgressSheet = false |
| 212 | 244 |
snapshotProgress = .idle |
| 213 | 245 |
} |
| 214 | 246 |
|
| 215 | 247 |
func discardSnapshot(context: ModelContext) async {
|
| 248 |
+ pendingPartialSnapshot = nil |
|
| 216 | 249 |
if let snapshotID = completedSnapshotID {
|
| 217 | 250 |
do {
|
| 218 | 251 |
let allSnapshots = try context.fetch(FetchDescriptor<HealthSnapshot>()) |
@@ -305,7 +305,7 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 305 | 305 |
lines.append("")
|
| 306 | 306 |
lines.append("STATISTICS")
|
| 307 | 307 |
lines.append("Records: \(progress.totalRecords)")
|
| 308 |
- lines.append("Types: \(progress.types.count) processed, \(progress.completedCount) complete, \(degraded.count) degraded")
|
|
| 308 |
+ lines.append("Types: \(progress.types.count)/\(progress.totalTypeCount) processed, \(progress.completedCount) complete, \(degraded.count) degraded")
|
|
| 309 | 309 |
lines.append("")
|
| 310 | 310 |
lines.append(failedLines) |
| 311 | 311 |
|
@@ -412,9 +412,9 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 412 | 412 |
|
| 413 | 413 |
private func fetchProgressSummary(_ progress: SnapshotFetchProgress) -> String {
|
| 414 | 414 |
if progress.failedCount > 0 {
|
| 415 |
- return "\(progress.completedCount)/\(progress.types.count) fetched - \(progress.failedCount) failed" |
|
| 415 |
+ return "\(progress.completedCount)/\(progress.totalTypeCount) fetched - \(progress.failedCount) failed" |
|
| 416 | 416 |
} |
| 417 |
- return "\(progress.completedCount)/\(progress.types.count) fetched" |
|
| 417 |
+ return "\(progress.completedCount)/\(progress.totalTypeCount) fetched" |
|
| 418 | 418 |
} |
| 419 | 419 |
|
| 420 | 420 |
private func colorForStatus(_ status: SnapshotFetchProgress.TypeProgress.FetchStatus) -> Color {
|
@@ -502,7 +502,7 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 502 | 502 |
.cornerRadius(8) |
| 503 | 503 |
|
| 504 | 504 |
VStack(spacing: 0) {
|
| 505 |
- ReportRow(label: "Types processed", value: "\(progress.types.count)") |
|
| 505 |
+ ReportRow(label: "Types processed", value: "\(progress.types.count)/\(progress.totalTypeCount)") |
|
| 506 | 506 |
Rectangle().fill(Color(.separator)).frame(height: 0.5).padding(.leading, 16) |
| 507 | 507 |
ReportRow(label: "Successful", value: "\(progress.completedCount)", valueColor: .healthyGreen) |
| 508 | 508 |
if progress.failedCount > 0 {
|
@@ -607,16 +607,15 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 607 | 607 |
} |
| 608 | 608 |
|
| 609 | 609 |
private func reportDiagnosticBlock(_ progress: SnapshotFetchProgress) -> some View {
|
| 610 |
- let previewText = buildDiagnosticText(progress, mode: .compact) |
|
| 611 | 610 |
let fullText = buildDiagnosticText(progress, mode: .full) |
| 612 |
- return CollapsibleDiagnosticBlock(previewText: previewText, fullText: fullText) |
|
| 611 |
+ return CollapsibleDiagnosticBlock(fullText: fullText) |
|
| 613 | 612 |
} |
| 614 | 613 |
|
| 615 | 614 |
private func reportDecisionOverviewSection() -> some View {
|
| 616 | 615 |
reportRemediationSection( |
| 617 | 616 |
[ |
| 618 |
- "Retry failed metric with extended timeout.", |
|
| 619 |
- "Accept snapshot if partial data is sufficient.", |
|
| 617 |
+ "Retry the full snapshot with extended timeout.", |
|
| 618 |
+ "Save the partial snapshot only if this incomplete report is useful.", |
|
| 620 | 619 |
"Discard snapshot and start a new one.", |
| 621 | 620 |
"Disable this metric in Settings if it consistently times out." |
| 622 | 621 |
], |
@@ -777,7 +776,7 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 777 | 776 |
} |
| 778 | 777 |
|
| 779 | 778 |
if let progress = viewModel.fetchProgress {
|
| 780 |
- Text("\(progress.types.count) metrics")
|
|
| 779 |
+ Text("\(progress.totalTypeCount) metrics")
|
|
| 781 | 780 |
.font(.caption) |
| 782 | 781 |
.foregroundStyle(.secondary) |
| 783 | 782 |
} |
@@ -869,7 +868,11 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 869 | 868 |
adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled |
| 870 | 869 |
) |
| 871 | 870 |
} else {
|
| 872 |
- await viewModel.retryFailedMetricsWithExtendedTimeout(context: modelContext) |
|
| 871 |
+ await viewModel.retryFailedMetricsWithExtendedTimeout( |
|
| 872 |
+ context: modelContext, |
|
| 873 |
+ selectedTypeIDs: appSettings.selectedTypeIDs, |
|
| 874 |
+ adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled |
|
| 875 |
+ ) |
|
| 873 | 876 |
} |
| 874 | 877 |
} |
| 875 | 878 |
} label: {
|
@@ -879,12 +882,13 @@ private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
|
||
| 879 | 882 |
.disabled(viewModel.isCreatingSnapshot || viewModel.isRequestingAuth) |
| 880 | 883 |
|
| 881 | 884 |
Button {
|
| 882 |
- viewModel.acceptPartialSnapshot() |
|
| 885 |
+ Task { await viewModel.savePartialSnapshot(context: modelContext) }
|
|
| 883 | 886 |
} label: {
|
| 884 |
- Text("Accept").frame(maxWidth: .infinity)
|
|
| 887 |
+ Text("Save Partial").frame(maxWidth: .infinity)
|
|
| 885 | 888 |
} |
| 886 | 889 |
.buttonStyle(.bordered) |
| 887 |
- .accessibilityLabel("Accept partial snapshot")
|
|
| 890 |
+ .disabled(viewModel.isCreatingSnapshot) |
|
| 891 |
+ .accessibilityLabel("Save partial snapshot")
|
|
| 888 | 892 |
|
| 889 | 893 |
Button {
|
| 890 | 894 |
Task { await viewModel.discardSnapshot(context: modelContext) }
|
@@ -1016,33 +1020,67 @@ private struct ReportRow: View {
|
||
| 1016 | 1020 |
} |
| 1017 | 1021 |
|
| 1018 | 1022 |
private struct CollapsibleDiagnosticBlock: View {
|
| 1019 |
- let previewText: String |
|
| 1020 | 1023 |
let fullText: String |
| 1021 | 1024 |
@State private var isExpanded = false |
| 1025 |
+ @State private var didCopy = false |
|
| 1022 | 1026 |
|
| 1023 | 1027 |
var body: some View {
|
| 1024 | 1028 |
VStack(alignment: .leading, spacing: 8) {
|
| 1025 |
- Button {
|
|
| 1026 |
- withAnimation(.snappy) {
|
|
| 1027 |
- isExpanded.toggle() |
|
| 1028 |
- } |
|
| 1029 |
- } label: {
|
|
| 1030 |
- HStack {
|
|
| 1029 |
+ HStack(spacing: 12) {
|
|
| 1030 |
+ Button {
|
|
| 1031 |
+ withAnimation(.snappy) {
|
|
| 1032 |
+ isExpanded.toggle() |
|
| 1033 |
+ } |
|
| 1034 |
+ } label: {
|
|
| 1031 | 1035 |
Label("Diagnostics", systemImage: "doc.text.magnifyingglass")
|
| 1032 |
- Spacer() |
|
| 1036 |
+ } |
|
| 1037 |
+ .buttonStyle(.plain) |
|
| 1038 |
+ |
|
| 1039 |
+ Spacer() |
|
| 1040 |
+ |
|
| 1041 |
+ Button {
|
|
| 1042 |
+ copyDiagnostics() |
|
| 1043 |
+ } label: {
|
|
| 1044 |
+ Label(didCopy ? "Copied" : "Copy", systemImage: didCopy ? "checkmark" : "doc.on.doc") |
|
| 1045 |
+ .font(.subheadline.weight(.semibold)) |
|
| 1046 |
+ } |
|
| 1047 |
+ .buttonStyle(.plain) |
|
| 1048 |
+ .accessibilityLabel(didCopy ? "Diagnostics copied" : "Copy diagnostics") |
|
| 1049 |
+ |
|
| 1050 |
+ Button {
|
|
| 1051 |
+ withAnimation(.snappy) {
|
|
| 1052 |
+ isExpanded.toggle() |
|
| 1053 |
+ } |
|
| 1054 |
+ } label: {
|
|
| 1033 | 1055 |
Image(systemName: isExpanded ? "chevron.up" : "chevron.down") |
| 1034 | 1056 |
.font(.caption.weight(.semibold)) |
| 1057 |
+ .frame(width: 28, height: 28) |
|
| 1035 | 1058 |
} |
| 1059 |
+ .buttonStyle(.plain) |
|
| 1060 |
+ .accessibilityLabel(isExpanded ? "Collapse diagnostics" : "Expand diagnostics") |
|
| 1061 |
+ } |
|
| 1062 |
+ |
|
| 1063 |
+ if isExpanded {
|
|
| 1064 |
+ Text(fullText) |
|
| 1065 |
+ .font(.system(.caption, design: .monospaced)) |
|
| 1066 |
+ .textSelection(.enabled) |
|
| 1067 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 1068 |
+ .padding(10) |
|
| 1069 |
+ .background(Color(.secondarySystemGroupedBackground)) |
|
| 1070 |
+ .clipShape(RoundedRectangle(cornerRadius: 8)) |
|
| 1071 |
+ } |
|
| 1072 |
+ } |
|
| 1073 |
+ } |
|
| 1074 |
+ |
|
| 1075 |
+ private func copyDiagnostics() {
|
|
| 1076 |
+ UIPasteboard.general.string = fullText |
|
| 1077 |
+ withAnimation(.snappy) {
|
|
| 1078 |
+ didCopy = true |
|
| 1079 |
+ } |
|
| 1080 |
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
|
| 1081 |
+ withAnimation(.snappy) {
|
|
| 1082 |
+ didCopy = false |
|
| 1036 | 1083 |
} |
| 1037 |
- .buttonStyle(.plain) |
|
| 1038 |
- |
|
| 1039 |
- Text(isExpanded ? fullText : previewText) |
|
| 1040 |
- .font(.system(.caption, design: .monospaced)) |
|
| 1041 |
- .textSelection(.enabled) |
|
| 1042 |
- .frame(maxWidth: .infinity, alignment: .leading) |
|
| 1043 |
- .padding(10) |
|
| 1044 |
- .background(Color(.secondarySystemGroupedBackground)) |
|
| 1045 |
- .clipShape(RoundedRectangle(cornerRadius: 8)) |
|
| 1046 | 1084 |
} |
| 1047 | 1085 |
} |
| 1048 | 1086 |
} |