1 contributor
import SwiftUI
import SwiftData
struct SnapshotsView: View {
@Environment(\.modelContext) private var modelContext
@Query(sort: \HealthSnapshot.timestamp, order: .reverse) private var allSnapshots: [HealthSnapshot]
@State private var viewModel = SnapshotsViewModel()
@State private var profileMap: [String: LocalDeviceProfile] = [:]
private var displayedSnapshots: [HealthSnapshot] {
guard let deviceID = localDeviceID else { return [] }
return allSnapshots.filter { $0.deviceID == deviceID }
}
private var hasTimelineRows: Bool {
!(viewModel.archiveRows?.isEmpty ?? true) || !displayedSnapshots.isEmpty
}
private var timelineReloadID: String {
[
String(allSnapshots.count),
allSnapshots.compactMap(\.archiveObservationID).map(String.init).joined(separator: ",")
].joined(separator: "|")
}
private var snapshotItems: [SnapshotListItem] {
let baselines = viewModel.baselines(for: displayedSnapshots)
if let archiveRows = viewModel.archiveRows {
let snapshotsByObservationID = Dictionary(uniqueKeysWithValues: displayedSnapshots.compactMap { snapshot in
snapshot.archiveObservationID.map { ($0, snapshot) }
})
return archiveRows.map { row in
let snapshot = snapshotsByObservationID[row.observationID]
return SnapshotListItem(
snapshot: snapshot,
baseline: snapshot.flatMap { baselines[$0.id] },
archiveRow: row,
showsDeltaSummary: viewModel.comparisonMode == .previous
)
}
}
return displayedSnapshots.map { snapshot in
SnapshotListItem(
snapshot: snapshot,
baseline: baselines[snapshot.id] ?? nil,
archiveRow: nil,
showsDeltaSummary: viewModel.comparisonMode == .previous
)
}
}
private var localDeviceID: String? {
let currentID = AppSettings.currentDeviceID
if allSnapshots.contains(where: { $0.deviceID == currentID }) {
return currentID
}
return allSnapshots.first?.deviceID
}
var body: some View {
NavigationStack {
Group {
if !hasTimelineRows {
EmptyStateView(
icon: "clock.arrow.circlepath",
title: "No Snapshots",
message: "Use the Dashboard to create your first local snapshot."
)
} else {
snapshotList
}
}
.navigationTitle("Snapshots")
.toolbar { toolbarContent }
.task(id: timelineReloadID) {
loadDeviceProfiles()
await viewModel.loadArchiveRows()
}
}
}
// MARK: - List
private var snapshotList: some View {
List(snapshotItems) { item in
if let snapshot = item.snapshot {
NavigationLink {
SnapshotDetailView(
snapshot: snapshot,
baseline: item.baseline,
profile: profileMap[snapshot.deviceID]
)
} label: {
SnapshotRow(
snapshot: snapshot,
baseline: item.baseline,
archiveRow: item.archiveRow,
showsDeltaSummary: item.showsDeltaSummary,
isSelectedBaseline: viewModel.selectedBaseline?.id == snapshot.id,
profile: profileMap[snapshot.deviceID]
)
}
.swipeActions(edge: .leading) {
Button {
viewModel.toggleBaseline(snapshot)
viewModel.comparisonMode = .selected
} label: {
Label(
viewModel.selectedBaseline?.id == snapshot.id ? "Unset Baseline" : "Set as Baseline",
systemImage: viewModel.selectedBaseline?.id == snapshot.id ? "pin.slash" : "pin"
)
}
.tint(.indigo)
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
do {
try SnapshotLifecycleService.delete(snapshot, context: modelContext)
} catch {
// Keep the list responsive; delete failures can be retried.
}
} label: {
Label("Delete", systemImage: "trash")
}
}
} else {
SnapshotRow(
snapshot: nil,
baseline: item.baseline,
archiveRow: item.archiveRow,
showsDeltaSummary: item.showsDeltaSummary,
isSelectedBaseline: false,
profile: nil
)
}
}
}
// MARK: - Toolbar
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Picker("Compare Against", selection: $viewModel.comparisonMode) {
Text("Previous").tag(ComparisonMode.previous)
ForEach(ComparisonMode.relativeOptions, id: \.interval) { opt in
Text(opt.label).tag(ComparisonMode.relativeTime(opt.interval))
}
if viewModel.selectedBaseline != nil {
Text("Selected Baseline").tag(ComparisonMode.selected)
}
}
} label: {
Label(viewModel.comparisonMode.label, systemImage: "arrow.left.arrow.right")
.labelStyle(.titleAndIcon)
}
}
}
private func loadDeviceProfiles() {
let profiles = LocalDeviceProfileStore.allProfiles()
profileMap = Dictionary(uniqueKeysWithValues: profiles.compactMap {
$0.deviceID.isEmpty ? nil : ($0.deviceID, $0)
})
}
}
private struct SnapshotListItem: Identifiable {
let snapshot: HealthSnapshot?
let baseline: HealthSnapshot?
let archiveRow: CachedArchiveObservationRow?
let showsDeltaSummary: Bool
var id: String {
if let archiveRow {
return "archive-\(archiveRow.observationID)"
}
return snapshot?.id.uuidString ?? "missing-snapshot-row"
}
}
// MARK: - Row
private struct SnapshotRow: View {
let snapshot: HealthSnapshot?
let baseline: HealthSnapshot?
let archiveRow: CachedArchiveObservationRow?
let showsDeltaSummary: Bool
let isSelectedBaseline: Bool
let profile: LocalDeviceProfile?
private static let dateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .short
return f
}()
private var observedAt: Date {
archiveRow?.observedAt ?? snapshot?.timestamp ?? Date(timeIntervalSince1970: 0)
}
private var deviceDisplayName: String {
if let name = profile?.name, !name.isEmpty { return name }
guard let snapshot else { return "Local archive" }
return snapshot.deviceName.isEmpty ? "Unknown device" : snapshot.deviceName
}
private var deviceColor: Color {
DeviceColor(rawValue: profile?.colorTag ?? "")?.color ?? .neutralGray
}
private var metricCountLabel: String? {
if let archiveRow {
return archiveRow.trackedTypeCount == 1
? "1 metric"
: "\(archiveRow.trackedTypeCount) metrics"
}
guard let snapshot else { return nil }
guard snapshot.hasCurrentCachedSummary else { return nil }
return snapshot.cachedTypeCount == 1 ? "1 metric" : "\(snapshot.cachedTypeCount) metrics"
}
private var recordCountLabel: String? {
guard let archiveRow else { return nil }
return archiveRow.visibleRecordCount == 1
? "1 record"
: "\(archiveRow.visibleRecordCount) records"
}
private var deltaSummaryText: String? {
if let archiveRow {
let appeared = archiveRow.appearedCount
let disappeared = archiveRow.disappearedCount
let changed = archiveRow.representationChangedCount
let total = appeared + disappeared + changed
guard total > 0 else { return "No record changes" }
var parts: [String] = []
if appeared > 0 { parts.append("\(appeared) new") }
if disappeared > 0 { parts.append("\(disappeared) missing") }
if changed > 0 { parts.append("\(changed) changed") }
return parts.joined(separator: " • ")
}
return nil
}
private var deltaSummaryColor: Color {
if let archiveRow {
if archiveRow.disappearedCount > 0 { return Color.criticalRed }
if archiveRow.appearedCount + archiveRow.representationChangedCount > 0 { return Color.warningAmber }
return Color.healthyGreen
}
return .secondary
}
private var deltaSummaryIconName: String {
if let archiveRow {
let hasChanges = archiveRow.appearedCount
+ archiveRow.disappearedCount
+ archiveRow.representationChangedCount > 0
return hasChanges ? "arrow.triangle.2.circlepath" : "checkmark.circle"
}
return "checkmark.circle"
}
private var hasOSVersionChange: Bool {
guard let snapshot, let baseline else { return false }
let currentVersion = snapshot.osVersion.trimmingCharacters(in: .whitespacesAndNewlines)
let baselineVersion = baseline.osVersion.trimmingCharacters(in: .whitespacesAndNewlines)
return !currentVersion.isEmpty && !baselineVersion.isEmpty && currentVersion != baselineVersion
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(Self.dateFormatter.string(from: observedAt))
.font(.subheadline.weight(.semibold))
Spacer()
if isSelectedBaseline {
Image(systemName: "pin.fill")
.foregroundStyle(.indigo)
.font(.caption)
.accessibilityLabel("Selected as comparison baseline")
}
}
HStack(spacing: 6) {
Circle()
.fill(deviceColor)
.frame(width: 8, height: 8)
Text(deviceDisplayName)
.font(.caption)
.foregroundStyle(.secondary)
if let metricCountLabel {
Label(metricCountLabel, systemImage: "list.bullet.rectangle")
.font(.caption)
.foregroundStyle(.secondary)
}
if let recordCountLabel {
Label(recordCountLabel, systemImage: "doc.text.magnifyingglass")
.font(.caption)
.foregroundStyle(.secondary)
}
if hasOSVersionChange {
Label("OS \(snapshot?.osVersion ?? "")", systemImage: "gearshape.fill")
.font(.caption)
.foregroundStyle(Color.warningAmber)
.accessibilityLabel("OS version changed to \(snapshot?.osVersion ?? "")")
}
}
// Chain indicators
chainIndicators
if let snapshot, snapshot.snapshotQuality != SnapshotQuality.complete {
Label("Incomplete snapshot", systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(Color.warningAmber)
}
if showsDeltaSummary,
let deltaSummaryText {
HStack(spacing: 4) {
Image(systemName: deltaSummaryIconName)
Text(deltaSummaryText)
}
.font(.caption)
.foregroundStyle(deltaSummaryColor)
}
}
.padding(.vertical, 2)
.accessibilityElement(children: .combine)
}
@ViewBuilder
private var chainIndicators: some View {
if let archiveRow, snapshot == nil {
Label("Archive observation \(archiveRow.observationID)", systemImage: "externaldrive")
.font(.caption)
.foregroundStyle(.secondary)
}
if let snapshot {
if snapshot.isChainStart && snapshot.recoveredDeviceID {
Label("DB reset / recovered device ID", systemImage: "arrow.clockwise.icloud")
.font(.caption)
.foregroundStyle(.secondary)
} else if snapshot.isChainStart {
Label("Chain start", systemImage: "link.badge.plus")
.font(.caption)
.foregroundStyle(.secondary)
}
if snapshot.isPostRestore && !snapshot.isPostRestoreInferred {
Label("Post-restore baseline", systemImage: "clock.arrow.circlepath")
.font(.caption)
.foregroundStyle(.secondary)
} else if snapshot.isPostRestore && snapshot.isPostRestoreInferred {
Label("Post-restore baseline (inferred)", systemImage: "clock.arrow.circlepath")
.font(.caption)
.foregroundStyle(.secondary)
}
if snapshot.triggerReason == "observerCallback" {
Label("Observer-triggered snapshot", systemImage: "waveform")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
#Preview {
SnapshotsView()
.modelContainer(for: [HealthSnapshot.self, TypeCount.self, HealthRecord.self, TypeDistributionBin.self], inMemory: true)
.environment(AppSettings())
}