HealthProbe / HealthProbe / Views / Snapshots / DataTypeSnapshotDetailView.swift
1 contributor
581 lines | 20.245kb
import SwiftUI
import SwiftData

struct DataTypeSnapshotDetailView: View {
    let snapshot: HealthSnapshot
    let typeIdentifier: String
    let displayName: String

    @Query(sort: \HealthSnapshot.timestamp) private var allSnapshots: [HealthSnapshot]

    @State private var displayedSnapshot: HealthSnapshot?
    @State private var diffState: RecordDiffState = .idle
    @State private var showAddedRecords = false
    @State private var showDisappearedRecords = false

    private var currentSnapshot: HealthSnapshot {
        displayedSnapshot ?? snapshot
    }

    private var timelineSnapshots: [HealthSnapshot] {
        allSnapshots.filter { candidate in
            if currentSnapshot.deviceID.isEmpty {
                return candidate.deviceID.isEmpty
            }
            return candidate.deviceID == currentSnapshot.deviceID
        }
    }

    private var currentSnapshotIndex: Int? {
        timelineSnapshots.firstIndex { $0.id == currentSnapshot.id }
    }

    private var previousSnapshot: HealthSnapshot? {
        guard let currentSnapshotIndex, currentSnapshotIndex > 0 else { return nil }
        return timelineSnapshots[currentSnapshotIndex - 1]
    }

    private var nextSnapshot: HealthSnapshot? {
        guard let currentSnapshotIndex, currentSnapshotIndex < timelineSnapshots.count - 1 else { return nil }
        return timelineSnapshots[currentSnapshotIndex + 1]
    }

    private var currentTypeCount: TypeCount? {
        typeCount(in: currentSnapshot)
    }

    private var previousTypeCount: TypeCount? {
        previousSnapshot.flatMap(typeCount(in:))
    }

    private var diffTaskID: String {
        [
            currentSnapshot.id.uuidString,
            previousSnapshot?.id.uuidString ?? "none",
            typeIdentifier
        ].joined(separator: "|")
    }

    private var totalDelta: Int? {
        guard previousSnapshot != nil,
              let currentCount = countValue(for: currentTypeCount),
              let previousCount = countValue(for: previousTypeCount) else { return nil }
        return currentCount - previousCount
    }

    private var currentCountText: String {
        countText(for: currentTypeCount)
    }

    private var previousCountText: String {
        countText(for: previousTypeCount)
    }

    var body: some View {
        ScrollView {
            VStack(spacing: 16) {
                if previousSnapshot == nil {
                    emptyStateContent("No baseline available for this device.", icon: "clock.badge.questionmark")
                } else if currentTypeCount == nil && previousTypeCount == nil {
                    emptyStateContent("Data type not tracked in selected snapshots.", icon: "eye.slash")
                } else {
                    metricComparison
                    typeEvolutionSection
                    dataRangeSection
                    recordChangesSection
                }
            }
            .padding(16)
        }
        .navigationTitle(displayName)
        .navigationBarTitleDisplayMode(.inline)
        .safeAreaInset(edge: .top, spacing: 0) {
            snapshotNavigationHeader
                .frame(height: 64)
        }
        .toolbar {
            ToolbarItem(placement: .principal) {
                snapshotToolbarTitle
            }
        }
        .task(id: diffTaskID) {
            await loadRecordDiff()
        }
    }

    private func typeCount(in snapshot: HealthSnapshot) -> TypeCount? {
        snapshot.typeCounts?.first { $0.typeIdentifier == typeIdentifier }
    }

    private func countText(for typeCount: TypeCount?) -> String {
        guard let typeCount else { return "Not tracked" }
        if typeCount.isUnsupported { return "Unsupported" }
        if typeCount.count < 0 { return "Unavailable" }
        return "\(typeCount.count)"
    }

    private func countValue(for typeCount: TypeCount?) -> Int? {
        guard let typeCount else { return 0 }
        guard !typeCount.isUnsupported, typeCount.count >= 0 else { return nil }
        return typeCount.count
    }

