1 contributor
import SwiftUI
import SwiftData
import UIKit
struct SnapshotsView: View {
@Environment(\.modelContext) private var modelContext
@Environment(AppSettings.self) private var appSettings
@Query(sort: \HealthSnapshot.timestamp, order: .reverse) private var allSnapshots: [HealthSnapshot]
@Query private var deviceProfiles: [DeviceProfile]
@State private var viewModel = SnapshotsViewModel()
private var profileMap: [String: DeviceProfile] {
Dictionary(uniqueKeysWithValues: deviceProfiles.compactMap {
$0.deviceID.isEmpty ? nil : ($0.deviceID, $0)
})
}
private var displayedSnapshots: [HealthSnapshot] {
let selected = appSettings.selectedDeviceIDs
guard !selected.isEmpty else { return [] }
return allSnapshots.filter { selected.contains($0.deviceID) }
}
private var knownDevices: [DeviceEntry] {
let currentID = UIDevice.current.identifierForVendor?.uuidString ?? ""
var ids = Set(allSnapshots.map { $0.deviceID })
if !currentID.isEmpty { ids.insert(currentID) }
return ids.map { id in
let profile = profileMap[id]
let name: String
if let n = profile?.name, !n.isEmpty { name = n }
else if id.isEmpty { name = "Unidentified" }
else { name = "Unknown Device" }
let color = DeviceColor(rawValue: profile?.colorTag ?? "")?.color ?? .neutralGray
return DeviceEntry(id: id, displayName: name, color: color, isCurrent: id == currentID)
}.sorted {
if $0.isCurrent != $1.isCurrent { return $0.isCurrent }
return $0.displayName < $1.displayName
}
}
var body: some View {
NavigationStack {
Group {
if displayedSnapshots.isEmpty {
EmptyStateView(
icon: "clock.arrow.circlepath",
title: appSettings.selectedDeviceIDs.isEmpty ? "No Devices Selected" : "No Snapshots",
message: appSettings.selectedDeviceIDs.isEmpty
? "Select at least one device using the filter above."
: "Use the Dashboard to create your first snapshot."
)
} else {
snapshotList
}
}
.navigationTitle("Snapshots")
.toolbar { toolbarContent }
.onChange(of: appSettings.selectedDeviceIDs) {
if let baseline = viewModel.selectedBaseline,
!displayedSnapshots.contains(where: { $0.id == baseline.id }) {
viewModel.selectedBaseline = nil
viewModel.comparisonMode = .previous
}
}
}
}
// MARK: - List
private var snapshotList: some View {
List(displayedSnapshots) { snapshot in
NavigationLink {
SnapshotDetailView(
snapshot: snapshot,
baseline: viewModel.baseline(for: snapshot, in: displayedSnapshots),
profile: profileMap[snapshot.deviceID]
)
} label: {
SnapshotRow(
snapshot: snapshot,
baseline: viewModel.baseline(for: snapshot, in: displayedSnapshots),
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 {
// Failure is surfaced via the navigation stack; no silent discard
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
// MARK: - Toolbar
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) {
devicePickerMenu
}
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 var devicePickerMenu: some View {
let selected = appSettings.selectedDeviceIDs
let isMulti = selected.count > 1
return Menu {
ForEach(knownDevices) { entry in
Button {
appSettings.toggleDevice(entry.id)
} label: {
Label {
Text(entry.isCurrent
? "\(entry.displayName) (This Device)"
: entry.displayName)
} icon: {
Image(systemName: selected.contains(entry.id)
? "checkmark.circle.fill" : "circle.fill")
.foregroundStyle(entry.color)
}
}
}
} label: {
Image(systemName: "iphone")
}
.tint(isMulti ? .orange : .accentColor)
.accessibilityLabel("Select devices – \(selected.count) selected")
}
}
// MARK: - Row
private struct SnapshotRow: View {
let snapshot: HealthSnapshot
let baseline: HealthSnapshot?
let isSelectedBaseline: Bool
let profile: DeviceProfile?
private let diffService = SnapshotDiffService.shared
private static let dateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .short
return f
}()
private var deviceDisplayName: String {
if let name = profile?.name, !name.isEmpty { return name }
return snapshot.deviceName.isEmpty ? "Unknown device" : snapshot.deviceName
}
private var deviceColor: Color {
DeviceColor(rawValue: profile?.colorTag ?? "")?.color ?? .neutralGray
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(Self.dateFormatter.string(from: snapshot.timestamp))
.font(.subheadline.weight(.semibold))
Spacer()
if isSelectedBaseline {
Image(systemName: "pin.fill")
.foregroundStyle(.indigo)
.font(.caption)
.accessibilityLabel("Selected as comparison baseline")
}
if !snapshot.anomalyFlags.isEmpty {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(Color.warningAmber)
.font(.caption)
.accessibilityLabel("Has anomalies")
}
}
HStack(spacing: 6) {
Circle()
.fill(deviceColor)
.frame(width: 8, height: 8)
Text(deviceDisplayName)
.font(.caption)
.foregroundStyle(.secondary)
Label(snapshot.osVersion, systemImage: "gear")
.font(.caption)
.foregroundStyle(.secondary)
}
// Chain indicators
chainIndicators
if snapshot.snapshotQuality != SnapshotQuality.complete {
Label("Incomplete snapshot", systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(Color.warningAmber)
}
if let baseline {
let delta = diffService.totalAbsoluteChange(current: snapshot, baseline: baseline)
HStack(spacing: 4) {
Image(systemName: "arrow.triangle.2.circlepath")
Text(delta == 0 ? "No changes" : "\(delta) record changes")
}
.font(.caption)
.foregroundStyle(delta == 0 ? Color.healthyGreen : Color.warningAmber)
}
}
.padding(.vertical, 2)
.accessibilityElement(children: .combine)
}
@ViewBuilder
private var chainIndicators: some View {
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, DeviceProfile.self], inMemory: true)
.environment(AppSettings())
}