1 contributor
import Charts
import SwiftUI
import SwiftData
import UIKit
struct SnapshotDetailView: View {
let snapshot: HealthSnapshot
let baseline: HealthSnapshot?
let profile: LocalDeviceProfile?
@Query(sort: \HealthSnapshot.timestamp) private var allSnapshots: [HealthSnapshot]
@Query private var allDeltas: [SnapshotDelta]
@State private var displayedSnapshot: HealthSnapshot?
@State private var archiveTypeRows: [SnapshotArchiveTypeRow]?
@State private var archiveTypeError: String?
private var currentSnapshot: HealthSnapshot {
displayedSnapshot ?? snapshot
}
private var currentDelta: SnapshotDelta? {
allDeltas.first { $0.toSnapshotID == currentSnapshot.id }
}
private var currentDeltaSummary: SnapshotDeltaListSummary? {
currentDelta?.listSummary
}
private var allTypeDeltas: [TypeDelta] {
(currentDelta?.typeDeltas ?? [])
.sorted { $0.displayName.localizedCompare($1.displayName) == .orderedAscending }
}
private var archiveReloadID: String {
[
currentSnapshot.id.uuidString,
String(currentSnapshot.archiveObservationID ?? -1),
String(baseline?.archiveObservationID ?? -1)
].joined(separator: "|")
}
private var summaryTypeCount: Int? {
if let archiveTypeRows {
return archiveTypeRows.count
}
guard currentSnapshot.hasCurrentCachedSummary else { return nil }
return currentSnapshot.cachedTypeCount
}
private var summaryRecordCount: Int? {
if let archiveTypeRows {
return archiveTypeRows.reduce(0) { $0 + $1.currentCount }
}
guard currentSnapshot.hasCurrentCachedSummary else { return nil }
return currentSnapshot.cachedRecordCount
}
private var summaryEarliestRecordDate: Date? {
archiveTypeRows?.compactMap(\.earliestStartDate).min() ?? currentSnapshot.cachedEarliestRecordDate
}
private var summaryLatestRecordDate: Date? {
archiveTypeRows?.compactMap(\.latestEndDate).max() ?? currentSnapshot.cachedLatestRecordDate
}
private var archiveRecordChangeCount: Int? {
archiveTypeRows?.reduce(0) { $0 + $1.recordChangeCount }
}
private var archiveAffectedMetricCount: Int? {
archiveTypeRows?.filter(\.hasChanges).count
}
private var deviceDisplayName: String {
if let name = profile?.name, !name.isEmpty { return name }
return currentSnapshot.deviceName.isEmpty ? "Unknown device" : currentSnapshot.deviceName
}
private var timelineSnapshots: [HealthSnapshot] {
allSnapshots.filter { candidate in
if currentSnapshot.deviceID.isEmpty {
return candidate.deviceID.isEmpty
}
return candidate.deviceID == currentSnapshot.deviceID
}
}
@State private var showShareSheet = false
@State private var pdfExportURL: URL?
@State private var isExporting = false
@State private var showMetadataSheet = false
var body: some View {
List {
evolutionSection
}
.navigationTitle("Snapshot")
.navigationBarTitleDisplayMode(.inline)
.safeAreaInset(edge: .top, spacing: 0) {
SnapshotNavigationHeader(
snapshots: timelineSnapshots,
currentSnapshot: currentSnapshot,
onSnapshotSelected: { displayedSnapshot = $0 }
)
.frame(height: 64)
}
.toolbar {
ToolbarItem(placement: .principal) {
snapshotToolbarTitle
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 12) {
Button {
showMetadataSheet = true
} label: {
Image(systemName: "info.circle")
}
.accessibilityLabel("View snapshot details")
if isExporting {
ProgressView()
.accessibilityLabel("Generating PDF")
} else {
Button {
exportAsPDF()
} label: {
Image(systemName: "square.and.arrow.up")
}
.accessibilityLabel("Export snapshot as PDF")
}
}
}
}
.sheet(isPresented: $showMetadataSheet) {
metadataSheetContent
}
.sheet(isPresented: $showShareSheet) {
if let url = pdfExportURL {
ShareSheet(items: [url])
.ignoresSafeArea()
}
}
.task(id: archiveReloadID) {
await loadArchiveTypeRows()
}
}
private func exportAsPDF() {
isExporting = true
let reportData = SnapshotPDFExporter.extractReportData(
snapshot: currentSnapshot,
baseline: baseline,
profile: profile
)
let timestamp = currentSnapshot.timestamp
Task(priority: .userInitiated) {
let pdfData = SnapshotPDFExporter.generatePDF(from: reportData)
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd-HH-mm"
let name = "HealthProbe-Snapshot-\(formatter.string(from: timestamp)).pdf"
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
try? pdfData.write(to: url)
isExporting = false
pdfExportURL = url
showShareSheet = true
}
}
@MainActor
private func loadArchiveTypeRows() async {
guard let currentObservationID = currentSnapshot.archiveObservationID else {
archiveTypeRows = nil
archiveTypeError = nil
return
}
do {
let cache = try CoreDataArchiveCacheStore()
let currentSummaries = try cache.typeSummaries(observationID: currentObservationID)
let baselineObservationID = baseline?.archiveObservationID
let baselineSummaries = try baselineObservationID.map {
try cache.typeSummaries(observationID: $0)
} ?? []
let currentByType = Dictionary(uniqueKeysWithValues: currentSummaries.map { ($0.sampleTypeIdentifier, $0) })
let baselineByType = Dictionary(uniqueKeysWithValues: baselineSummaries.map { ($0.sampleTypeIdentifier, $0) })
let allTypeIdentifiers = Set(currentByType.keys).union(baselineByType.keys)
var rows: [SnapshotArchiveTypeRow] = []
rows.reserveCapacity(allTypeIdentifiers.count)
for typeIdentifier in allTypeIdentifiers {
let current = currentByType[typeIdentifier]
let baselineSummary = baselineByType[typeIdentifier]
let diff: HealthArchiveDiffSummary
if let baselineObservationID {
diff = try await SQLiteHealthArchiveStore.shared.diffSummary(HealthArchiveDiffRequest(
fromObservationID: baselineObservationID,
toObservationID: currentObservationID,
sampleTypeIdentifier: typeIdentifier
))
} else {
diff = HealthArchiveDiffSummary(
fromObservationID: currentObservationID,
toObservationID: currentObservationID,
sampleTypeIdentifier: typeIdentifier,
appearedCount: 0,
disappearedCount: 0,
representationChangedCount: 0
)
}
rows.append(SnapshotArchiveTypeRow(
typeIdentifier: typeIdentifier,
displayName: current?.displayName ?? baselineSummary?.displayName ?? typeIdentifier,
currentCount: current?.visibleRecordCount ?? 0,
previousCount: baselineSummary?.visibleRecordCount,
appearedCount: diff.appearedCount,
disappearedCount: diff.disappearedCount,
representationChangedCount: diff.representationChangedCount,
earliestStartDate: current?.earliestStartDate,
latestEndDate: current?.latestEndDate
))
}
archiveTypeRows = rows.sorted {
$0.displayName.localizedCompare($1.displayName) == .orderedAscending
}
archiveTypeError = nil
} catch {
archiveTypeRows = nil
archiveTypeError = error.localizedDescription
}
}
@ViewBuilder
private var snapshotToolbarTitle: some View {
if #available(iOS 26.0, *) {
Text("Snapshot")
.font(.headline.weight(.semibold))
.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(currentSnapshot.timestamp, format: .dateTime.month().day().hour().minute())
.font(.headline.weight(.semibold))
.padding(.horizontal, 18)
.frame(height: 36)
.background(.ultraThinMaterial, in: Capsule())
}
}
@ViewBuilder
private var metadataSheetContent: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
// Title with Date
VStack(spacing: 4) {
Text("Snapshot")
.font(.headline.weight(.semibold))
Text(currentSnapshot.timestamp, format: .dateTime.month().day().hour().minute())
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(12)
.background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12))
// Data Range
SnapshotDataRangeIndicator(
oldestRecordDate: summaryEarliestRecordDate,
newestRecordDate: summaryLatestRecordDate,
quality: currentSnapshot.snapshotQuality
)
// Summary Stats (compact)
VStack(spacing: 12) {
if let summaryTypeCount,
let summaryRecordCount {
HStack(spacing: 16) {
statCompact(label: "Types", value: "\(summaryTypeCount)")
Divider()
statCompact(label: "Records", value: "\(summaryRecordCount)")
}
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("Snapshot summary unavailable")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
}
}
.padding(12)
.background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12))
// Device (collapsible)
DisclosureGroup {
VStack(alignment: .leading, spacing: 12) {
DetailRow(label: "Version") {
Text(extractOSVersion(currentSnapshot.osVersion))
.foregroundStyle(.secondary)
.font(.caption.monospacedDigit())
}
Divider()
DetailRow(label: "Build") {
Text(extractBuildNumber(currentSnapshot.osVersion))
.foregroundStyle(.secondary)
.font(.caption.monospacedDigit())
}
}
.padding(.top, 8)
} label: {
HStack(spacing: 8) {
Image(systemName: "iphone")
.font(.system(size: 16, weight: .semibold))
Text(deviceDisplayName)
.font(.subheadline.weight(.semibold))
}
}
.padding(12)
.background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12))
// Comparison (if baseline exists)
if let baseline {
comparisonSection(baseline: baseline)
}
Spacer()
}
.padding(16)
}
.navigationTitle("Snapshot")
.navigationBarTitleDisplayMode(.inline)
}
}
@ViewBuilder
private func comparisonSection(baseline: HealthSnapshot) -> some View {
let delta = archiveRecordChangeCount ?? currentDeltaSummary?.absoluteRecordChangeCount ?? 0
let deltaPercent = computeDeltaPercent(delta: delta, baseline: baseline)
let affectedMetricCount = archiveAffectedMetricCount ?? currentDeltaSummary?.affectedMetricCount ?? 0
let isSignificant = delta > 0 || affectedMetricCount > 0 || (deltaPercent > 10)
DisclosureGroup {
VStack(alignment: .leading, spacing: 12) {
DetailRow(label: "Baseline") {
Text(baseline.timestamp, format: .dateTime.month().day().hour().minute())
.foregroundStyle(.secondary)
}
Divider()
DetailRow(label: "Time Span") {
let days = Calendar.current.dateComponents([.day], from: baseline.timestamp, to: currentSnapshot.timestamp).day ?? 0
Text(days == 0 ? "Same day" : "\(days) days")
.foregroundStyle(.secondary)
}
if archiveTypeRows != nil || currentDeltaSummary != nil {
Divider()
DetailRow(label: "Changed Metrics") {
Text("\(affectedMetricCount)")
.foregroundStyle(.secondary)
}
Divider()
DetailRow(label: "Record Changes") {
Text("\(delta)")
.foregroundStyle(.secondary)
}
}
}
.padding(.top, 8)
} label: {
HStack(spacing: 8) {
Image(systemName: "arrow.left.and.right.square")
.font(.system(size: 16, weight: .semibold))
Text("Comparison")
.font(.subheadline.weight(.semibold))
Spacer()
if isSignificant {
SeverityBadge(delta: delta)
.frame(height: 24)
} else {
Text("–")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
}
}
}
.padding(12)
.background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12))
}
private func shortOSVersion(_ full: String) -> Text {
if full.hasPrefix("iOS ") {
let version = full.dropFirst(4).prefix(while: { $0 != " " })
return Text("iOS \(version)")
}
return Text(full)
}
private func extractOSVersion(_ full: String) -> String {
if full.hasPrefix("iOS ") {
let versionPart = full.dropFirst(4).prefix(while: { $0 != " " && $0 != "(" })
return String(versionPart)
}
return full
}
private func extractBuildNumber(_ full: String) -> String {
if let start = full.firstIndex(of: "("), let end = full.firstIndex(of: ")") {
let buildPart = String(full[full.index(after: start)..<end])
return buildPart.hasPrefix("Build ") ? String(buildPart.dropFirst(6)) : buildPart
}
return full
}
private func computeDeltaPercent(delta: Int, baseline: HealthSnapshot) -> Double {
if let archiveTypeRows {
let baselineTotal = archiveTypeRows.reduce(0) { $0 + ($1.previousCount ?? 0) }
return baselineTotal > 0 ? Double(delta) / Double(baselineTotal) * 100 : 0
}
let baselineTotal = baseline.hasCurrentCachedSummary ? baseline.cachedRecordCount : 0
return baselineTotal > 0 ? Double(delta) / Double(baselineTotal) * 100 : 0
}
private func statCompact(label: String, value: String) -> some View {
VStack(alignment: .center, spacing: 2) {
Text(label)
.font(.caption2.weight(.medium))
Text(value)
.font(.subheadline.weight(.semibold).monospacedDigit())
.foregroundStyle(.primary)
}
.frame(maxWidth: .infinity)
}
private var evolutionSection: some View {
Section("Data Types") {
if let archiveTypeRows {
if archiveTypeRows.isEmpty {
Text("No data types are available for this snapshot.")
.foregroundStyle(.secondary)
} else {
ForEach(archiveTypeRows) { row in
NavigationLink {
DataTypeSnapshotDetailView(
snapshot: currentSnapshot,
typeIdentifier: row.typeIdentifier,
displayName: row.displayName
)
} label: {
SnapshotArchiveTypeRowView(row: row, hasBaseline: baseline != nil)
}
}
}
} else if baseline == nil {
Text("This snapshot starts the chain, so no baseline comparison is available.")
.foregroundStyle(.secondary)
} else if currentDelta == nil {
Text("Cached metric summary unavailable for this snapshot.")
.foregroundStyle(.secondary)
} else if allTypeDeltas.isEmpty {
Text("No data types are available for this snapshot.")
.foregroundStyle(.secondary)
} else {
ForEach(allTypeDeltas) { typeDelta in
NavigationLink {
DataTypeSnapshotDetailView(
snapshot: currentSnapshot,
typeIdentifier: typeDelta.typeIdentifier,
displayName: typeDelta.displayName
)
} label: {
SnapshotTypeDeltaRow(typeDelta: typeDelta)
}
}
}
}
}
}
private struct SnapshotArchiveTypeRow: Identifiable {
let typeIdentifier: String
let displayName: String
let currentCount: Int
let previousCount: Int?
let appearedCount: Int
let disappearedCount: Int
let representationChangedCount: Int
let earliestStartDate: Date?
let latestEndDate: Date?
var id: String { typeIdentifier }
var recordChangeCount: Int {
appearedCount + disappearedCount + representationChangedCount
}
var hasChanges: Bool {
currentDelta != 0 || recordChangeCount > 0
}
var currentDelta: Int {
guard let previousCount else { return currentCount }
return currentCount - previousCount
}
}
private struct SnapshotArchiveTypeRowView: View {
let row: SnapshotArchiveTypeRow
let hasBaseline: Bool
private var countText: String {
"\(row.currentCount)"
}
private var changeLabel: String {
guard hasBaseline else { return "Stored" }
if row.disappearedCount > 0 {
return "\(row.disappearedCount) missing"
}
if row.appearedCount > 0 {
return "\(row.appearedCount) new"
}
if row.representationChangedCount > 0 {
return "\(row.representationChangedCount) changed"
}
if row.currentDelta != 0 {
let prefix = row.currentDelta > 0 ? "+" : ""
return "\(prefix)\(row.currentDelta) records"
}
return "No changes"
}
private var changeColor: Color {
guard hasBaseline else { return .secondary }
if row.disappearedCount > 0 { return .criticalRed }
if row.hasChanges { return .warningAmber }
return .secondary
}
var body: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text(row.displayName)
.font(.subheadline)
Text(row.typeIdentifier)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
Text(countText)
.font(.subheadline.monospacedDigit())
.foregroundStyle(.primary)
Text(changeLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(changeColor)
}
}
.accessibilityElement(children: .combine)
}
}
private struct SnapshotTypeDeltaRow: View {
let typeDelta: TypeDelta
private var deltaLabel: String {
switch typeDelta.transition {
case .changed:
if typeDelta.countDelta == 0 {
return "Content changed"
}
let prefix = typeDelta.countDelta > 0 ? "+" : ""
return "\(prefix)\(typeDelta.countDelta) records"
case .appeared:
return "New"
case .disappeared:
return "Missing"
case .unchanged:
return "No changes"
}
}
private var deltaColor: Color {
switch typeDelta.transition {
case .disappeared:
return .criticalRed
case .changed, .appeared:
return .warningAmber
case .unchanged:
return .secondary
}
}
var body: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text(typeDelta.displayName)
.font(.subheadline)
Text(typeDelta.typeIdentifier)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer()
Text(deltaLabel)
.font(.caption.weight(.semibold))
.foregroundStyle(deltaColor)
}
.accessibilityElement(children: .combine)
}
}
private enum EvolutionXAxisMode: String, CaseIterable, Identifiable {
case time
case snapshots
var id: String { rawValue }
var title: String {
switch self {
case .time:
return "Time"
case .snapshots:
return "Snapshots"
}
}
}
private struct TypeEvolutionSeries: Identifiable {
let typeIdentifier: String
let displayName: String
let points: [TypeEvolutionPoint]
var id: String { typeIdentifier }
var latestPoint: TypeEvolutionPoint? {
points.max { $0.timestamp < $1.timestamp }
}
var selectedOrLatestPoint: TypeEvolutionPoint? {
points.last
}
var yDomain: ClosedRange<Double> {
let counts = points.map(\.count)
guard let minCount = counts.min(), let maxCount = counts.max() else {
return 0...1
}
if minCount == maxCount {
let lower = max(0, minCount - 1)
return Double(lower)...Double(maxCount + 1)
}
return Double(max(0, minCount))...Double(maxCount)
}
}
private struct TypeEvolutionPoint: Identifiable {
let snapshotID: UUID
let timestamp: Date
let count: Int
var id: UUID { snapshotID }
}
private struct TypeEvolutionChart: View {
let series: TypeEvolutionSeries
let contextSnapshots: [HealthSnapshot]
let xAxisMode: EvolutionXAxisMode
let selectedSnapshotID: UUID
let selectedTimestamp: Date
let snapshotNumbers: [UUID: Int]
let baselineTypeCount: TypeCount?
private struct SnapshotAxisPoint: Identifiable {
let snapshotID: UUID
let contextIndex: Int
let timestamp: Date
let count: Int
var id: UUID { snapshotID }
}
private var selectedPoint: TypeEvolutionPoint? {
series.points.first { $0.snapshotID == selectedSnapshotID }
}
private var isMissingInSelectedSnapshot: Bool {
selectedPoint == nil
}
private var previousPoint: TypeEvolutionPoint? {
guard let selectedIndex = series.points.firstIndex(where: { $0.snapshotID == selectedSnapshotID }),
selectedIndex > 0 else { return nil }
return series.points[selectedIndex - 1]
}
private var delta: Int? {
guard let selected = selectedPoint,
let previous = previousPoint,
selected.count >= 0,
previous.count >= 0 else { return nil }
return selected.count - previous.count
}
private var isSignificantChange: Bool {
guard let d = delta, let prev = previousPoint?.count, prev > 0 else { return false }
let percentChange = abs(Double(d)) / Double(prev) * 100
return percentChange > 10 || d > 0
}
private var contextPointCountLabel: String {
"\(series.points.count)/\(contextSnapshots.count) snapshots with data"
}
private var contextAxisPoints: [SnapshotAxisPoint] {
contextSnapshots.enumerated().compactMap { index, snapshot in
guard let candidateTypeCount = snapshot.typeCounts?.first(where: {
$0.typeIdentifier == series.typeIdentifier
}), candidateTypeCount.count >= 0 else {
return nil
}
return SnapshotAxisPoint(
snapshotID: snapshot.id,
contextIndex: index,
timestamp: snapshot.timestamp,
count: candidateTypeCount.count
)
}
}
private var contextAxisGroups: [[SnapshotAxisPoint]] {
guard !contextAxisPoints.isEmpty else { return [] }
var groups: [[SnapshotAxisPoint]] = []
var currentGroup: [SnapshotAxisPoint] = [contextAxisPoints[0]]
for point in contextAxisPoints.dropFirst() {
if let previous = currentGroup.last, point.contextIndex == previous.contextIndex + 1 {
currentGroup.append(point)
} else {
groups.append(currentGroup)
currentGroup = [point]
}
}
groups.append(currentGroup)
return groups
}
private var selectedContextIndex: Int? {
contextSnapshots.firstIndex { $0.id == selectedSnapshotID }
}
private var snapshotAxisValues: [Int] {
Array(contextSnapshots.indices)
}
private func snapshotAxisLabel(for index: Int) -> String {
guard contextSnapshots.indices.contains(index) else { return "\(index + 1)" }
let snapshotID = contextSnapshots[index].id
return "\(snapshotNumbers[snapshotID] ?? index + 1)"
}
private var snapshotAxisDomain: ClosedRange<Int> {
guard let first = snapshotAxisValues.first, let last = snapshotAxisValues.last else {
return 0...0
}
return first...last
}
@ViewBuilder
private var chartContent: some View {
switch xAxisMode {
case .time:
timeChart
case .snapshots:
snapshotChart
}
}
private var timeChart: some View {
Chart {
ForEach(contextSnapshots, id: \.id) { item in
RuleMark(x: .value("Timeline", item.timestamp))
.foregroundStyle(Color.secondary.opacity(0.10))
}
RuleMark(x: .value("Selected Snapshot", selectedTimestamp))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3]))
.foregroundStyle(Color.secondary.opacity(0.55))
ForEach(series.points) { point in
LineMark(
x: .value("Date", point.timestamp),
y: .value("Records", point.count)
)
.interpolationMethod(.linear)
PointMark(
x: .value("Date", point.timestamp),
y: .value("Records", point.count)
)
.symbolSize(24)
if point.snapshotID == selectedSnapshotID {
PointMark(
x: .value("Selected Date", point.timestamp),
y: .value("Selected Records", point.count)
)
.symbolSize(64)
}
}
}
}
private var snapshotChart: some View {
Chart {
ForEach(contextSnapshots.indices, id: \.self) { index in
RuleMark(x: .value("Snapshot", index))
.foregroundStyle(Color.secondary.opacity(0.10))
}
if let selectedContextIndex {
RuleMark(x: .value("Selected Snapshot", selectedContextIndex))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3]))
.foregroundStyle(Color.secondary.opacity(0.55))
}
ForEach(contextAxisGroups.indices, id: \.self) { groupIndex in
let group = contextAxisGroups[groupIndex]
ForEach(group) { point in
LineMark(
x: .value("Snapshot", point.contextIndex),
y: .value("Records", point.count)
)
.interpolationMethod(.linear)
PointMark(
x: .value("Snapshot", point.contextIndex),
y: .value("Records", point.count)
)
.symbolSize(24)
if point.snapshotID == selectedSnapshotID {
PointMark(
x: .value("Selected Snapshot", point.contextIndex),
y: .value("Selected Records", point.count)
)
.symbolSize(64)
}
}
}
}
.chartXAxis {
AxisMarks(values: snapshotAxisValues) { value in
AxisGridLine()
AxisTick()
if let rawIndex = value.as(Int.self) {
AxisValueLabel(snapshotAxisLabel(for: rawIndex))
}
}
}
.chartXScale(domain: snapshotAxisDomain)
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline) {
Text(series.displayName)
.font(.subheadline.weight(.semibold))
Spacer()
VStack(alignment: .trailing, spacing: 4) {
if let selectedPoint {
Text("\(selectedPoint.count)")
.font(.subheadline.monospacedDigit())
.foregroundStyle(.secondary)
}
if isSignificantChange, let delta {
SeverityBadge(delta: delta)
}
}
}
chartContent
.chartYScale(domain: series.yDomain)
.chartXAxis {
switch xAxisMode {
case .time:
AxisMarks(values: .automatic(desiredCount: 3))
case .snapshots:
AxisMarks(values: snapshotAxisValues) { value in
AxisGridLine()
AxisTick()
if let rawIndex = value.as(Int.self) {
AxisValueLabel(snapshotAxisLabel(for: rawIndex))
}
}
}
}
.chartYAxis {
AxisMarks(position: .leading, values: .automatic(desiredCount: 3))
}
.frame(height: 120)
.foregroundStyle(Color.accentColor)
if isMissingInSelectedSnapshot {
Text("Datatype missing in this snapshot")
.font(.caption2)
.foregroundStyle(Color.warningAmber)
} else if series.points.count == 1 {
Text("Only one measurement")
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(contextPointCountLabel)
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
.accessibilityElement(children: .combine)
}
}
private struct SnapshotTypeCountRow: View {
let typeCount: TypeCount
let baselineTypeCount: TypeCount?
private var countText: String {
if typeCount.isUnsupported { return "Unsupported" }
if typeCount.count == -1 { return "Unavailable" }
return "\(typeCount.count)"
}
private var countColor: Color {
if typeCount.isUnsupported { return .secondary }
if typeCount.count == -1 { return Color.criticalRed }
if typeCount.quality != SnapshotQuality.complete { return Color.warningAmber }
return Color.primary
}
private var delta: Int? {
guard let b = baselineTypeCount,
typeCount.count >= 0,
b.count >= 0 else { return nil }
return typeCount.count - b.count
}
private var isSignificantChange: Bool {
guard let d = delta, let b = baselineTypeCount?.count, b > 0 else { return false }
let percentChange = abs(Double(d)) / Double(b) * 100
return percentChange > 10 || d > 0
}
var body: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 3) {
Text(typeCount.displayName)
.font(.subheadline)
Text(typeCount.typeIdentifier)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer()
VStack(alignment: .trailing, spacing: 4) {
Text(countText)
.font(.subheadline.monospacedDigit())
.foregroundStyle(countColor)
if isSignificantChange, let delta {
SeverityBadge(delta: delta)
}
}
}
.accessibilityElement(children: .combine)
}
}
private struct SnapshotDataRangeIndicator: View {
let oldestRecordDate: Date?
let newestRecordDate: Date?
let quality: SnapshotQuality
private var hasDateRange: Bool {
oldestRecordDate != nil && newestRecordDate != nil
}
private var daySpan: Int? {
guard let oldest = oldestRecordDate, let newest = newestRecordDate else { return nil }
return Calendar.current.dateComponents([.day], from: oldest, to: newest).day ?? 0
}
var body: some View {
VStack(spacing: 12) {
HStack(spacing: 8) {
Text("Data Range")
.font(.headline.weight(.semibold))
Spacer()
qualityBadge
}
if hasDateRange {
dateRangeVisualization
} else {
Text("No dated records available")
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 16)
}
}
.padding(16)
.background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12))
}
@ViewBuilder
private var qualityBadge: some View {
if quality != .complete {
Label("Incomplete", systemImage: "exclamationmark.triangle.fill")
.font(.caption.weight(.medium))
.foregroundStyle(Color.warningAmber)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.warningAmber.opacity(0.12), in: Capsule())
}
}
@ViewBuilder
private var dateRangeVisualization: some View {
if let oldest = oldestRecordDate, let newest = newestRecordDate, let span = daySpan {
VStack(spacing: 12) {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .center, spacing: 4) {
Image(systemName: "calendar.badge.clock")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Color.healthyGreen)
VStack(alignment: .center, spacing: 2) {
Text("Oldest record")
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
Text(oldest, format: .dateTime.month().day().year())
.font(.caption.weight(.semibold))
}
}
.frame(maxWidth: .infinity)
VStack(alignment: .center, spacing: 4) {
Text("\(span)")
.font(.system(size: 18, weight: .semibold).monospacedDigit())
.foregroundStyle(.primary)
Text("days")
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
}
VStack(alignment: .center, spacing: 4) {
Image(systemName: "calendar.badge.clock")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Color.accentColor)
VStack(alignment: .center, spacing: 2) {
Text("Newest record")
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
Text(newest, format: .dateTime.month().day().year())
.font(.caption.weight(.semibold))
}
}
.frame(maxWidth: .infinity)
}
timelineBar
}
}
}
@ViewBuilder
private var timelineBar: some View {
if oldestRecordDate != nil, newestRecordDate != nil {
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 3)
.fill(Color(.systemGray5))
RoundedRectangle(cornerRadius: 3)
.fill(
LinearGradient(
gradient: Gradient(colors: [Color.healthyGreen, Color.accentColor]),
startPoint: .leading,
endPoint: .trailing
)
)
.opacity(0.7)
}
.frame(height: 4)
}
}
}
private struct DetailRow<Content: View>: View {
let label: String
@ViewBuilder let content: () -> Content
var body: some View {
HStack {
Text(label)
Spacer()
content()
}
}
}
private struct ShareSheet: UIViewControllerRepresentable {
let items: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: items, applicationActivities: nil)
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
}
#Preview {
NavigationStack {
SnapshotDetailView(
snapshot: HealthSnapshot(
timestamp: .now,
osVersion: "iOS 26.4",
deviceName: "Preview iPhone",
deviceID: "preview-device"
),
baseline: nil,
profile: LocalDeviceProfile(deviceID: "preview-device")
)
}
.modelContainer(for: [HealthSnapshot.self, SnapshotDelta.self, TypeDelta.self, TypeCount.self, YearlyCount.self, HealthRecord.self, TypeDistributionBin.self], inMemory: true)
}