    @ViewBuilder
    private var snapshotToolbarTitle: some View {
        if #available(iOS 26.0, *) {
            Text(displayName)
                .font(.headline.weight(.semibold))
                .lineLimit(1)
                .padding(.horizontal, 18)
                .frame(height: 36)
                .background(Color(.systemBackground).opacity(0.08), in: Capsule())
                .glassEffect(
                    .regular.tint(Color(.systemBackground).opacity(0.12)),
                    in: Capsule()
                )
        } else {
            Text(displayName)
                .font(.headline.weight(.semibold))
                .lineLimit(1)
                .padding(.horizontal, 18)
                .frame(height: 36)
                .background(.ultraThinMaterial, in: Capsule())
        }
    }

    @ViewBuilder
    private var snapshotNavigationHeader: some View {
        if #available(iOS 26.0, *) {
            GlassEffectContainer(spacing: 10) {
                snapshotNavigationHeaderContent
                    .padding(.horizontal, 12)
                    .frame(height: 52)
                    .background(Color(.systemBackground).opacity(0.08), in: Capsule())
                    .glassEffect(
                        .regular.tint(Color(.systemBackground).opacity(0.14)),
                        in: Capsule()
                    )
                    .shadow(color: .black.opacity(0.18), radius: 18, x: 0, y: 8)
            }
            .padding(.horizontal, 12)
            .padding(.vertical, 6)
        } else {
            snapshotNavigationHeaderContent
                .padding(.horizontal, 12)
                .frame(height: 52)
                .background(.ultraThinMaterial, in: Capsule())
                .overlay(
                    Capsule()
                        .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
                )
                .shadow(color: .black.opacity(0.14), radius: 16, x: 0, y: 8)
                .padding(.horizontal, 12)
                .padding(.vertical, 6)
        }
    }

    private var snapshotNavigationHeaderContent: some View {
        HStack(spacing: 12) {
            snapshotNavigationButton(
                systemName: "chevron.left",
                label: "Prev",
                target: previousSnapshot,
                accessibilityLabel: "Previous snapshot"
            )

            Spacer(minLength: 8)

            Menu {
                ForEach(timelineSnapshots) { candidate in
                    Button {
                        displayedSnapshot = candidate
                    } label: {
                        if candidate.id == currentSnapshot.id {
                            Label(
                                candidate.timestamp.formatted(.dateTime.year().month().day().hour().minute()),
                                systemImage: "checkmark"
                            )
                        } else {
                            Text(candidate.timestamp, format: .dateTime.year().month().day().hour().minute())
                        }
                    }
                }
            } label: {
                VStack(spacing: 2) {
                    Text("Snapshot")
                        .font(.headline.weight(.semibold))
                    Text(currentSnapshot.timestamp, format: .dateTime.month().day().hour().minute())
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
            }
            .buttonStyle(.plain)
            .lineLimit(1)
            .accessibilityLabel("Select current snapshot")

            Spacer(minLength: 8)

            snapshotNavigationButton(
                systemName: "chevron.right",
                label: "Next",
                target: nextSnapshot,
                accessibilityLabel: "Next snapshot"
            )
        }
    }

    @ViewBuilder
    private func snapshotNavigationButton(
        systemName: String,
        label: String,
        target: HealthSnapshot?,
        accessibilityLabel: String
    ) -> some View {
        if let target {
            Button {
                displayedSnapshot = target
            } label: {
                VStack(spacing: 2) {
                    Image(systemName: systemName)
                        .font(.system(size: 23, weight: .regular))
                        .symbolRenderingMode(.hierarchical)
                    Text(label)
                        .font(.caption2.weight(.medium))
                        .lineLimit(1)
                }
                .frame(width: 70, height: 50)
                .contentShape(Rectangle())
            }
            .buttonStyle(.plain)
            .foregroundStyle(Color.primary)
            .accessibilityLabel(accessibilityLabel)
        } else {
            Color.clear
                .frame(width: 70, height: 50)
        }
    }

    // MARK: - Optimized Content

    @ViewBuilder
    private var metricComparison: some View {
        if previousSnapshot == nil {
            EmptyView()
        } else {
            MetricComparisonCard(
                currentValue: currentTypeCount?.count ?? 0,
                previousValue: previousTypeCount?.count,
                displayName: displayName,
                isCurrentValid: (currentTypeCount?.count ?? 0) >= 0,
                isPreviousTracked: previousTypeCount != nil
            )
        }
    }

    @ViewBuilder
    private var typeEvolutionSection: some View {
        if previousSnapshot != nil {
            TypeEvolutionTimeline(
                snapshots: timelineSnapshots,
                typeIdentifier: typeIdentifier,
                displayName: displayName,
                currentSnapshotID: currentSnapshot.id
            )
        }
    }

    @ViewBuilder
    private var dataRangeSection: some View {
        if currentTypeCount != nil {
            DataTypeRangeIndicator(
                earliestDate: currentTypeCount?.earliestDate,
                latestDate: currentTypeCount?.latestDate,
                quality: currentTypeCount?.quality ?? .complete
            )
        }
    }

    @ViewBuilder
    private var recordChangesSection: some View {
        if previousSnapshot != nil {
            switch diffState {
            case .loaded(let diff):
                RecordChangeIndicator(
                    addedCount: diff.addedCount,
                    disappearedCount: diff.disappearedCount,
                    totalCount: diff.addedCount + diff.disappearedCount,
                    displayName: displayName,
                    onAddedTap: {
                        if diff.addedCount > 0 {
                            showAddedRecords = true
                        }
                    },
                    onDisappearedTap: {
                        if diff.disappearedCount > 0 {
                            showDisappearedRecords = true
                        }
                    }
                )
                .navigationDestination(isPresented: $showAddedRecords) {
                    DataTypeRecordListView(
                        title: "Added Records",
                        displayName: displayName,
                        records: diff.addedRecords,
                        totalCount: diff.addedCount,
                        tint: Color.healthyGreen
                    )
                }
                .navigationDestination(isPresented: $showDisappearedRecords) {
                    DataTypeRecordListView(
                        title: "Disappeared Records",
                        displayName: displayName,
                        records: diff.disappearedRecords,
                        totalCount: diff.disappearedCount,
                        tint: Color.criticalRed
                    )
                }

            case .unavailable:
                Label(
                    "Legacy record format. Recreate database to inspect details.",
                    systemImage: "exclamationmark.triangle.fill"
                )
                .font(.subheadline)
                .foregroundStyle(Color.warningAmber)
                .padding(12)
                .frame(maxWidth: .infinity, alignment: .leading)
                .background(Color.warningAmber.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))

            case .failed(let message):
                Label(message, systemImage: "exclamationmark.triangle.fill")
                    .font(.subheadline)
                    .foregroundStyle(Color.warningAmber)
                    .padding(12)
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .background(Color.warningAmber.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))

            case .idle, .loading:
                HStack(spacing: 8) {
                    ProgressView()
                    Text("Analyzing record changes...")
                        .font(.subheadline)
                        .foregroundStyle(.secondary)
                }
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(12)
                .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
            }
        }
    }

    private func emptyStateContent(_ message: String, icon: String) -> some View {
        VStack(spacing: 12) {
            Image(systemName: icon)
                .font(.system(size: 32, weight: .semibold))
                .foregroundStyle(.secondary)

            Text(message)
                .font(.subheadline)
                .foregroundStyle(.secondary)
                .multilineTextAlignment(.center)
        }
        .padding(24)
        .frame(maxWidth: .infinity)
        .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12))
    }

    @MainActor
    private func loadRecordDiff() async {
        guard previousSnapshot != nil else {
            diffState = .loaded(.empty)
            return
        }

        let currentCount = currentTypeCount?.count ?? 0
        let previousCount = previousTypeCount?.count ?? 0
        let currentArchive = currentTypeCount?.recordArchiveData
        let previousArchive = previousTypeCount?.recordArchiveData

        let currentNeedsArchive = currentCount > 0
        let previousNeedsArchive = previousCount > 0
        guard (!currentNeedsArchive || currentArchive != nil),
              (!previousNeedsArchive || previousArchive != nil) else {
            diffState = .unavailable
            return
        }

        diffState = .loading
        let result = await Task.detached(priority: .userInitiated) {
            DataTypeRecordDiff.compute(
                currentArchive: currentArchive,
                previousArchive: previousArchive
            )
        }.value
        diffState = result
    }
}

