1 contributor
385 lines | 14.449kb
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())
}