1 contributor
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)
}