private struct DataTypeRecordListView: View {
    let title: String
    let displayName: String
    let records: [HealthRecordValue]
    let totalCount: Int
    let tint: Color

    var body: some View {
        List {
            Section {
                DataTypeDetailRow(label: "Data Type") {
                    Text(displayName)
                        .foregroundStyle(.secondary)
                        .lineLimit(1)
                }
                DataTypeDetailRow(label: "Records") {
                    Text("\(totalCount)")
                        .foregroundStyle(tint)
                        .monospacedDigit()
                }
                if totalCount > records.count {
                    Text("Showing newest \(records.count) records.")
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
            }

            Section(title) {
                if records.isEmpty {
                    Text("No records.")
                        .foregroundStyle(.secondary)
                } else {
                    ForEach(records) { record in
                        DataTypeRecordRow(record: record, tint: tint)
                    }
                }
            }
        }
        .navigationTitle(title)
        .navigationBarTitleDisplayMode(.inline)
    }
}

private enum RecordDiffState: Equatable {
    case idle
    case loading
    case unavailable
    case failed(String)
    case loaded(DataTypeRecordDiff)
}

private struct DataTypeRecordDiff: Equatable, Sendable {
    static let previewLimit = 1_000
    static let empty = DataTypeRecordDiff(
        addedCount: 0,
        disappearedCount: 0,
        addedRecords: [],
        disappearedRecords: []
    )

