1 contributor
import SwiftUI
import SwiftData
struct DataTypesView: View {
@Query(sort: \HealthSnapshot.timestamp, order: .reverse) private var allSnapshots: [HealthSnapshot]
@State private var viewModel = DataTypesViewModel()
private var displayedSnapshots: [HealthSnapshot] {
guard let deviceID = localDeviceID else { return [] }
return allSnapshots.filter { $0.deviceID == deviceID }
}
private var latest: HealthSnapshot? { displayedSnapshots.first }
private var currentBaseline: HealthSnapshot? {
guard let latest else { return nil }
return viewModel.baseline(for: latest, in: displayedSnapshots)
}
private var archiveDiffTaskID: String {
[
latest?.id.uuidString ?? "none",
currentBaseline?.id.uuidString ?? "none",
String(describing: viewModel.comparisonMode)
].joined(separator: "|")
}
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 displayedSnapshots.count < 2 {
EmptyStateView(
icon: "waveform.path.ecg",
title: "Not Enough Data",
message: "Create at least two local snapshots to compare data types."
)
} else {
typeList
}
}
.navigationTitle("Data Types")
.toolbar { filterPicker }
.task(id: archiveDiffTaskID) {
await viewModel.loadArchiveDiffs(current: latest, snapshots: displayedSnapshots)
}
}
}
// MARK: - List
private var typeList: some View {
let diffs = viewModel.diffs(current: latest, snapshots: displayedSnapshots)
return List {
comparisonModeHeader
if diffs.isEmpty {
Section {
Label("No types match the current filter.", systemImage: "magnifyingglass")
.foregroundStyle(.secondary)
.font(.subheadline)
}
} else {
ForEach(diffs) { diff in
NavigationLink(destination: {
if let latest = latest {
DataTypeSnapshotDetailView(
snapshot: latest,
typeIdentifier: diff.typeIdentifier,
displayName: diff.displayName
)
}
}) {
TypeDiffRow(diff: diff)
}
}
}
}
}
private var comparisonModeHeader: some View {
Section {
Picker("Compare Against", selection: $viewModel.comparisonMode) {
Text("Previous Snapshot").tag(ComparisonMode.previous)
ForEach(ComparisonMode.relativeOptions, id: \.interval) { opt in
Text(opt.label).tag(ComparisonMode.relativeTime(opt.interval))
}
}
.pickerStyle(.menu)
.accessibilityLabel("Comparison baseline")
}
}
// MARK: - Toolbar
@ToolbarContentBuilder
private var filterPicker: some ToolbarContent {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Picker("Filter", selection: $viewModel.filter) {
ForEach(DiffFilter.allCases, id: \.self) { f in
Text(f.rawValue).tag(f)
}
}
} label: {
Label(viewModel.filter.rawValue, systemImage: "line.3.horizontal.decrease.circle")
.labelStyle(.titleAndIcon)
}
}
}
}
// MARK: - Row
private struct TypeDiffRow: View {
let diff: TypeDiff
private var deltaDirection: DeltaIndicator {
if !diff.previousTracked { return .new }
if diff.delta > 0 { return .increase }
if diff.delta < 0 { return .decrease }
if diff.recordChangeCount > 0 { return .changed }
return .stable
}
var body: some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text(diff.displayName)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
HStack(spacing: 12) {
metricCompact("Now", diff.currentCount, .accentColor)
if diff.previousTracked {
metricCompact("Before", diff.previousCount, .secondary)
} else {
metricCompact("Before", nil, .secondary)
}
}
if diff.recordChangeCount > 0 {
Text(recordChangeText)
.font(.caption2.weight(.medium))
.foregroundStyle(Color.warningAmber)
}
}
Spacer()
VStack(alignment: .trailing, spacing: 8) {
deltaIndicatorIcon
SeverityBadge(delta: diff.previousTracked ? diff.delta : 0, dimmed: !diff.previousTracked)
.frame(height: 24)
}
}
.padding(.vertical, 8)
.accessibilityElement(children: .combine)
.accessibilityLabel(accessibilityDescription)
}
private func metricCompact(_ label: String, _ value: Int?, _ color: Color) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
if let value = value {
Text(value < 0 ? "unavailable" : "\(value)")
.font(.caption.weight(.semibold).monospacedDigit())
.foregroundStyle(value < 0 ? Color.criticalRed : color)
} else {
Text("–")
.font(.caption.weight(.semibold).monospacedDigit())
.foregroundStyle(.secondary)
}
}
}
@ViewBuilder
private var deltaIndicatorIcon: some View {
switch deltaDirection {
case .increase:
Image(systemName: "arrow.up.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Color.healthyGreen)
case .decrease:
Image(systemName: "arrow.down.left")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Color.criticalRed)
case .stable:
EmptyView()
case .changed:
Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Color.warningAmber)
case .new:
Image(systemName: "sparkles")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Color.accentColor)
}
}
private var accessibilityDescription: String {
if diff.previousTracked {
return "\(diff.displayName). Current: \(diff.currentCount). Previous: \(diff.previousCount). Delta: \(diff.delta). \(recordChangeText)."
} else {
return "\(diff.displayName). Current: \(diff.currentCount). New data type in baseline."
}
}
private var recordChangeText: String {
let count = diff.recordChangeCount
guard count > 0 else { return "No record changes" }
return count == 1 ? "1 record change" : "\(count) record changes"
}
}
private enum DeltaIndicator {
case increase, decrease, stable, changed, new
}
#Preview {
DataTypesView()
.modelContainer(for: [HealthSnapshot.self, TypeCount.self, HealthRecord.self, TypeDistributionBin.self], inMemory: true)
.environment(AppSettings())
}