1 contributor
import SwiftUI
import HealthKit
import UIKit
struct DashboardView: View {
@Environment(AppSettings.self) private var appSettings
@State private var viewModel = DashboardViewModel()
@State private var currentDeviceProfile = LocalDeviceProfileStore.profile(for: AppSettings.currentDeviceID)
@State private var didAutoRequestPermissions = false
@State private var snapshotSheetTab: SnapshotSheetTab = .progress
@State private var diagnosticReport: DiagnosticReport?
@State private var expandedIssueIDs: Set<String> = []
@State private var idleTimerWasDisabledBeforeSnapshot = false
@State private var snapshotIdleTimerOverrideActive = false
@State private var savedDiagnosticSnapshotIDs: Set<UUID> = []
private var latestArchiveObservation: CachedArchiveObservationRow? {
viewModel.latestArchiveObservation
}
private var previousArchiveObservation: CachedArchiveObservationRow? {
guard viewModel.archiveObservationRows.count > 1 else { return nil }
return viewModel.archiveObservationRows[1]
}
private var currentDeviceDisplayName: String {
if !currentDeviceProfile.name.isEmpty { return currentDeviceProfile.name }
return "Local device"
}
private var latestArchiveChangeCount: Int? {
guard let latestArchiveObservation,
previousArchiveObservation != nil else {
return nil
}
return latestArchiveObservation.appearedCount
+ latestArchiveObservation.disappearedCount
+ latestArchiveObservation.representationChangedCount
}
var body: some View {
NavigationStack {
List {
statusSection
actionsSection
if let msg = viewModel.authError ?? viewModel.snapshotError {
Section {
Label(msg, systemImage: "exclamationmark.circle")
.foregroundStyle(Color.criticalRed)
.font(.caption)
}
}
}
.navigationTitle("HealthProbe")
}
.sheet(isPresented: $viewModel.showProgressSheet) {
progressSheet
}
.task {
currentDeviceProfile = LocalDeviceProfileStore.profile(for: AppSettings.currentDeviceID)
await viewModel.loadArchiveCacheStatus()
if !didAutoRequestPermissions && !HealthKitService.shared.hasRequestedPermissionsBefore {
didAutoRequestPermissions = true
await viewModel.requestAuthorization()
}
}
.onChange(of: viewModel.snapshotProgress) { _, newProgress in
updateIdleTimer(for: newProgress)
}
.onDisappear {
restoreSnapshotIdleTimerIfNeeded()
}
}
// MARK: - Helpers
private func updateIdleTimer(for progress: SnapshotProgress) {
let shouldDisableIdleTimer = progress == .fetching || progress == .processing
if shouldDisableIdleTimer && !snapshotIdleTimerOverrideActive {
idleTimerWasDisabledBeforeSnapshot = UIApplication.shared.isIdleTimerDisabled
UIApplication.shared.isIdleTimerDisabled = true
snapshotIdleTimerOverrideActive = true
} else if !shouldDisableIdleTimer {
restoreSnapshotIdleTimerIfNeeded()
}
}
private func restoreSnapshotIdleTimerIfNeeded() {
guard snapshotIdleTimerOverrideActive else { return }
UIApplication.shared.isIdleTimerDisabled = idleTimerWasDisabledBeforeSnapshot
snapshotIdleTimerOverrideActive = false
}
// MARK: - Report helpers
private func failedTypesList(in progress: SnapshotFetchProgress) -> [SnapshotFetchProgress.TypeProgress] {
progress.visibleTypes.filter {
if case .failed = $0.status { return true }
return false
}
}
private func timedOutMetricNames(_ progress: SnapshotFetchProgress?) -> [String] {
progress?.types.compactMap { type in
if case .failed(let r) = type.status, r == "Timeout" { return type.displayName }
return nil
} ?? []
}
private func hasTimedOutMetrics(_ progress: SnapshotFetchProgress?) -> Bool {
progress?.types.contains {
if case .failed(let reason) = $0.status { return reason == "Timeout" }
return false
} ?? false
}
private func hasAuthorizationFailures(_ progress: SnapshotFetchProgress?) -> Bool {
progress?.types.contains {
if case .failed(let reason) = $0.status { return reason == "Not authorized" }
return false
} ?? false
}
private func degradedTypesList(in progress: SnapshotFetchProgress) -> [SnapshotFetchProgress.TypeProgress] {
progress.types.filter { type in
let hasAllExpectedCalls = Set(type.apiCallDetails.map(\.queryType)) == Set(["record_import", "earliest_sample", "latest_sample"])
let allCallsComplete = type.apiCallDetails.allSatisfy { $0.status == .complete }
return type.quality != "complete" || !hasAllExpectedCalls || !allCallsComplete
}
}
private func formatDuration(_ seconds: TimeInterval) -> String {
if seconds < 60 { return String(format: "%.1fs", seconds) }
return "\(Int(seconds) / 60)m \(Int(seconds) % 60)s"
}
private func timingResidual(totalElapsed: TimeInterval, breakdown: ImportTimingBreakdown) -> TimeInterval {
max(0, totalElapsed - breakdown.accountedElapsedSeconds)
}
private func qualityLabel(progress: SnapshotFetchProgress) -> String {
switch viewModel.snapshotProgress {
case .complete: return "Complete"
case .requiresResolution: return "Needs decision"
case .incomplete:
let hasUnauthorized = failedTypesList(in: progress).contains {
if case .failed(let r) = $0.status { return r == "Not authorized" }
return false
}
return hasUnauthorized ? "Partial (unauthorized)" : "Partial"
default: return "—"
}
}
private func qualityColor(progress: SnapshotFetchProgress) -> Color {
switch viewModel.snapshotProgress {
case .complete: return .healthyGreen
case .requiresResolution: return .warningAmber
case .incomplete: return .warningAmber
default: return .neutralGray
}
}
private func failureReason(_ type: SnapshotFetchProgress.TypeProgress) -> String {
if case .failed(let r) = type.status { return r.isEmpty ? "Failed" : r }
return "Unknown"
}
private func failureImpact(_ reason: String) -> String {
switch reason {
case "Not authorized": return "Excluded from checksum and change analysis"
case "Timeout": return "Data unavailable — type skipped this run"
case "Unsupported": return "Not supported on this device or OS version"
default: return "Data unavailable"
}
}
private func remediationNoteItems(progress: SnapshotFetchProgress) -> [String] {
let failed = failedTypesList(in: progress)
var items: [String] = []
let unauthorized = failed.filter {
if case .failed(let r) = $0.status { return r == "Not authorized" }
return false
}
let timedOut = failed.filter {
if case .failed(let r) = $0.status { return r == "Timeout" }
return false
}
if !unauthorized.isEmpty {
items.append("Open iOS Settings, choose HealthProbe, and re-enable Health read access for the listed metrics.")
items.append("After restoring access, create a new snapshot before relying on checksum or change analysis.")
}
if !timedOut.isEmpty {
items.append("Timeout reflects HealthProbe's configured limit, not missing Health data or permission.")
}
if viewModel.snapshotProgress == .incomplete {
items.append("Partial snapshots are excluded from change analysis. A complete snapshot is needed to resume integrity checks.")
}
return items
}
private func ambiguousDisappearanceNotes() -> [String] {
[
"All values are missing for the listed metric in the current Health view. Apple API does not allow HealthProbe to verify Health read authorization status.",
"Possible causes: Health read authorization was withdrawn, or the metric is genuinely absent from the current Health store."
]
}
private func ambiguousDisappearanceActions() -> [String] {
[
"Treat the metric as unauthorized.",
"Treat the metric as absent from the current Health store.",
"Cancel saving this snapshot."
]
}
private func iso8601String(_ date: Date?) -> String {
guard let date else { return "none" }
return ISO8601DateFormatter().string(from: date)
}
private enum DiagnosticReportMode {
case compact
case full
}
private func operationResultValue() -> String {
switch viewModel.snapshotProgress {
case .complete:
return "complete_success"
case .incomplete:
return "partial_success"
case .requiresResolution:
return "requires_user_resolution"
case .idle, .fetching, .processing:
return "failed"
}
}
private func normalizedAuthorization(for type: SnapshotFetchProgress.TypeProgress) -> (status: String, source: String) {
let hasSuccessfulQuery = type.apiCallDetails.contains { $0.status == .complete }
let normalized = type.authorizationStatus.lowercased()
if hasSuccessfulQuery {
return ("granted", "inferredFromSuccessfulQuery")
}
if normalized == "denied" {
return ("denied", "inferredFromUnauthorizedQuery")
}
if normalized == "notdetermined" {
return ("notDetermined", "reported")
}
if normalized == "granted" {
return ("granted", "reported")
}
// When real HK authorization status is unavailable, report unknown+unavailable.
return ("unknown", "unavailable")
}
private func metricDateString(_ date: Date?, queryType: String, calls: [HealthKitAPICallResult]) -> String {
if let date { return iso8601String(date) }
guard let call = calls.first(where: { $0.queryType == queryType }) else { return "unknown" }
return call.status == .complete ? "none" : "unknown"
}
private func yearlyCountsString(_ counts: [SnapshotFetchProgress.YearlyCountProgress]) -> String {
guard !counts.isEmpty else { return "none" }
return counts
.sorted { $0.year < $1.year }
.map { "\($0.year)=\($0.count)\($0.isApproximate ? " approximate" : "")" }
.joined(separator: ", ")
}
private func callResultLabel(_ call: HealthKitAPICallResult) -> String {
if let result = call.resultValue, !result.isEmpty {
return result
}
return call.status == .complete ? "none" : "unknown"
}
private func shouldPrintCallErrorFields(_ call: HealthKitAPICallResult) -> Bool {
switch call.status {
case .failed, .timeout, .unknown, .unauthorized:
return true
case .complete, .unsupported:
return false
}
}
private func buildDiagnosticText(_ progress: SnapshotFetchProgress, mode: DiagnosticReportMode = .full) -> String {
let snapshotID = viewModel.completedSnapshotID.map { $0.uuidString } ?? "unknown"
let deviceID = viewModel.completedSnapshotDeviceID ?? "unknown"
let timestamp = viewModel.completedSnapshotTimestamp ?? Date()
let operationID = viewModel.operationID?.uuidString ?? "unknown"
let reportGeneratedAt = iso8601String(Date())
let trigger = viewModel.completedSnapshotTriggerReason ?? "manual"
let quality: String
switch viewModel.snapshotProgress {
case .complete: quality = "complete"
case .incomplete: quality = "partial"
case .requiresResolution: quality = "requires_resolution"
default: quality = "unknown"
}
let duration = viewModel.fetchDurationSeconds.map { formatDuration($0) } ?? "unknown"
let fmt = DateFormatter()
fmt.dateFormat = "yyyy-MM-dd HH:mm:ss"
fmt.locale = Locale(identifier: "en_US_POSIX")
let tsStr = fmt.string(from: timestamp)
let degraded = degradedTypesList(in: progress)
let isPartialSnapshot = viewModel.snapshotProgress == .incomplete
let requiresResolution = viewModel.snapshotProgress == .requiresResolution
let failedLines: String
if degraded.isEmpty {
failedLines = "FAILED/DEGRADED METRICS: none"
} else {
failedLines = (["FAILED/DEGRADED METRICS"] + degraded
.map { " \($0.displayName): \($0.quality)" })
.joined(separator: "\n")
}
var lines: [String] = []
lines.append("BEGIN HEALTHPROBE REPORT")
lines.append("")
lines.append("OPERATION METADATA")
lines.append("operationType: \(trigger == "retryFailedMetrics" ? "retryFailedMetrics" : "createSnapshot")")
lines.append("operationID: \(operationID)")
lines.append("operationResult: \(operationResultValue())")
lines.append("primaryObjectType: snapshot")
lines.append("primaryObjectID: \(snapshotID)")
lines.append("reportSchemaVersion: 3")
lines.append("reportGeneratedAt: \(reportGeneratedAt)")
lines.append("appVersion: \(Bundle.main.appVersion)")
lines.append("buildFingerprint: \(Bundle.main.buildFingerprint)")
lines.append("sourceCommit: \(Bundle.main.sourceCommit)")
lines.append("sourceDirty: \(Bundle.main.sourceDirty)")
lines.append("")
lines.append("OPERATION SUMMARY")
lines.append("Snapshot: \(snapshotID)")
lines.append("Archive Observation: \(viewModel.completedArchiveObservationID.map(String.init) ?? "unknown")")
lines.append("Device: \(deviceID)")
lines.append("Timestamp: \(tsStr)")
lines.append("Quality: \(quality)")
lines.append("Duration: \(duration)")
lines.append("Trigger: \(trigger)")
if let retryOfSnapshotID = viewModel.completedSnapshotRetryOfSnapshotID {
lines.append("Retry of: \(retryOfSnapshotID.uuidString)")
}
lines.append("")
lines.append("DIAGNOSTIC NOTES")
lines.append("Per-metric timing sums are cumulative work and can exceed wall-clock duration because type fetches overlap")
lines.append("Fetch/processing/insert/finalize fields are measured for import performance comparison")
if isPartialSnapshot {
lines.append("Partial snapshot: true")
lines.append("Failed metrics are excluded from checksum/change analysis")
lines.append("Do not infer deletion from partial snapshots")
}
if requiresResolution {
lines.append("Requires user resolution: true")
lines.append("All-values-missing metrics require review before saving")
}
if progress.types.contains(where: { $0.apiCallDetails.contains(where: { $0.status == .timeout }) }) {
lines.append("Timeout means HealthProbe cancelled after configured timeout, not necessarily HealthKit denial")
}
lines.append("")
lines.append("CONFIGURATION")
lines.append("adaptiveTimeoutsEnabled: \(progress.adaptiveTimeoutsEnabled ? "true" : "false")")
lines.append("defaultInitialTimeout: \(formatDuration(LocalMetricTimeoutProfile.defaultInitialTimeout))")
lines.append("maximumTimeout: \(formatDuration(LocalMetricTimeoutProfile.maximumTimeout))")
lines.append("maxConcurrentTypeFetches: \(progress.maxConcurrentTypeFetches)")
lines.append("")
lines.append("CHAIN/INTEGRITY CONTEXT")
lines.append("previousSnapshotID: \(progress.previousSnapshotID.map { $0.uuidString } ?? "none")")
lines.append("isChainStart: \(progress.isChainStart.map { $0 ? "true" : "false" } ?? "unknown")")
lines.append("snapshotChecksum: \(progress.snapshotChecksum)")
lines.append("monitoredTypeSetHash: \(progress.monitoredTypeSetHash)")
lines.append("monitoredRegistryVersion: \(progress.monitoredRegistryVersion.map(String.init) ?? "unknown")")
lines.append("")
lines.append("STATISTICS")
lines.append("Records: \(progress.totalRecords)")
lines.append("Types: \(progress.types.count)/\(progress.totalTypeCount) processed, \(progress.completedCount) complete, \(degraded.count) degraded")
lines.append("WallClockDuration: \(duration)")
let aggregateTiming = progress.aggregateTimingBreakdown
let summedMetricTotalElapsed = progress.types.reduce(0) { $0 + max(0, $1.totalElapsedSeconds) }
let summedMetricResidualElapsed = progress.types.reduce(0) { partial, type in
partial + timingResidual(totalElapsed: type.totalElapsedSeconds, breakdown: type.timingBreakdown)
}
let captureModeCounts = Dictionary(grouping: progress.types, by: \.captureMode)
.mapValues(\.count)
let summedDeltaEventCount = progress.types.reduce(0) { $0 + max(0, $1.deltaEventCount) }
lines.append("SummedMetricTotalElapsed: \(formatDuration(summedMetricTotalElapsed))")
lines.append("SummedFetchElapsed: \(formatDuration(aggregateTiming.fetchElapsedSeconds))")
lines.append("SummedProcessingElapsed: \(formatDuration(aggregateTiming.processingElapsedSeconds))")
if aggregateTiming.processingAccountedElapsedSeconds > 0 {
lines.append("SummedProcessingDeltaApplyElapsed: \(formatDuration(aggregateTiming.processingDeltaApplyElapsedSeconds))")
lines.append("SummedProcessingRecordArchiveRebuildElapsed: \(formatDuration(aggregateTiming.processingRecordArchiveRebuildElapsedSeconds))")
lines.append("SummedProcessingInitialRecordElapsed: \(formatDuration(aggregateTiming.processingInitialRecordElapsedSeconds))")
lines.append("SummedProcessingRecordArchiveFinalizeElapsed: \(formatDuration(aggregateTiming.processingRecordArchiveFinalizeElapsedSeconds))")
lines.append("SummedProcessingOtherElapsed: \(formatDuration(aggregateTiming.processingOtherElapsedSeconds))")
}
lines.append("SummedInsertElapsed: \(formatDuration(aggregateTiming.insertElapsedSeconds))")
lines.append("SummedFinalizeElapsed: \(formatDuration(aggregateTiming.finalizeElapsedSeconds))")
if aggregateTiming.finalizeAccountedElapsedSeconds > 0 {
lines.append("SummedFinalizeEventCountElapsed: \(formatDuration(aggregateTiming.finalizeEventCountElapsedSeconds))")
lines.append("SummedFinalizeTypeSummaryElapsed: \(formatDuration(aggregateTiming.finalizeTypeSummaryElapsedSeconds))")
lines.append("SummedFinalizeDailyAggregateElapsed: \(formatDuration(aggregateTiming.finalizeDailyAggregateElapsedSeconds))")
if aggregateTiming.finalizeDailyAggregateAccountedElapsedSeconds > 0 {
lines.append("SummedFinalizeDailyAggregateBucketLookupElapsed: \(formatDuration(aggregateTiming.finalizeDailyAggregateBucketLookupElapsedSeconds))")
lines.append("SummedFinalizeDailyAggregateCopyElapsed: \(formatDuration(aggregateTiming.finalizeDailyAggregateCopyElapsedSeconds))")
lines.append("SummedFinalizeDailyAggregateDeleteElapsed: \(formatDuration(aggregateTiming.finalizeDailyAggregateDeleteElapsedSeconds))")
lines.append("SummedFinalizeDailyAggregateRebuildElapsed: \(formatDuration(aggregateTiming.finalizeDailyAggregateRebuildElapsedSeconds))")
lines.append("SummedFinalizeDailyAggregateInsertElapsed: \(formatDuration(aggregateTiming.finalizeDailyAggregateInsertElapsedSeconds))")
lines.append("SummedFinalizeDailyAggregateOtherElapsed: \(formatDuration(aggregateTiming.finalizeDailyAggregateOtherElapsedSeconds))")
}
lines.append("SummedFinalizeRunUpdateElapsed: \(formatDuration(aggregateTiming.finalizeRunUpdateElapsedSeconds))")
lines.append("SummedFinalizeOtherElapsed: \(formatDuration(aggregateTiming.finalizeOtherElapsedSeconds))")
}
lines.append("SummedResidualElapsed: \(formatDuration(summedMetricResidualElapsed))")
lines.append("CaptureModes: unchangedDelta=\(captureModeCounts["unchangedDelta", default: 0]), delta=\(captureModeCounts["delta", default: 0]), initialImport=\(captureModeCounts["initialImport", default: 0]), unavailable=\(captureModeCounts["unavailable", default: 0])")
lines.append("DeltaEvents: \(summedDeltaEventCount)")
lines.append("")
lines.append(failedLines)
if requiresResolution {
lines.append("")
lines.append("AMBIGUOUS TOTAL DISAPPEARANCE")
for metric in viewModel.ambiguousDisappearedMetrics {
lines.append(" \(metric.displayName): previousCount=\(metric.previousCount), currentCount=0")
}
lines.append(" note: Apple API does not expose read authorization status for this decision point")
}
lines.append("")
lines.append("IMPORT PERFORMANCE BY METRIC")
let orderedTypes = progress.types.sorted(by: { $0.displayName < $1.displayName })
let degradedIDs = Set(degraded.map(\.id))
let reportTypes: [SnapshotFetchProgress.TypeProgress]
switch mode {
case .compact:
reportTypes = orderedTypes.filter { degradedIDs.contains($0.id) }
case .full:
reportTypes = orderedTypes
}
for type in reportTypes {
lines.append("")
lines.append("\(type.displayName):")
lines.append(" identifier: \(type.id)")
lines.append(" quality: \(type.quality)")
lines.append(" count: \(type.recordCount)")
lines.append(" captureMode: \(type.captureMode)")
lines.append(" deltaEvents: \(type.deltaEventCount)")
lines.append(" timeoutMode: \(type.timeoutMode)")
lines.append(" timeoutConfigured: \(formatDuration(type.timeoutConfiguredSeconds))")
lines.append(" lastSuccessfulElapsed: \(type.lastSuccessfulElapsed > 0 ? formatDuration(type.lastSuccessfulElapsed) : "none")")
lines.append(" learnedTimeout: \(type.learnedTimeout > 0 ? formatDuration(type.learnedTimeout) : "none")")
lines.append(" suggestedRetryTimeout: \(type.suggestedRetryTimeout > 0 ? formatDuration(type.suggestedRetryTimeout) : "none")")
lines.append(" timeoutCount: \(type.timeoutCount)")
lines.append(" successCount: \(type.successCount)")
lines.append(" totalElapsed: \(formatDuration(type.totalElapsedSeconds))")
lines.append(" fetchElapsed: \(formatDuration(type.timingBreakdown.fetchElapsedSeconds))")
lines.append(" processingElapsed: \(formatDuration(type.timingBreakdown.processingElapsedSeconds))")
if type.timingBreakdown.processingAccountedElapsedSeconds > 0 {
lines.append(" processingDeltaApplyElapsed: \(formatDuration(type.timingBreakdown.processingDeltaApplyElapsedSeconds))")
lines.append(" processingRecordArchiveRebuildElapsed: \(formatDuration(type.timingBreakdown.processingRecordArchiveRebuildElapsedSeconds))")
lines.append(" processingInitialRecordElapsed: \(formatDuration(type.timingBreakdown.processingInitialRecordElapsedSeconds))")
lines.append(" processingRecordArchiveFinalizeElapsed: \(formatDuration(type.timingBreakdown.processingRecordArchiveFinalizeElapsedSeconds))")
lines.append(" processingOtherElapsed: \(formatDuration(type.timingBreakdown.processingOtherElapsedSeconds))")
}
lines.append(" insertElapsed: \(formatDuration(type.timingBreakdown.insertElapsedSeconds))")
lines.append(" finalizeElapsed: \(formatDuration(type.timingBreakdown.finalizeElapsedSeconds))")
if type.timingBreakdown.finalizeAccountedElapsedSeconds > 0 {
lines.append(" finalizeEventCountElapsed: \(formatDuration(type.timingBreakdown.finalizeEventCountElapsedSeconds))")
lines.append(" finalizeTypeSummaryElapsed: \(formatDuration(type.timingBreakdown.finalizeTypeSummaryElapsedSeconds))")
lines.append(" finalizeDailyAggregateElapsed: \(formatDuration(type.timingBreakdown.finalizeDailyAggregateElapsedSeconds))")
if type.timingBreakdown.finalizeDailyAggregateAccountedElapsedSeconds > 0 {
lines.append(" finalizeDailyAggregateBucketLookupElapsed: \(formatDuration(type.timingBreakdown.finalizeDailyAggregateBucketLookupElapsedSeconds))")
lines.append(" finalizeDailyAggregateCopyElapsed: \(formatDuration(type.timingBreakdown.finalizeDailyAggregateCopyElapsedSeconds))")
lines.append(" finalizeDailyAggregateDeleteElapsed: \(formatDuration(type.timingBreakdown.finalizeDailyAggregateDeleteElapsedSeconds))")
lines.append(" finalizeDailyAggregateRebuildElapsed: \(formatDuration(type.timingBreakdown.finalizeDailyAggregateRebuildElapsedSeconds))")
lines.append(" finalizeDailyAggregateInsertElapsed: \(formatDuration(type.timingBreakdown.finalizeDailyAggregateInsertElapsedSeconds))")
lines.append(" finalizeDailyAggregateOtherElapsed: \(formatDuration(type.timingBreakdown.finalizeDailyAggregateOtherElapsedSeconds))")
}
lines.append(" finalizeRunUpdateElapsed: \(formatDuration(type.timingBreakdown.finalizeRunUpdateElapsedSeconds))")
lines.append(" finalizeOtherElapsed: \(formatDuration(type.timingBreakdown.finalizeOtherElapsedSeconds))")
}
lines.append(" residualElapsed: \(formatDuration(timingResidual(totalElapsed: type.totalElapsedSeconds, breakdown: type.timingBreakdown)))")
if type.blockElapsedSeconds > 0 {
lines.append(" fetchProgressElapsed: \(formatDuration(type.blockElapsedSeconds))")
}
if type.blockSamplesPerSecond > 0 {
lines.append(" fetchProgressRate: \(formatRate(type.blockSamplesPerSecond)) samples/s")
}
if mode == .full || type.isUnsupported {
lines.append(" unsupported: \(type.isUnsupported ? "true" : "false")")
}
let auth = normalizedAuthorization(for: type)
lines.append(" authorizationStatus: \(auth.status)")
lines.append(" authorizationStatusSource: \(auth.source)")
lines.append(" earliestDate: \(metricDateString(type.earliestDate, queryType: "earliest_sample", calls: type.apiCallDetails))")
lines.append(" latestDate: \(metricDateString(type.latestDate, queryType: "latest_sample", calls: type.apiCallDetails))")
lines.append(" yearlyCounts: \(yearlyCountsString(type.yearlyCounts))")
if type.recordCount == 0 {
let zeroReason = type.quality == "complete" ? "complete query with zero records" : "\(type.authorizationStatus) authorization/query state"
lines.append(" zeroCountContext: \(zeroReason)")
}
if type.apiCallDetails.contains(where: { $0.status == .timeout }) {
lines.append(" interpretation: timeout policy failure, not HealthKit denial")
lines.append(" action: Retry with extended timeout")
}
lines.append(" apiCalls:")
for queryType in ["record_import", "earliest_sample", "latest_sample"] {
if let call = type.apiCallDetails.first(where: { $0.queryType == queryType }) {
lines.append(" \(queryType):")
lines.append(" status: \(call.statusDescription)")
lines.append(" elapsed: \(String(format: "%.2f", call.elapsedSeconds))s")
if mode == .full || call.status != .complete {
lines.append(" result: \(callResultLabel(call))")
}
if shouldPrintCallErrorFields(call) {
if let failureKind = call.failureKind, !failureKind.isEmpty, failureKind != "none" {
lines.append(" failureKind: \(failureKind)")
}
if let cancellationReason = call.cancellationReason, !cancellationReason.isEmpty, cancellationReason != "none" {
lines.append(" cancellationReason: \(cancellationReason)")
}
if let errorDomain = call.errorDomain, !errorDomain.isEmpty, errorDomain != "none" {
lines.append(" errorDomain: \(errorDomain)")
}
if let errorCode = call.errorCode, !errorCode.isEmpty, errorCode != "none" {
lines.append(" errorCode: \(errorCode)")
}
if let errorMessage = call.errorDescription, !errorMessage.isEmpty, errorMessage != "none" {
lines.append(" errorMessage: \(errorMessage)")
}
}
} else {
lines.append(" \(queryType):")
lines.append(" status: unknown")
lines.append(" elapsed: 0.00s")
if mode == .full {
lines.append(" result: unknown")
lines.append(" failureKind: not_run")
}
}
}
}
lines.append("")
lines.append("DEVICE/APP CONTEXT")
lines.append("OS: \(UIDevice.current.systemVersion)")
lines.append("App Version: \(Bundle.main.appVersion)")
lines.append("Build Fingerprint: \(Bundle.main.buildFingerprint)")
lines.append("Source Commit: \(Bundle.main.sourceCommit)")
lines.append("Source Dirty: \(Bundle.main.sourceDirty)")
lines.append("")
lines.append("END HEALTHPROBE REPORT")
return lines.joined(separator: "\n")
}
private func plainTextStatus(for status: SnapshotFetchProgress.TypeProgress.FetchStatus) -> String {
switch status {
case .pending: return "Pending"
case .fetching: return "Fetching"
case .complete: return "Complete"
case .failed(let message): return message.isEmpty ? "Failed" : "Failed (\(message))"
}
}
private func fetchProgressSummary(_ progress: SnapshotFetchProgress) -> String {
if progress.failedCount > 0 {
return "\(progress.completedCount)/\(progress.totalTypeCount) fetched - \(progress.failedCount) failed"
}
return "\(progress.completedCount)/\(progress.totalTypeCount) fetched"
}
private func colorForStatus(_ status: SnapshotFetchProgress.TypeProgress.FetchStatus) -> Color {
switch status {
case .pending: return .gray
case .fetching: return .blue
case .complete: return .healthyGreen
case .failed(let reason):
return reason == "Not authorized" ? .warningAmber : .criticalRed
}
}
private func rowBackground(for status: SnapshotFetchProgress.TypeProgress.FetchStatus) -> Color {
switch status {
case .fetching:
return .blue.opacity(0.1)
default:
return Color(.systemGray6)
}
}
private func rowBorder(for status: SnapshotFetchProgress.TypeProgress.FetchStatus) -> Color {
switch status {
case .fetching:
return .blue.opacity(0.3)
default:
return .clear
}
}
private var shouldShowFetchProgress: Bool {
switch viewModel.snapshotProgress {
case .fetching, .complete, .incomplete, .requiresResolution:
return viewModel.fetchProgress != nil
case .idle, .processing:
return false
}
}
private var shouldShowFetchReport: Bool {
switch viewModel.snapshotProgress {
case .complete, .incomplete, .requiresResolution:
return true
case .idle, .fetching, .processing:
return false
}
}
private func fetchReportView(_ progress: SnapshotFetchProgress) -> some View {
let isIncomplete = viewModel.snapshotProgress == .incomplete
let requiresResolution = viewModel.snapshotProgress == .requiresResolution
let failed = isIncomplete ? failedTypesList(in: progress) : []
let noteItems = isIncomplete ? remediationNoteItems(progress: progress) : []
let remediationTitle = hasAuthorizationFailures(progress) ? "WHAT TO DO" : "NOTES"
return VStack(alignment: .leading, spacing: 20) {
reportSummarySection(progress)
if !failed.isEmpty { reportIssuesSection(failed) }
if requiresResolution {
ambiguousDisappearanceSection()
reportRemediationSection(ambiguousDisappearanceNotes(), title: "NOTES")
reportRemediationSection(ambiguousDisappearanceActions(), title: "WHAT TO DO")
}
if !noteItems.isEmpty { reportRemediationSection(noteItems, title: remediationTitle) }
if isIncomplete, hasTimedOutMetrics(progress) { reportDecisionOverviewSection() }
if isIncomplete { snapshotResultActionsSection }
if requiresResolution { ambiguousResolutionActionsSection }
}
}
private func reportSummarySection(_ progress: SnapshotFetchProgress) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("SUMMARY")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
let isComplete = viewModel.snapshotProgress == .complete
let requiresResolution = viewModel.snapshotProgress == .requiresResolution
HStack(spacing: 10) {
Image(systemName: isComplete ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
.font(.title3)
.foregroundStyle(isComplete ? Color.healthyGreen : Color.warningAmber)
VStack(alignment: .leading, spacing: 2) {
Text(isComplete ? "Snapshot created successfully" : (requiresResolution ? "Snapshot needs review" : "Partial snapshot"))
.font(.subheadline.weight(.semibold))
Text(isComplete ? "All monitored metrics loaded" : (requiresResolution ? "\(viewModel.ambiguousDisappearedMetrics.count) metric\(viewModel.ambiguousDisappearedMetrics.count == 1 ? "" : "s") need review" : "\(progress.failedCount) metric\(progress.failedCount == 1 ? "" : "s") failed"))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(isComplete ? Color.healthyGreen.opacity(0.08) : Color.warningAmber.opacity(0.08))
.cornerRadius(8)
VStack(spacing: 0) {
ReportRow(label: "Types processed", value: "\(progress.types.count)/\(progress.totalTypeCount)")
Rectangle().fill(Color(.separator)).frame(height: 0.5).padding(.leading, 16)
ReportRow(label: "Successful", value: "\(progress.completedCount)", valueColor: .healthyGreen)
if progress.failedCount > 0 {
Rectangle().fill(Color(.separator)).frame(height: 0.5).padding(.leading, 16)
ReportRow(label: "Failed", value: "\(progress.failedCount)", valueColor: .criticalRed)
}
Rectangle().fill(Color(.separator)).frame(height: 0.5).padding(.leading, 16)
ReportRow(label: "Records fetched", value: "\(progress.totalRecords)")
Rectangle().fill(Color(.separator)).frame(height: 0.5).padding(.leading, 16)
ReportRow(
label: "Quality",
value: qualityLabel(progress: progress),
valueColor: qualityColor(progress: progress)
)
if let secs = viewModel.fetchDurationSeconds {
Rectangle().fill(Color(.separator)).frame(height: 0.5).padding(.leading, 16)
ReportRow(label: "Duration", value: formatDuration(secs))
}
Rectangle().fill(Color(.separator)).frame(height: 0.5).padding(.leading, 16)
ReportRow(label: "Trigger", value: viewModel.completedSnapshotTriggerReason ?? "manual")
Rectangle().fill(Color(.separator)).frame(height: 0.5).padding(.leading, 16)
ReportActionRow(label: "Diagnostics", value: "View", systemImage: "doc.text.magnifyingglass") {
diagnosticReport = DiagnosticReport(text: buildDiagnosticText(progress, mode: .full))
}
}
.background(Color(.systemGray6))
.cornerRadius(10)
}
}
private func ambiguousDisappearanceSection() -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("ISSUES")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text("\(viewModel.ambiguousDisappearedMetrics.count) metric\(viewModel.ambiguousDisappearedMetrics.count == 1 ? "" : "s") returned zero samples")
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.warningAmber)
VStack(spacing: 4) {
ForEach(viewModel.ambiguousDisappearedMetrics) { metric in
HStack(spacing: 8) {
Image(systemName: "questionmark.circle.fill")
.foregroundStyle(Color.warningAmber)
.font(.caption)
.frame(width: 14)
Text(metric.displayName)
.font(.subheadline.weight(.semibold))
Spacer()
Text("all values missing")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Color(.systemGray6))
.cornerRadius(8)
}
}
}
}
private func reportIssuesSection(_ failed: [SnapshotFetchProgress.TypeProgress]) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("ISSUES")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Text("\(failed.count) metric\(failed.count == 1 ? "" : "s") failed")
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color.criticalRed)
VStack(spacing: 4) {
ForEach(failed) { type in
let reason = failureReason(type)
let isExpanded = expandedIssueIDs.contains(type.id)
VStack(alignment: .leading, spacing: 0) {
// Collapsed row (always visible)
Button {
withAnimation(.easeInOut(duration: 0.2)) {
if isExpanded {
expandedIssueIDs.remove(type.id)
} else {
expandedIssueIDs.insert(type.id)
}
}
} label: {
HStack(spacing: 8) {
Image(systemName: reason == "Not authorized"
? "exclamationmark.triangle.fill" : "xmark.circle.fill")
.foregroundStyle(reason == "Not authorized"
? Color.warningAmber : Color.criticalRed)
.font(.caption)
.frame(width: 14)
Text(type.displayName)
.font(.subheadline.weight(.semibold))
Text("— \(reason)")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption2.weight(.semibold))
.foregroundStyle(.tertiary)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
// Expanded detail (on tap)
if isExpanded {
Divider().padding(.horizontal, 12)
VStack(alignment: .leading, spacing: 4) {
Text("Reason: \(reason)").font(.caption).foregroundStyle(.secondary)
Text("Impact: \(failureImpact(reason))").font(.caption).foregroundStyle(.secondary)
if reason == "Timeout" {
Text("Configured: \(formatDuration(type.timeoutConfiguredSeconds)) \(type.timeoutMode)")
.font(.caption).foregroundStyle(.secondary)
if type.learnedTimeout > 0 {
Text("Observed needed: ~\(formatDuration(type.learnedTimeout))")
.font(.caption).foregroundStyle(.secondary)
}
Text("Suggested retry: \(type.suggestedRetryTimeout > 0 ? formatDuration(type.suggestedRetryTimeout) : formatDuration(LocalMetricTimeoutProfile.maximumTimeout))")
.font(.caption).foregroundStyle(.secondary)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.background(Color(.systemGray6))
.cornerRadius(8)
}
}
}
}
private func reportDecisionOverviewSection() -> some View {
reportRemediationSection(
[
"Retry the full snapshot with extended timeout.",
"Save the partial snapshot only if this incomplete report is useful.",
"Discard snapshot and start a new one.",
"Disable this metric in Settings if it consistently times out."
],
title: "WHAT YOU CAN DO"
)
}
private func reportRemediationSection(_ items: [String], title: String = "WHAT TO DO") -> some View {
let isNotes = title == "NOTES"
return VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(isNotes ? .caption : .caption.weight(.semibold))
.foregroundStyle(isNotes ? Color(.tertiaryLabel) : .secondary)
VStack(alignment: .leading, spacing: isNotes ? 4 : 8) {
ForEach(Array(items.enumerated()), id: \.offset) { index, item in
HStack(alignment: .top, spacing: 8) {
if isNotes {
Text("·")
.font(.caption2)
.foregroundStyle(Color(.tertiaryLabel))
.frame(width: 12, alignment: .trailing)
} else {
Text("\(index + 1).")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 16, alignment: .trailing)
}
Text(item)
.font(isNotes ? .caption2 : .caption)
.foregroundStyle(isNotes ? Color(.secondaryLabel) : .primary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.systemGray6))
.cornerRadius(8)
}
}
private func progressFeedView(_ progress: SnapshotFetchProgress) -> some View {
VStack(spacing: 12) {
HStack {
Text(fetchProgressSummary(progress))
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Spacer()
Text("\(progress.totalRecords) records")
.font(.caption)
.foregroundStyle(.secondary)
}
ScrollView {
VStack(spacing: 8) {
ForEach(progress.visibleTypes) { type in
HStack(spacing: 12) {
if case .fetching = type.status {
ProgressView()
.scaleEffect(0.8, anchor: .center)
.frame(width: 20)
} else {
Image(systemName: type.status.icon)
.font(.subheadline.weight(.semibold))
.frame(width: 20)
.foregroundStyle(colorForStatus(type.status))
}
VStack(alignment: .leading, spacing: 2) {
Text(type.displayName)
.font(.caption)
.lineLimit(1)
if !type.blockProgress.isEmpty {
Text(type.blockProgress)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
fetchMetricStatsColumn(type, progress: progress)
}
.padding(.horizontal, 10)
.padding(.vertical, 9)
.background(rowBackground(for: type.status))
.cornerRadius(6)
.overlay {
RoundedRectangle(cornerRadius: 6)
.stroke(rowBorder(for: type.status), lineWidth: 1)
}
}
}
.frame(maxWidth: .infinity)
}
}
.frame(maxHeight: .infinity)
}
private var pendingReportView: some View {
VStack(spacing: 12) {
Image(systemName: "doc.text.magnifyingglass")
.font(.system(size: 36))
.foregroundStyle(.secondary)
Text("Report will appear when the snapshot finishes.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var snapshotSheetTabPicker: some View {
HStack(spacing: 2) {
ForEach(SnapshotSheetTab.allCases) { tab in
let isSelected = snapshotSheetTab == tab
let isDisabled = tab == .report && !shouldShowFetchReport
Button {
snapshotSheetTab = tab
} label: {
Text(tab.rawValue)
.font(.caption.weight(.semibold))
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.foregroundStyle(isDisabled ? .tertiary : (isSelected ? .primary : .secondary))
.background(isSelected ? Color(.systemBackground) : Color.clear)
.cornerRadius(6)
}
.buttonStyle(.plain)
.disabled(isDisabled)
.accessibilityLabel(tab.rawValue)
}
}
.padding(2)
.background(Color(.systemGray5))
.cornerRadius(8)
}
private var sheetOperationHeader: some View {
VStack(alignment: .center, spacing: 4) {
HStack(spacing: 8) {
Image(systemName: "camera.viewfinder")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(Color(.secondaryLabel))
.frame(width: 22)
HStack(spacing: 4) {
Text("Health data snapshot").font(.headline)
if viewModel.snapshotProgress == .complete {
Image(systemName: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(Color.healthyGreen)
} else if viewModel.snapshotProgress == .incomplete || viewModel.snapshotProgress == .requiresResolution {
Image(systemName: "exclamationmark.circle.fill")
.font(.caption)
.foregroundStyle(Color.warningAmber)
}
}
}
if let progress = viewModel.fetchProgress {
Text("\(progress.totalTypeCount) metrics")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity)
}
@ViewBuilder
private func fetchMetricStatsColumn(
_ type: SnapshotFetchProgress.TypeProgress,
progress: SnapshotFetchProgress
) -> some View {
if case .fetching = type.status {
TimelineView(.periodic(from: progress.startedAt, by: 1)) { context in
let elapsed = max(type.blockElapsedSeconds, progress.elapsedSeconds(at: context.date))
let rate = displayedRate(for: type, elapsedSeconds: elapsed)
fetchMetricStatsContent(type, elapsedSeconds: elapsed, samplesPerSecond: rate)
}
} else if type.recordCount > 0 || type.blockElapsedSeconds > 0 || type.blockSamplesPerSecond > 0 {
fetchMetricStatsContent(
type,
elapsedSeconds: type.blockElapsedSeconds,
samplesPerSecond: displayedRate(for: type, elapsedSeconds: type.blockElapsedSeconds)
)
} else if case .failed(let reason) = type.status, reason == "Not authorized" {
Text("Unavailable")
.font(.caption2)
.foregroundStyle(Color.warningAmber)
.lineLimit(1)
}
}
@ViewBuilder
private func fetchMetricStatsContent(
_ type: SnapshotFetchProgress.TypeProgress,
elapsedSeconds: TimeInterval,
samplesPerSecond: Double
) -> some View {
if type.recordCount > 0 || elapsedSeconds > 0 || samplesPerSecond > 0 {
VStack(alignment: .trailing, spacing: 2) {
if type.recordCount > 0 {
Text("\(type.recordCount) records")
}
if elapsedSeconds > 0 {
Text(formatDuration(elapsedSeconds))
}
if samplesPerSecond > 0 {
Text("\(formatRate(samplesPerSecond)) rec/s")
}
}
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.frame(minWidth: 82, alignment: .trailing)
}
}
private func displayedRate(
for type: SnapshotFetchProgress.TypeProgress,
elapsedSeconds: TimeInterval
) -> Double {
guard type.recordCount > 0, elapsedSeconds > 0 else { return 0 }
return Double(type.recordCount) / elapsedSeconds
}
private func formatRate(_ value: Double) -> String {
if value >= 1_000 {
return value.formatted(.number.precision(.fractionLength(0)).grouping(.automatic))
}
if value >= 100 {
return value.formatted(.number.precision(.fractionLength(0)))
}
if value >= 10 {
return value.formatted(.number.precision(.fractionLength(1)))
}
return value.formatted(.number.precision(.fractionLength(2)))
}
// MARK: - Progress Sheet
private var progressSheet: some View {
VStack(spacing: 16) {
sheetOperationHeader
.frame(maxWidth: .infinity, alignment: .center)
if let progress = viewModel.fetchProgress, shouldShowFetchProgress {
VStack(spacing: 12) {
snapshotSheetTabPicker
Group {
switch snapshotSheetTab {
case .progress:
progressFeedView(progress)
case .report:
if shouldShowFetchReport {
ScrollView {
fetchReportView(progress)
}
} else {
pendingReportView
}
}
}
.frame(maxHeight: .infinity)
}
.frame(maxHeight: .infinity)
}
if viewModel.snapshotProgress == .idle, let errorMsg = viewModel.snapshotError, !errorMsg.isEmpty {
VStack(spacing: 12) {
HStack {
Text("Error details")
.font(.caption.weight(.semibold))
Spacer()
Button {
UIPasteboard.general.string = errorMsg
} label: {
Image(systemName: "doc.on.doc")
.font(.caption)
}
.accessibilityLabel("Copy error details to clipboard")
}
.foregroundStyle(.secondary)
Text(errorMsg)
.font(.caption)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color(.systemGray6))
.cornerRadius(8)
}
}
}
.padding(24)
.sheet(item: $diagnosticReport) { report in
DiagnosticReportSheet(reportText: report.text)
}
.presentationDetents(sheetDetents)
.presentationDragIndicator(.visible)
.interactiveDismissDisabled(viewModel.snapshotProgress == .fetching || viewModel.snapshotProgress == .requiresResolution || viewModel.isRequestingAuth)
.onAppear {
snapshotSheetTab = .progress
}
.onChange(of: viewModel.snapshotProgress) { _, newProgress in
if newProgress == .incomplete || newProgress == .requiresResolution {
snapshotSheetTab = .report
persistCompletedDiagnosticReportIfNeeded()
} else if newProgress == .fetching {
snapshotSheetTab = .progress
} else if newProgress == .complete {
persistCompletedDiagnosticReportIfNeeded()
}
}
}
private func persistCompletedDiagnosticReportIfNeeded() {
guard let progress = viewModel.fetchProgress,
let snapshotID = viewModel.completedSnapshotID,
!savedDiagnosticSnapshotIDs.contains(snapshotID) else {
return
}
let text = buildDiagnosticText(progress, mode: .full)
do {
try DiagnosticReportStore.persist(
text: text,
snapshotID: snapshotID,
observationID: viewModel.completedArchiveObservationID,
operationID: viewModel.operationID
)
savedDiagnosticSnapshotIDs.insert(snapshotID)
} catch {
print("Failed to persist diagnostic report: \(error.localizedDescription)")
}
}
@ViewBuilder
private var snapshotResultActionsSection: some View {
let hasAuthorizationIssue = hasAuthorizationFailures(viewModel.fetchProgress)
let hasTimeoutIssue = hasTimedOutMetrics(viewModel.fetchProgress)
HStack(spacing: 12) {
if hasAuthorizationIssue && !viewModel.canRetryWithPermissions {
Button {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
} label: {
Text("Open Settings").frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isCreatingSnapshot || viewModel.isRequestingAuth)
.accessibilityLabel("Open app settings to restore HealthKit read access")
} else if viewModel.canRetryWithPermissions || hasTimeoutIssue {
Button {
Task {
if viewModel.canRetryWithPermissions {
await viewModel.retryWithPermissions(
selectedTypeIDs: appSettings.selectedTypeIDs,
adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled
)
} else {
await viewModel.retryFailedMetricsWithExtendedTimeout(
selectedTypeIDs: appSettings.selectedTypeIDs,
adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled
)
}
}
} label: {
Text("Retry").frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isCreatingSnapshot || viewModel.isRequestingAuth)
}
Button {
Task { await viewModel.savePartialSnapshot() }
} label: {
Text("Save Partial").frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(viewModel.isCreatingSnapshot)
.accessibilityLabel("Save partial snapshot")
Button {
Task { await viewModel.discardSnapshot() }
} label: {
Text("Discard").foregroundStyle(Color.criticalRed).frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(viewModel.isCreatingSnapshot)
.accessibilityLabel("Discard snapshot")
}
}
private var ambiguousResolutionActionsSection: some View {
VStack(spacing: 10) {
Button {
Task { await viewModel.treatAmbiguousMetricsAsUnauthorized() }
} label: {
Label("Treat as Unauthorized", systemImage: "lock.slash")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isCreatingSnapshot)
.accessibilityLabel("Treat missing metrics as unauthorized")
Button {
Task { await viewModel.treatAmbiguousMetricsAsDeleted() }
} label: {
Label("Treat as Missing", systemImage: "minus.circle")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(viewModel.isCreatingSnapshot)
.accessibilityLabel("Treat missing metrics as absent from the current Health store")
Button {
Task { await viewModel.discardSnapshot() }
} label: {
Label("Cancel Save", systemImage: "xmark")
.foregroundStyle(Color.criticalRed)
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.disabled(viewModel.isCreatingSnapshot)
.accessibilityLabel("Cancel saving this snapshot")
}
}
private var sheetDetents: Set<PresentationDetent> {
[.large]
}
// MARK: - Sections
private var statusSection: some View {
Section("Status") {
if let latestArchiveObservation {
InfoRow(label: "Last Snapshot") {
Text(latestArchiveObservation.observedAt, style: .relative)
.foregroundStyle(.secondary)
}
InfoRow(label: "Records") {
Text("\(latestArchiveObservation.visibleRecordCount)")
.foregroundStyle(.secondary)
}
InfoRow(label: "Types") {
Text("\(latestArchiveObservation.trackedTypeCount)")
.foregroundStyle(.secondary)
}
} else if viewModel.isLoadingArchiveStatus {
Label("Loading archive observations", systemImage: "clock.arrow.circlepath")
.foregroundStyle(.secondary)
} else {
Label("No archive observations yet", systemImage: "camera.viewfinder")
.foregroundStyle(.secondary)
}
if latestArchiveObservation != nil {
InfoRow(label: "Device") {
Text(currentDeviceDisplayName)
.foregroundStyle(.secondary)
}
}
InfoRow(label: "Monitored Types") {
Text("\(appSettings.selectedTypeIDs.count)")
.foregroundStyle(.secondary)
}
if let archiveObservation = latestArchiveObservation {
InfoRow(label: "Observation ID") {
Text("\(archiveObservation.observationID)")
.foregroundStyle(.secondary)
}
}
if let archiveCacheError = viewModel.archiveCacheError {
Label("Archive cache error", systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(Color.criticalRed)
Text(archiveCacheError)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(2)
} else if let archiveHealthStatus = viewModel.archiveHealthStatus {
InfoRow(label: "Archive Cache") {
Text(archiveHealthStatus.lastIntegrityStatus == "ok" ? "OK" : archiveHealthStatus.lastIntegrityStatus)
.foregroundStyle(archiveHealthStatus.lastIntegrityStatus == "ok" ? Color.healthyGreen : Color.warningAmber)
}
InfoRow(label: "Cache Schema") {
Text("\(archiveHealthStatus.cacheSchemaVersion)")
.foregroundStyle(.secondary)
}
} else if !viewModel.isLoadingArchiveStatus {
Label("Archive cache not built", systemImage: "externaldrive.badge.questionmark")
.font(.caption)
.foregroundStyle(.secondary)
}
if let archiveDelta = latestArchiveChangeCount {
InfoRow(label: "Changes vs Previous") {
Text(archiveDelta == 0 ? "None" : "\(archiveDelta) records")
.foregroundStyle(archiveDelta == 0 ? Color.healthyGreen : Color.warningAmber)
}
}
}
}
private var actionsSection: some View {
Section("Actions") {
Button {
Task { await viewModel.requestAuthorization() }
} label: {
HStack {
Label("Request Health Access", systemImage: "heart.text.square")
Spacer()
if viewModel.isRequestingAuth { ProgressView() }
}
}
.disabled(viewModel.isRequestingAuth)
.accessibilityLabel("Request HealthKit read authorization")
Button {
Task {
await viewModel.createSnapshot(
selectedTypeIDs: appSettings.selectedTypeIDs,
adaptiveTimeoutsEnabled: appSettings.adaptiveTimeoutsEnabled
)
}
} label: {
Label("Create Snapshot", systemImage: "camera.viewfinder")
}
.disabled(viewModel.isCreatingSnapshot)
.accessibilityLabel("Create a new data snapshot")
}
}
}
private enum SnapshotSheetTab: String, CaseIterable, Identifiable {
case progress = "Progress"
case report = "Result"
var id: Self { self }
}
private struct DiagnosticReport: Identifiable {
let id = UUID()
let text: String
}
// Avoids LabeledContent's TableRowContent ambiguity in List/Section contexts.
private struct InfoRow<Content: View>: View {
let label: String
@ViewBuilder let content: () -> Content
var body: some View {
HStack {
Text(label)
Spacer()
content()
}
}
}
private struct ReportActionRow: View {
let label: String
let value: String
let systemImage: String
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 10) {
Label(label, systemImage: systemImage)
.font(.subheadline)
.labelStyle(.titleAndIcon)
Spacer()
HStack(spacing: 6) {
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary)
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.tertiary)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("\(label), \(value)")
}
}
private struct ReportRow: View {
let label: String
let value: String
var valueColor: Color = .primary
var body: some View {
HStack {
Text(label)
.font(.subheadline)
Spacer()
Text(value)
.font(.subheadline.weight(.semibold))
.foregroundStyle(valueColor)
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
}
extension Bundle {
var appVersion: String {
guard let version = infoDictionary?["CFBundleShortVersionString"] as? String else {
return "unknown"
}
guard let build = infoDictionary?["CFBundleVersion"] as? String else {
return version
}
return "\(version)(\(build))"
}
var sourceCommit: String {
let value = infoDictionary?["HPSourceCommit"] as? String
return value?.isEmpty == false ? value! : "unknown"
}
var sourceDirty: String {
let value = infoDictionary?["HPSourceDirty"] as? String
return value?.isEmpty == false ? value! : "unknown"
}
var buildFingerprint: String {
guard let url = executableURL,
let values = try? url.resourceValues(forKeys: [.contentModificationDateKey, .fileSizeKey]) else {
return "unknown"
}
let modified = values.contentModificationDate?.timeIntervalSince1970 ?? 0
let size = values.fileSize ?? 0
return "\(appVersion)-\(Int(modified))-\(size)"
}
}
#Preview {
DashboardView()
.environment(AppSettings())
}