    let addedCount: Int
    let disappearedCount: Int
    let addedRecords: [HealthRecordValue]
    let disappearedRecords: [HealthRecordValue]

    var isPreviewLimited: Bool {
        addedCount > Self.previewLimit || disappearedCount > Self.previewLimit
    }

    static func compute(currentArchive: Data?, previousArchive: Data?) -> RecordDiffState {
        let currentRecords: [HealthRecordValue]
        if let currentArchive {
            guard let decoded = HealthRecordArchive.decode(currentArchive) else {
                return .failed("Could not decode current record archive.")
            }
            currentRecords = decoded
        } else {
            currentRecords = []
        }

        let previousRecords: [HealthRecordValue]
        if let previousArchive {
            guard let decoded = HealthRecordArchive.decode(previousArchive) else {
                return .failed("Could not decode previous record archive.")
            }
            previousRecords = decoded
        } else {
            previousRecords = []
        }

        let previousFingerprints = Set(previousRecords.map(\.recordFingerprint))
        let currentFingerprints = Set(currentRecords.map(\.recordFingerprint))

        let added = currentRecords.filter { !previousFingerprints.contains($0.recordFingerprint) }
        let disappeared = previousRecords.filter { !currentFingerprints.contains($0.recordFingerprint) }

        return .loaded(
            DataTypeRecordDiff(
                addedCount: added.count,
                disappearedCount: disappeared.count,
                addedRecords: newestRecords(from: added),
                disappearedRecords: newestRecords(from: disappeared)
            )
        )
    }

    private static func newestRecords(from records: [HealthRecordValue]) -> [HealthRecordValue] {
        Array(records.sorted { $0.startDate > $1.startDate }.prefix(previewLimit))
    }
}

private struct DataTypeRecordRow: View {
    let record: HealthRecordValue
    let tint: Color

    var body: some View {
        HStack(spacing: 12) {
            VStack(alignment: .leading, spacing: 2) {
                Text(record.displayValue ?? "Value unavailable")
                    .font(.subheadline)
                    .foregroundStyle(record.displayValue == nil ? .secondary : .primary)
                Text(record.startDate, format: .dateTime.year().month().day().hour().minute())
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }

            Spacer()
            Text(record.endDate, format: .dateTime.hour().minute())
                .font(.caption)
                .foregroundStyle(tint)
        }
        .padding(.vertical, 2)
        .accessibilityElement(children: .combine)
    }
}

private struct DataTypeDetailRow<Content: View>: View {
    let label: String
    @ViewBuilder let content: () -> Content

    var body: some View {
        HStack {
            Text(label)
            Spacer()
            content()
        }
    }
}

#Preview {
    NavigationStack {
        DataTypeSnapshotDetailView(
            snapshot: HealthSnapshot(
                timestamp: .now,
                osVersion: "iOS 26.4",
                deviceName: "Preview iPhone",
                deviceID: "preview-device"
            ),
            typeIdentifier: "HKQuantityTypeIdentifierStepCount",
            displayName: "Step Count"
        )
    }
    .modelContainer(for: [HealthSnapshot.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self], inMemory: true)
}