1 contributor
import Foundation
import SwiftData
enum LegacySwiftDataBridgeError: LocalizedError {
case notConfigured
var errorDescription: String? {
switch self {
case .notConfigured:
return "Legacy SwiftData bridge is not configured."
}
}
}
enum LegacySwiftDataBridge {
private static var container: ModelContainer?
private static var context: ModelContext?
static func configure(container: ModelContainer) {
self.container = container
context = ModelContext(container)
}
static func modelContext() throws -> ModelContext {
if let context { return context }
guard let container else {
throw LegacySwiftDataBridgeError.notConfigured
}
let context = ModelContext(container)
self.context = context
return context
}
}
@Observable
final class DashboardViewModel {
var isRequestingAuth = false
var isCreatingSnapshot = false
var authError: String?
var snapshotError: String?
var snapshotProgress: SnapshotProgress = .idle
var snapshotProgressMessage: String = ""
var snapshotProgressDetail: String = ""
var showProgressSheet = false
var canRetryWithPermissions = false
var permissionsAlreadyRequested = false
var fetchProgress: SnapshotFetchProgress?
var fetchDurationSeconds: TimeInterval? = nil
var fetchStartDate: Date? = nil
var operationID: UUID? = nil
var completedSnapshotID: UUID? = nil
var completedSnapshotTimestamp: Date? = nil
var completedSnapshotDeviceID: String? = nil
var completedSnapshotTriggerReason: String? = nil
var completedSnapshotRetryOfSnapshotID: UUID? = nil
var ambiguousDisappearedMetrics: [AmbiguousDisappearedMetric] = []
var latestArchiveObservation: CachedArchiveObservationRow?
var archiveObservationRows: [CachedArchiveObservationRow] = []
var archiveHealthStatus: CachedArchiveHealthStatus?
var archiveCacheError: String?
private let healthKit = HealthKitService.shared
private var pendingPartialSnapshot: HealthSnapshot?
private var pendingAmbiguousSnapshot: HealthSnapshot?
func requestAuthorization() async {
isRequestingAuth = true
authError = nil
defer { isRequestingAuth = false }
do {
try await healthKit.requestAuthorization()
} catch {
authError = error.localizedDescription
}
}
func createSnapshot(
selectedTypeIDs: Set<String>,
adaptiveTimeoutsEnabled: Bool,
triggerReason: String = "manual",
retryOfSnapshotID: UUID? = nil,
timeoutMultiplier: Double = 1
) async {
guard !selectedTypeIDs.isEmpty else {
snapshotError = "No health data types selected. Please select types to monitor in Settings."
return
}
let context: ModelContext
do {
context = try LegacySwiftDataBridge.modelContext()
} catch {
snapshotError = "Failed to access legacy snapshot cache: \(error.localizedDescription)"
snapshotProgress = .idle
showProgressSheet = true
return
}
isCreatingSnapshot = true
snapshotError = nil
snapshotProgress = .fetching
showProgressSheet = true
fetchStartDate = Date()
fetchDurationSeconds = nil
operationID = UUID()
completedSnapshotID = nil
completedSnapshotTimestamp = nil
completedSnapshotDeviceID = nil
completedSnapshotTriggerReason = nil
completedSnapshotRetryOfSnapshotID = nil
pendingPartialSnapshot = nil
pendingAmbiguousSnapshot = nil
ambiguousDisappearedMetrics = []
snapshotProgressMessage = ""
snapshotProgressDetail = ""
canRetryWithPermissions = false
permissionsAlreadyRequested = false
let monitoredTypes = HealthKitService.allTypes
.filter { selectedTypeIDs.contains($0.id) }
.sortedByFetchDisplayNameDescending()
.map { (id: $0.id, displayName: $0.displayName) }
fetchProgress = SnapshotFetchProgress(monitoredTypes: monitoredTypes)
fetchProgress?.updateConfiguration(
perTypeTimeoutSeconds: HealthKitService.defaultInitialTimeoutSeconds,
maxConcurrentTypeFetches: HealthKitService.maxConcurrentTypeFetches,
adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled
)
defer { isCreatingSnapshot = false }
do {
let concurrentBatches = ceil(Double(selectedTypeIDs.count) / Double(HealthKitService.maxConcurrentTypeFetches))
let historyImportTimeout = concurrentBatches * HealthKitService.fullHistoryImportTimeoutSeconds + 30
let learnedMetricTimeout = Double(selectedTypeIDs.count) * HealthKitService.maximumTimeoutSeconds + 30
let operationTimeout = max(120, historyImportTimeout, learnedMetricTimeout)
let snapshot = try await withTimeout(seconds: operationTimeout) {
try await self.healthKit.createSnapshot(
in: context,
selectedTypeIDs: selectedTypeIDs,
adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled,
triggerReason: triggerReason,
retryOfSnapshotID: retryOfSnapshotID,
timeoutMultiplier: timeoutMultiplier,
reviewAmbiguousCompleteDisappearedTypes: triggerReason == "manual",
progress: self.fetchProgress
)
}
fetchDurationSeconds = fetchStartDate.map { Date().timeIntervalSince($0) }
completedSnapshotID = snapshot.id
completedSnapshotTimestamp = snapshot.timestamp
completedSnapshotDeviceID = snapshot.deviceID
completedSnapshotTriggerReason = snapshot.triggerReason
completedSnapshotRetryOfSnapshotID = snapshot.retryOfSnapshotID
fetchProgress?.updateChainContext(
previousSnapshotID: snapshot.previousSnapshotID,
isChainStart: snapshot.isChainStart,
snapshotChecksum: HashService.snapshotChecksum(typeCounts: snapshot.typeCounts ?? []),
monitoredTypeSetHash: snapshot.monitoredTypeSetHash,
monitoredRegistryVersion: snapshot.monitoredRegistryVersion
)
reflectUnavailableMetricsInProgress(snapshot: snapshot)
if snapshot.snapshotQuality != SnapshotQuality.complete {
pendingPartialSnapshot = snapshot
let typeCounts = snapshot.typeCounts ?? []
let unauthorizedCount = typeCounts.filter { $0.quality == SnapshotQuality.unauthorized }.count
let failedCount = typeCounts.filter { $0.quality == SnapshotQuality.failed }.count
let failedTypes = typeCounts.filter { $0.quality == SnapshotQuality.failed }
let unauthorizedTypes = typeCounts.filter { $0.quality == SnapshotQuality.unauthorized }
snapshotProgress = .incomplete
snapshotProgressMessage = "Incomplete snapshot"
permissionsAlreadyRequested = healthKit.hasRequestedPermissionsBefore
if shouldAutoSaveKnownUnauthorizedPartial(snapshot: snapshot, failedCount: failedCount, context: context) {
snapshotProgressMessage = "Known unavailable metrics"
snapshotProgressDetail = "Snapshot was auto-saved as partial because unavailable metrics were already confirmed in the previous snapshot."
await savePartialSnapshot(keepSheetOpenForReview: true)
return
}
if unauthorizedCount > 0 {
snapshotProgressMessage = "Missing permissions (\(unauthorizedCount) type\(unauthorizedCount == 1 ? "" : "s"))"
let typesList = unauthorizedTypes.map { $0.displayName }.joined(separator: ", ")
if permissionsAlreadyRequested {
snapshotProgressDetail = """
Not authorized: \(typesList)
You need to grant access to health data. Open Settings and enable HealthKit access for the types you want to monitor.
"""
canRetryWithPermissions = false
} else {
snapshotProgressDetail = "Some health data types are not authorized. You can request permissions and try again."
canRetryWithPermissions = true
}
} else if failedCount > 0 {
snapshotProgressMessage = "Data load failed (\(failedCount) type\(failedCount == 1 ? "" : "s"))"
let typesList = failedTypes.map { $0.displayName }.joined(separator: ", ")
snapshotProgressDetail = """
Failed to load: \(typesList)
This may indicate a temporary issue. Try again or check HealthKit availability.
"""
canRetryWithPermissions = false
} else {
snapshotProgressDetail = "Snapshot created with quality: \(snapshot.snapshotQuality.rawValue). Some data may be incomplete."
canRetryWithPermissions = false
}
showProgressSheet = true
return
}
let ambiguousMetrics = findAmbiguousDisappearedMetrics(snapshot: snapshot, context: context)
if !ambiguousMetrics.isEmpty {
pendingAmbiguousSnapshot = snapshot
ambiguousDisappearedMetrics = ambiguousMetrics
snapshotProgress = .requiresResolution
snapshotProgressMessage = "Metric access needs review"
snapshotProgressDetail = "One or more metrics returned zero samples after previously having data. Choose how HealthProbe should classify this before saving the snapshot."
showProgressSheet = true
return
}
let snapshotID = snapshot.id
var descriptor = FetchDescriptor<HealthSnapshot>(
predicate: #Predicate<HealthSnapshot> { $0.id == snapshotID }
)
descriptor.fetchLimit = 1
let exists = try !context.fetch(descriptor).isEmpty
if !exists {
throw SnapshotCreationError.snapshotNotSaved
}
snapshotProgress = .complete
refreshArchiveCache()
} catch is CancellationError {
snapshotError = "Snapshot creation exceeded the operation timeout. Individual metric timeouts are adaptive; retry failed metrics with an extended timeout when available."
snapshotProgress = .idle
showProgressSheet = true
} catch let error as SnapshotCreationError {
snapshotError = error.message
snapshotProgress = .idle
showProgressSheet = true
} catch {
snapshotError = "Failed to create snapshot: \(error.localizedDescription)"
snapshotProgress = .idle
showProgressSheet = true
}
}
func treatAmbiguousMetricsAsUnauthorized() async {
guard let snapshot = pendingAmbiguousSnapshot else { return }
let ambiguousIDs = Set(ambiguousDisappearedMetrics.map(\.id))
let typeCounts = snapshot.typeCounts ?? []
for typeCount in typeCounts where ambiguousIDs.contains(typeCount.typeIdentifier) {
typeCount.count = -1
typeCount.contentHash = ""
typeCount.earliestDate = nil
typeCount.latestDate = nil
typeCount.quality = .unauthorized
typeCount.yearlyCounts = []
}
snapshot.snapshotQuality = healthKit.deriveSnapshotQuality(from: typeCounts)
do {
let context = try LegacySwiftDataBridge.modelContext()
let saved = try await healthKit.savePartialSnapshot(snapshot, in: context)
finishSavedReviewedSnapshot(saved)
} catch {
snapshotError = "Failed to save reviewed snapshot: \(error.localizedDescription)"
showProgressSheet = true
}
}
func treatAmbiguousMetricsAsDeleted() async {
guard let snapshot = pendingAmbiguousSnapshot else { return }
do {
let context = try LegacySwiftDataBridge.modelContext()
let saved = try await healthKit.saveReviewedCompleteSnapshot(snapshot, in: context)
finishSavedReviewedSnapshot(saved)
} catch {
snapshotError = "Failed to save reviewed snapshot: \(error.localizedDescription)"
showProgressSheet = true
}
}
func retryWithPermissions(selectedTypeIDs: Set<String>, adaptiveTimeoutsEnabled: Bool) async {
isRequestingAuth = true
defer { isRequestingAuth = false }
do {
try await healthKit.requestAuthorization()
await createSnapshot(selectedTypeIDs: selectedTypeIDs, adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled)
} catch {
snapshotError = "Failed to request permissions: \(error.localizedDescription)"
snapshotProgress = .idle
showProgressSheet = false
}
}
func retryFailedMetricsWithExtendedTimeout(
selectedTypeIDs: Set<String>,
adaptiveTimeoutsEnabled: Bool
) async {
guard let progress = fetchProgress else { return }
let hasTimeout = progress.types.contains { type in
guard case .failed(let reason) = type.status else { return false }
return reason == "Timeout"
}
guard hasTimeout else { return }
await createSnapshot(
selectedTypeIDs: selectedTypeIDs,
adaptiveTimeoutsEnabled: adaptiveTimeoutsEnabled,
triggerReason: "retryFailedMetrics",
timeoutMultiplier: 2
)
}
func savePartialSnapshot(keepSheetOpenForReview: Bool = false) async {
guard let snapshot = pendingPartialSnapshot else {
if !keepSheetOpenForReview {
fetchProgress = nil
showProgressSheet = false
snapshotProgress = .idle
}
return
}
do {
let context = try LegacySwiftDataBridge.modelContext()
let saved = try await healthKit.savePartialSnapshot(snapshot, in: context)
completedSnapshotID = saved.id
completedSnapshotTimestamp = saved.timestamp
completedSnapshotDeviceID = saved.deviceID
completedSnapshotTriggerReason = saved.triggerReason
completedSnapshotRetryOfSnapshotID = saved.retryOfSnapshotID
pendingPartialSnapshot = nil
fetchProgress?.updateChainContext(
previousSnapshotID: saved.previousSnapshotID,
isChainStart: saved.isChainStart,
snapshotChecksum: HashService.snapshotChecksum(typeCounts: saved.typeCounts ?? []),
monitoredTypeSetHash: saved.monitoredTypeSetHash,
monitoredRegistryVersion: saved.monitoredRegistryVersion
)
refreshArchiveCache()
} catch {
snapshotError = "Failed to save partial snapshot: \(error.localizedDescription)"
showProgressSheet = true
return
}
if keepSheetOpenForReview {
snapshotProgress = .incomplete
showProgressSheet = true
return
}
fetchProgress = nil
showProgressSheet = false
snapshotProgress = .idle
}
func discardSnapshot() async {
pendingPartialSnapshot = nil
pendingAmbiguousSnapshot = nil
ambiguousDisappearedMetrics = []
if let snapshotID = completedSnapshotID {
do {
let context = try LegacySwiftDataBridge.modelContext()
var descriptor = FetchDescriptor<HealthSnapshot>(
predicate: #Predicate<HealthSnapshot> { $0.id == snapshotID }
)
descriptor.fetchLimit = 1
if let snapshot = try context.fetch(descriptor).first {
context.delete(snapshot)
}
} catch { }
}
completedSnapshotID = nil
fetchProgress = nil
showProgressSheet = false
snapshotProgress = .idle
}
func loadArchiveCacheStatus() {
do {
let cache = try CoreDataArchiveCacheStore()
archiveObservationRows = try cache.observationRows(limit: 2)
latestArchiveObservation = archiveObservationRows.first
archiveHealthStatus = try cache.latestArchiveHealthStatus()
archiveCacheError = nil
} catch {
archiveObservationRows = []
latestArchiveObservation = nil
archiveHealthStatus = nil
archiveCacheError = error.localizedDescription
}
}
private func withTimeout<T>(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T {
try await withThrowingTaskGroup(of: T.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
throw CancellationError()
}
let result = try await group.next()!
group.cancelAll()
return result
}
}
private func findAmbiguousDisappearedMetrics(snapshot: HealthSnapshot, context: ModelContext) -> [AmbiguousDisappearedMetric] {
guard let previousID = snapshot.previousSnapshotID else { return [] }
let descriptor = FetchDescriptor<HealthSnapshot>(
predicate: #Predicate<HealthSnapshot> { $0.id == previousID }
)
guard let previous = try? context.fetch(descriptor).first else { return [] }
let previousByType = Dictionary(
uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
)
return (snapshot.typeCounts ?? []).compactMap { current in
guard current.quality == .complete,
current.count == 0,
let previous = previousByType[current.typeIdentifier],
previous.quality == .complete,
previous.count > 0 else {
return nil
}
return AmbiguousDisappearedMetric(
id: current.typeIdentifier,
displayName: current.displayName,
previousCount: previous.count
)
}.sorted { $0.displayName < $1.displayName }
}
private func reflectUnavailableMetricsInProgress(snapshot: HealthSnapshot) {
guard let fetchProgress else { return }
for typeCount in snapshot.typeCounts ?? [] where typeCount.quality == .unauthorized {
fetchProgress.markUnavailable(typeCount.typeIdentifier)
}
}
private func shouldAutoSaveKnownUnauthorizedPartial(snapshot: HealthSnapshot, failedCount: Int, context: ModelContext) -> Bool {
guard failedCount == 0,
let previousID = snapshot.previousSnapshotID else {
return false
}
let currentUnauthorized = (snapshot.typeCounts ?? []).filter { $0.quality == .unauthorized }
guard !currentUnauthorized.isEmpty else { return false }
let descriptor = FetchDescriptor<HealthSnapshot>(
predicate: #Predicate<HealthSnapshot> { $0.id == previousID }
)
guard let previous = try? context.fetch(descriptor).first else {
return false
}
let previousByType = Dictionary(
uniqueKeysWithValues: (previous.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
)
return currentUnauthorized.allSatisfy { previousByType[$0.typeIdentifier]?.quality == .unauthorized }
}
private func finishSavedReviewedSnapshot(_ snapshot: HealthSnapshot) {
completedSnapshotID = snapshot.id
completedSnapshotTimestamp = snapshot.timestamp
completedSnapshotDeviceID = snapshot.deviceID
completedSnapshotTriggerReason = snapshot.triggerReason
completedSnapshotRetryOfSnapshotID = snapshot.retryOfSnapshotID
pendingAmbiguousSnapshot = nil
pendingPartialSnapshot = nil
ambiguousDisappearedMetrics = []
fetchProgress?.updateChainContext(
previousSnapshotID: snapshot.previousSnapshotID,
isChainStart: snapshot.isChainStart,
snapshotChecksum: HashService.snapshotChecksum(typeCounts: snapshot.typeCounts ?? []),
monitoredTypeSetHash: snapshot.monitoredTypeSetHash,
monitoredRegistryVersion: snapshot.monitoredRegistryVersion
)
fetchProgress = nil
showProgressSheet = false
snapshotProgress = .idle
refreshArchiveCache()
}
private func refreshArchiveCache() {
do {
let cache = try CoreDataArchiveCacheStore()
_ = try cache.rebuild(fromArchiveAt: SQLiteHealthArchiveStore.defaultDatabaseURL)
archiveObservationRows = try cache.observationRows(limit: 2)
latestArchiveObservation = archiveObservationRows.first
archiveHealthStatus = try cache.latestArchiveHealthStatus()
archiveCacheError = nil
} catch {
archiveObservationRows = []
latestArchiveObservation = nil
archiveCacheError = error.localizedDescription
}
}
}
struct AmbiguousDisappearedMetric: Identifiable, Equatable {
let id: String
let displayName: String
let previousCount: Int
}
enum SnapshotProgress {
case idle
case fetching
case processing
case complete
case incomplete
case requiresResolution
var message: String {
switch self {
case .idle: return ""
case .fetching: return "Fetching health data..."
case .processing: return "Processing snapshot..."
case .complete: return "Snapshot created successfully!"
case .incomplete: return "Snapshot created with issues"
case .requiresResolution: return "Snapshot needs review"
}
}
var isError: Bool {
switch self {
case .incomplete, .requiresResolution: return true
default: return false
}
}
}
enum SnapshotCreationError: Error {
case snapshotNotSaved
var message: String {
switch self {
case .snapshotNotSaved:
return "Snapshot was not saved to database. This may indicate a HealthKit permission issue or data corruption. Try requesting health access again in the Actions section."
}
}
}