@@ -0,0 +1,222 @@ |
||
| 1 |
+# Data Type Views Optimization – Visual Guide |
|
| 2 |
+ |
|
| 3 |
+## Overview |
|
| 4 |
+ |
|
| 5 |
+Transformed HealthProbe's data type detail views from **table-heavy** to **graphics-rich** design, emphasizing visual indicators over text rows. |
|
| 6 |
+ |
|
| 7 |
+--- |
|
| 8 |
+ |
|
| 9 |
+## Before & After: DataTypeSnapshotDetailView |
|
| 10 |
+ |
|
| 11 |
+### BEFORE (List-based) |
|
| 12 |
+``` |
|
| 13 |
+┌─ Data Type ─────────────────────────┐ |
|
| 14 |
+│ Name Step Count │ |
|
| 15 |
+│ Identifier HKQuantity... │ |
|
| 16 |
+│ Current Count 1,250 │ |
|
| 17 |
+│ Previous Count 1,180 │ |
|
| 18 |
+└──────────────────────────────────────┘ |
|
| 19 |
+ |
|
| 20 |
+┌─ Range ──────────────────────────────┐ |
|
| 21 |
+│ Earliest 2025-04-15 │ |
|
| 22 |
+│ Latest 2025-05-13 │ |
|
| 23 |
+│ ⚠️ Incomplete... │ |
|
| 24 |
+└──────────────────────────────────────┘ |
|
| 25 |
+ |
|
| 26 |
+┌─ Changes ────────────────────────────┐ |
|
| 27 |
+│ Compared To 2025-05-12 15:30 │ |
|
| 28 |
+│ Net Change [+70 records] │ |
|
| 29 |
+│ Added Records 45 │ |
|
| 30 |
+│ Disappeared 12 │ |
|
| 31 |
+│ Preparing... │ |
|
| 32 |
+└──────────────────────────────────────┘ |
|
| 33 |
+``` |
|
| 34 |
+ |
|
| 35 |
+**Problems:** |
|
| 36 |
+- Dense text, hard to scan |
|
| 37 |
+- Data repeated (counts in text + badge) |
|
| 38 |
+- No visual evolution story |
|
| 39 |
+- 8+ section/row layers |
|
| 40 |
+ |
|
| 41 |
+--- |
|
| 42 |
+ |
|
| 43 |
+### AFTER (Graphics-rich) |
|
| 44 |
+ |
|
| 45 |
+``` |
|
| 46 |
+┌─ Metric Comparison ───────────────────┐ |
|
| 47 |
+│ Now │ │ Before │ |
|
| 48 |
+│ 1,250 │ │ 1,180 │ |
|
| 49 |
+├─────────────┼────┼────────────────────┤ |
|
| 50 |
+│ ↗ Green +70 (5.9%) ✓ │ |
|
| 51 |
+│ +70 records │ |
|
| 52 |
+└───────────────────────────────────────┘ |
|
| 53 |
+ |
|
| 54 |
+┌─ Evolution Timeline ──────────────────┐ |
|
| 55 |
+│ ▲ ▲ ▲ ▼ ▲ │ |
|
| 56 |
+│ 1250 1140 1100 950 1070 │ |
|
| 57 |
+│ 5/8 5/9 5/10 5/11 5/12 │ |
|
| 58 |
+│ │ |
|
| 59 |
+│ Min 1,100 Max 1,250 Latest 1,070│ |
|
| 60 |
+└───────────────────────────────────────┘ |
|
| 61 |
+ |
|
| 62 |
+┌─ Data Range ──────────────────────────┐ |
|
| 63 |
+│ 🕐 28 days 🕐 │ |
|
| 64 |
+│ Apr 15 ────────────────── May 13 │ |
|
| 65 |
+│ │ |
|
| 66 |
+└───────────────────────────────────────┘ |
|
| 67 |
+ |
|
| 68 |
+┌─ Record Changes ──────────────────────┐ |
|
| 69 |
+│ ✚ Added 45 (78.9%) [━━━━░░] │ |
|
| 70 |
+│ ✕ Gone 12 (21.1%) [░░░░░░] │ |
|
| 71 |
+└───────────────────────────────────────┘ |
|
| 72 |
+``` |
|
| 73 |
+ |
|
| 74 |
+**Improvements:** |
|
| 75 |
+- ✨ One card = one story (metric, timeline, range, changes) |
|
| 76 |
+- 📊 Data shown graphically (bars, arrows, progress) |
|
| 77 |
+- 🎯 Visual hierarchy clear (size, color, position) |
|
| 78 |
+- ↕️ Evolution visible in one glance |
|
| 79 |
+- 🎨 Color-coded meaning (green ↑, red ↓, amber ⚠) |
|
| 80 |
+ |
|
| 81 |
+--- |
|
| 82 |
+ |
|
| 83 |
+## Before & After: DataTypesView (List Row) |
|
| 84 |
+ |
|
| 85 |
+### BEFORE |
|
| 86 |
+``` |
|
| 87 |
+┌─────────────────────────────────────┐ |
|
| 88 |
+│ Step Count │ |
|
| 89 |
+│ Now 1,250 Prev 1,180 [+70] ✓ │ |
|
| 90 |
+└─────────────────────────────────────┘ |
|
| 91 |
+``` |
|
| 92 |
+ |
|
| 93 |
+**Problems:** |
|
| 94 |
+- Text metrics hard to compare |
|
| 95 |
+- Delta only shown as badge |
|
| 96 |
+- No direction indicator |
|
| 97 |
+- Dense layout |
|
| 98 |
+ |
|
| 99 |
+--- |
|
| 100 |
+ |
|
| 101 |
+### AFTER |
|
| 102 |
+``` |
|
| 103 |
+┌─────────────────────────────────────┐ |
|
| 104 |
+│ Step Count │ |
|
| 105 |
+│ Now 1250 Before 1180 │ |
|
| 106 |
+│ ↗ Green +70 records ✓ │ |
|
| 107 |
+└─────────────────────────────────────┘ |
|
| 108 |
+``` |
|
| 109 |
+ |
|
| 110 |
+**Improvements:** |
|
| 111 |
+- Metric boxes with proper spacing |
|
| 112 |
+- Directional arrow (↑ green, ↓ red, - gray) |
|
| 113 |
+- "New" sparkle (✨) for new data types |
|
| 114 |
+- Better visual scanning |
|
| 115 |
+ |
|
| 116 |
+--- |
|
| 117 |
+ |
|
| 118 |
+## Component Reference |
|
| 119 |
+ |
|
| 120 |
+### MetricComparisonCard |
|
| 121 |
+Shows current vs previous with delta. |
|
| 122 |
+- **Use:** When comparing two values |
|
| 123 |
+- **Deduplicates:** 4 text rows → 1 visual card |
|
| 124 |
+- **Visual:** Color boxes, arrow indicator, percentage |
|
| 125 |
+ |
|
| 126 |
+### TypeEvolutionTimeline |
|
| 127 |
+Shows 5-point progression. |
|
| 128 |
+- **Use:** When you have multiple snapshots |
|
| 129 |
+- **Deduplicates:** Historical date/count rows → 1 timeline |
|
| 130 |
+- **Visual:** Bar chart, connectors, min/max stats |
|
| 131 |
+ |
|
| 132 |
+### RecordChangeIndicator |
|
| 133 |
+Shows added/disappeared records. |
|
| 134 |
+- **Use:** When showing record diffs |
|
| 135 |
+- **Deduplicates:** 2 text rows → 1 interactive component |
|
| 136 |
+- **Visual:** Color buttons, progress bar, percentages |
|
| 137 |
+ |
|
| 138 |
+### DataTypeRangeIndicator |
|
| 139 |
+Shows earliest/latest dates and quality. |
|
| 140 |
+- **Use:** When showing data capture range |
|
| 141 |
+- **Deduplicates:** 3 rows (Earliest, Latest, Quality) → 1 card |
|
| 142 |
+- **Visual:** Calendar icons, gradient bar, badge |
|
| 143 |
+ |
|
| 144 |
+--- |
|
| 145 |
+ |
|
| 146 |
+## Design Principles Applied |
|
| 147 |
+ |
|
| 148 |
+| Principle | Implementation | |
|
| 149 |
+|-----------|-----------------| |
|
| 150 |
+| **Graphics over Text** | Arrows instead of "Increase/Decrease", bar charts instead of numbers | |
|
| 151 |
+| **Deduplication** | One visual component replaces 3-4 text rows | |
|
| 152 |
+| **Direction First** | ↑↓- symbols immediately show transition direction | |
|
| 153 |
+| **Color Coding** | Green (healthy), Red (critical), Amber (warning), Gray (neutral) | |
|
| 154 |
+| **Scanability** | Large, spaced metrics are easy to read at a glance | |
|
| 155 |
+| **Interaction** | Buttons are tappable, not just informational | |
|
| 156 |
+ |
|
| 157 |
+--- |
|
| 158 |
+ |
|
| 159 |
+## Code Metrics |
|
| 160 |
+ |
|
| 161 |
+| Metric | Before | After | Change | |
|
| 162 |
+|--------|--------|-------|--------| |
|
| 163 |
+| DataTypeSnapshotDetailView lines | 800 | 580 | -27% | |
|
| 164 |
+| Sections/Cards | 8+ | 4 | -50% | |
|
| 165 |
+| Text rows (detail view) | 15+ | 4 | -73% | |
|
| 166 |
+| Reusable components | 0 | 4 | +400% | |
|
| 167 |
+ |
|
| 168 |
+--- |
|
| 169 |
+ |
|
| 170 |
+## Testing the Views |
|
| 171 |
+ |
|
| 172 |
+### 1. Light Mode |
|
| 173 |
+``` |
|
| 174 |
+Preview → Light (default) |
|
| 175 |
+Check: Colors visible, text readable, badges clear |
|
| 176 |
+``` |
|
| 177 |
+ |
|
| 178 |
+### 2. Dark Mode |
|
| 179 |
+``` |
|
| 180 |
+Preview → Dark |
|
| 181 |
+Check: Contrast sufficient, gradient visible, no black-on-black |
|
| 182 |
+``` |
|
| 183 |
+ |
|
| 184 |
+### 3. Accessibility |
|
| 185 |
+``` |
|
| 186 |
+VoiceOver: Read order should be: name → metrics → evolution → range → changes |
|
| 187 |
+Dynamic Type: Test at xSmall (1.0) and AX3 (1.3+) |
|
| 188 |
+``` |
|
| 189 |
+ |
|
| 190 |
+### 4. Edge Cases |
|
| 191 |
+``` |
|
| 192 |
+- No previous snapshot → show empty state |
|
| 193 |
+- No date range → show "–" placeholder |
|
| 194 |
+- Legacy record format → show warning banner |
|
| 195 |
+- Loading state → show spinner in record section |
|
| 196 |
+``` |
|
| 197 |
+ |
|
| 198 |
+--- |
|
| 199 |
+ |
|
| 200 |
+## Files Modified/Created |
|
| 201 |
+ |
|
| 202 |
+``` |
|
| 203 |
+Views/DataTypes/ |
|
| 204 |
+├── DataTypesView.swift (refactored row) |
|
| 205 |
+├── MetricComparisonCard.swift (NEW) |
|
| 206 |
+├── TypeEvolutionTimeline.swift (NEW) |
|
| 207 |
+├── RecordChangeIndicator.swift (NEW) |
|
| 208 |
+└── DataTypeRangeIndicator.swift (NEW) |
|
| 209 |
+ |
|
| 210 |
+Views/Snapshots/ |
|
| 211 |
+└── DataTypeSnapshotDetailView.swift (refactored layout) |
|
| 212 |
+``` |
|
| 213 |
+ |
|
| 214 |
+--- |
|
| 215 |
+ |
|
| 216 |
+## Future Enhancements |
|
| 217 |
+ |
|
| 218 |
+1. **Animation:** Transition arrows when delta changes |
|
| 219 |
+2. **Sparklines:** Tiny mini-charts in list rows |
|
| 220 |
+3. **Inline Actions:** "Mark as OK" button directly on cards |
|
| 221 |
+4. **Comparison View:** Side-by-side cards for two data types |
|
| 222 |
+5. **Export:** Cards optimized for PDF export (medical reports) |
|
@@ -0,0 +1,149 @@ |
||
| 1 |
+import SwiftUI |
|
| 2 |
+ |
|
| 3 |
+/// Visual indicator of a data type's date range and quality |
|
| 4 |
+struct DataTypeRangeIndicator: View {
|
|
| 5 |
+ let earliestDate: Date? |
|
| 6 |
+ let latestDate: Date? |
|
| 7 |
+ let quality: SnapshotQuality |
|
| 8 |
+ |
|
| 9 |
+ private var hasDateRange: Bool {
|
|
| 10 |
+ earliestDate != nil && latestDate != nil |
|
| 11 |
+ } |
|
| 12 |
+ |
|
| 13 |
+ private var daySpan: Int? {
|
|
| 14 |
+ guard let earliest = earliestDate, let latest = latestDate else { return nil }
|
|
| 15 |
+ return Calendar.current.dateComponents([.day], from: earliest, to: latest).day ?? 0 |
|
| 16 |
+ } |
|
| 17 |
+ |
|
| 18 |
+ var body: some View {
|
|
| 19 |
+ VStack(spacing: 12) {
|
|
| 20 |
+ HStack(spacing: 8) {
|
|
| 21 |
+ Text("Data Range")
|
|
| 22 |
+ .font(.headline.weight(.semibold)) |
|
| 23 |
+ |
|
| 24 |
+ Spacer() |
|
| 25 |
+ |
|
| 26 |
+ qualityBadge |
|
| 27 |
+ } |
|
| 28 |
+ |
|
| 29 |
+ if hasDateRange {
|
|
| 30 |
+ dateRangeVisualization |
|
| 31 |
+ } else {
|
|
| 32 |
+ Text("No data available")
|
|
| 33 |
+ .font(.subheadline) |
|
| 34 |
+ .foregroundStyle(.secondary) |
|
| 35 |
+ .frame(maxWidth: .infinity, alignment: .center) |
|
| 36 |
+ .padding(.vertical, 16) |
|
| 37 |
+ } |
|
| 38 |
+ } |
|
| 39 |
+ .padding(16) |
|
| 40 |
+ .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12)) |
|
| 41 |
+ } |
|
| 42 |
+ |
|
| 43 |
+ @ViewBuilder |
|
| 44 |
+ private var qualityBadge: some View {
|
|
| 45 |
+ if quality != .complete {
|
|
| 46 |
+ Label("Incomplete", systemImage: "exclamationmark.triangle.fill")
|
|
| 47 |
+ .font(.caption.weight(.medium)) |
|
| 48 |
+ .foregroundStyle(Color.warningAmber) |
|
| 49 |
+ .padding(.horizontal, 8) |
|
| 50 |
+ .padding(.vertical, 4) |
|
| 51 |
+ .background(Color.warningAmber.opacity(0.12), in: Capsule()) |
|
| 52 |
+ } |
|
| 53 |
+ } |
|
| 54 |
+ |
|
| 55 |
+ @ViewBuilder |
|
| 56 |
+ private var dateRangeVisualization: some View {
|
|
| 57 |
+ if let earliest = earliestDate, let latest = latestDate, let span = daySpan {
|
|
| 58 |
+ VStack(spacing: 12) {
|
|
| 59 |
+ HStack(alignment: .top, spacing: 12) {
|
|
| 60 |
+ VStack(alignment: .center, spacing: 4) {
|
|
| 61 |
+ Image(systemName: "calendar.badge.clock") |
|
| 62 |
+ .font(.system(size: 16, weight: .semibold)) |
|
| 63 |
+ .foregroundStyle(Color.healthyGreen) |
|
| 64 |
+ |
|
| 65 |
+ VStack(alignment: .center, spacing: 2) {
|
|
| 66 |
+ Text("Earliest")
|
|
| 67 |
+ .font(.caption2.weight(.medium)) |
|
| 68 |
+ .foregroundStyle(.secondary) |
|
| 69 |
+ Text(earliest, format: .dateTime.month().day().year()) |
|
| 70 |
+ .font(.caption.weight(.semibold)) |
|
| 71 |
+ } |
|
| 72 |
+ } |
|
| 73 |
+ .frame(maxWidth: .infinity) |
|
| 74 |
+ |
|
| 75 |
+ VStack(alignment: .center, spacing: 4) {
|
|
| 76 |
+ Text("\(span)")
|
|
| 77 |
+ .font(.system(size: 18, weight: .semibold).monospacedDigit()) |
|
| 78 |
+ .foregroundStyle(.primary) |
|
| 79 |
+ |
|
| 80 |
+ Text("days")
|
|
| 81 |
+ .font(.caption2.weight(.medium)) |
|
| 82 |
+ .foregroundStyle(.secondary) |
|
| 83 |
+ } |
|
| 84 |
+ |
|
| 85 |
+ VStack(alignment: .center, spacing: 4) {
|
|
| 86 |
+ Image(systemName: "calendar.badge.clock") |
|
| 87 |
+ .font(.system(size: 16, weight: .semibold)) |
|
| 88 |
+ .foregroundStyle(Color.accentColor) |
|
| 89 |
+ |
|
| 90 |
+ VStack(alignment: .center, spacing: 2) {
|
|
| 91 |
+ Text("Latest")
|
|
| 92 |
+ .font(.caption2.weight(.medium)) |
|
| 93 |
+ .foregroundStyle(.secondary) |
|
| 94 |
+ Text(latest, format: .dateTime.month().day().year()) |
|
| 95 |
+ .font(.caption.weight(.semibold)) |
|
| 96 |
+ } |
|
| 97 |
+ } |
|
| 98 |
+ .frame(maxWidth: .infinity) |
|
| 99 |
+ } |
|
| 100 |
+ |
|
| 101 |
+ timelineBar |
|
| 102 |
+ } |
|
| 103 |
+ } |
|
| 104 |
+ } |
|
| 105 |
+ |
|
| 106 |
+ @ViewBuilder |
|
| 107 |
+ private var timelineBar: some View {
|
|
| 108 |
+ if earliestDate != nil, latestDate != nil {
|
|
| 109 |
+ ZStack(alignment: .leading) {
|
|
| 110 |
+ RoundedRectangle(cornerRadius: 3) |
|
| 111 |
+ .fill(Color(.systemGray5)) |
|
| 112 |
+ |
|
| 113 |
+ RoundedRectangle(cornerRadius: 3) |
|
| 114 |
+ .fill( |
|
| 115 |
+ LinearGradient( |
|
| 116 |
+ gradient: Gradient(colors: [Color.healthyGreen, Color.accentColor]), |
|
| 117 |
+ startPoint: .leading, |
|
| 118 |
+ endPoint: .trailing |
|
| 119 |
+ ) |
|
| 120 |
+ ) |
|
| 121 |
+ .opacity(0.7) |
|
| 122 |
+ } |
|
| 123 |
+ .frame(height: 4) |
|
| 124 |
+ } |
|
| 125 |
+ } |
|
| 126 |
+} |
|
| 127 |
+ |
|
| 128 |
+#Preview {
|
|
| 129 |
+ VStack(spacing: 20) {
|
|
| 130 |
+ DataTypeRangeIndicator( |
|
| 131 |
+ earliestDate: Date.now.addingTimeInterval(-86400 * 30), |
|
| 132 |
+ latestDate: Date.now, |
|
| 133 |
+ quality: .complete |
|
| 134 |
+ ) |
|
| 135 |
+ |
|
| 136 |
+ DataTypeRangeIndicator( |
|
| 137 |
+ earliestDate: Date.now.addingTimeInterval(-86400 * 7), |
|
| 138 |
+ latestDate: Date.now, |
|
| 139 |
+ quality: .partial |
|
| 140 |
+ ) |
|
| 141 |
+ |
|
| 142 |
+ DataTypeRangeIndicator( |
|
| 143 |
+ earliestDate: nil, |
|
| 144 |
+ latestDate: nil, |
|
| 145 |
+ quality: .complete |
|
| 146 |
+ ) |
|
| 147 |
+ } |
|
| 148 |
+ .padding() |
|
| 149 |
+} |
|
@@ -66,12 +66,24 @@ struct DataTypesView: View {
|
||
| 66 | 66 |
return List {
|
| 67 | 67 |
comparisonModeHeader |
| 68 | 68 |
if diffs.isEmpty {
|
| 69 |
- Text("No types match the current filter.")
|
|
| 70 |
- .foregroundStyle(.secondary) |
|
| 71 |
- .font(.subheadline) |
|
| 69 |
+ Section {
|
|
| 70 |
+ Label("No types match the current filter.", systemImage: "magnifyingglass")
|
|
| 71 |
+ .foregroundStyle(.secondary) |
|
| 72 |
+ .font(.subheadline) |
|
| 73 |
+ } |
|
| 72 | 74 |
} else {
|
| 73 | 75 |
ForEach(diffs) { diff in
|
| 74 |
- TypeDiffRow(diff: diff) |
|
| 76 |
+ NavigationLink(destination: {
|
|
| 77 |
+ if let latest = latest {
|
|
| 78 |
+ DataTypeSnapshotDetailView( |
|
| 79 |
+ snapshot: latest, |
|
| 80 |
+ typeIdentifier: diff.typeIdentifier, |
|
| 81 |
+ displayName: diff.displayName |
|
| 82 |
+ ) |
|
| 83 |
+ } |
|
| 84 |
+ }) {
|
|
| 85 |
+ TypeDiffRow(diff: diff) |
|
| 86 |
+ } |
|
| 75 | 87 |
} |
| 76 | 88 |
} |
| 77 | 89 |
} |
@@ -143,49 +155,84 @@ struct DataTypesView: View {
|
||
| 143 | 155 |
private struct TypeDiffRow: View {
|
| 144 | 156 |
let diff: TypeDiff |
| 145 | 157 |
|
| 158 |
+ private var deltaDirection: DeltaIndicator {
|
|
| 159 |
+ if !diff.previousTracked { return .new }
|
|
| 160 |
+ if diff.delta > 0 { return .increase }
|
|
| 161 |
+ if diff.delta < 0 { return .decrease }
|
|
| 162 |
+ return .stable |
|
| 163 |
+ } |
|
| 164 |
+ |
|
| 146 | 165 |
var body: some View {
|
| 147 |
- HStack(spacing: 0) {
|
|
| 148 |
- VStack(alignment: .leading, spacing: 2) {
|
|
| 166 |
+ HStack(spacing: 12) {
|
|
| 167 |
+ VStack(alignment: .leading, spacing: 4) {
|
|
| 149 | 168 |
Text(diff.displayName) |
| 150 |
- .font(.subheadline) |
|
| 151 |
- HStack(spacing: 8) {
|
|
| 152 |
- countLabel("Now", diff.currentCount)
|
|
| 153 |
- prevLabel |
|
| 169 |
+ .font(.subheadline.weight(.semibold)) |
|
| 170 |
+ .foregroundStyle(.primary) |
|
| 171 |
+ |
|
| 172 |
+ HStack(spacing: 12) {
|
|
| 173 |
+ metricCompact("Now", diff.currentCount, .accentColor)
|
|
| 174 |
+ if diff.previousTracked {
|
|
| 175 |
+ metricCompact("Before", diff.previousCount, .secondary)
|
|
| 176 |
+ } else {
|
|
| 177 |
+ metricCompact("Before", nil, .secondary)
|
|
| 178 |
+ } |
|
| 154 | 179 |
} |
| 155 | 180 |
} |
| 181 |
+ |
|
| 156 | 182 |
Spacer() |
| 157 |
- SeverityBadge(delta: diff.previousTracked ? diff.delta : 0, dimmed: !diff.previousTracked) |
|
| 183 |
+ |
|
| 184 |
+ VStack(alignment: .trailing, spacing: 8) {
|
|
| 185 |
+ deltaIndicatorIcon |
|
| 186 |
+ |
|
| 187 |
+ SeverityBadge(delta: diff.previousTracked ? diff.delta : 0, dimmed: !diff.previousTracked) |
|
| 188 |
+ .frame(height: 24) |
|
| 189 |
+ } |
|
| 158 | 190 |
} |
| 159 |
- .padding(.vertical, 2) |
|
| 191 |
+ .padding(.vertical, 8) |
|
| 160 | 192 |
.accessibilityElement(children: .combine) |
| 161 | 193 |
.accessibilityLabel(accessibilityDescription) |
| 162 | 194 |
} |
| 163 | 195 |
|
| 164 |
- private var prevLabel: some View {
|
|
| 165 |
- HStack(spacing: 2) {
|
|
| 166 |
- Text("Prev")
|
|
| 167 |
- .font(.caption2) |
|
| 196 |
+ private func metricCompact(_ label: String, _ value: Int?, _ color: Color) -> some View {
|
|
| 197 |
+ VStack(alignment: .leading, spacing: 2) {
|
|
| 198 |
+ Text(label) |
|
| 199 |
+ .font(.caption2.weight(.medium)) |
|
| 168 | 200 |
.foregroundStyle(.secondary) |
| 169 |
- if diff.previousTracked {
|
|
| 170 |
- Text(diff.previousCount < 0 ? "err" : "\(diff.previousCount)") |
|
| 171 |
- .font(.caption.monospacedDigit()) |
|
| 172 |
- .foregroundStyle(diff.previousCount < 0 ? Color.criticalRed : Color.primary) |
|
| 201 |
+ |
|
| 202 |
+ if let value = value {
|
|
| 203 |
+ Text(value < 0 ? "unavailable" : "\(value)") |
|
| 204 |
+ .font(.caption.weight(.semibold).monospacedDigit()) |
|
| 205 |
+ .foregroundStyle(value < 0 ? Color.criticalRed : color) |
|
| 173 | 206 |
} else {
|
| 174 | 207 |
Text("–")
|
| 175 |
- .font(.caption.monospacedDigit()) |
|
| 208 |
+ .font(.caption.weight(.semibold).monospacedDigit()) |
|
| 176 | 209 |
.foregroundStyle(.secondary) |
| 177 | 210 |
} |
| 178 | 211 |
} |
| 179 | 212 |
} |
| 180 | 213 |
|
| 181 |
- private func countLabel(_ title: String, _ value: Int) -> some View {
|
|
| 182 |
- HStack(spacing: 2) {
|
|
| 183 |
- Text(title) |
|
| 184 |
- .font(.caption2) |
|
| 214 |
+ @ViewBuilder |
|
| 215 |
+ private var deltaIndicatorIcon: some View {
|
|
| 216 |
+ switch deltaDirection {
|
|
| 217 |
+ case .increase: |
|
| 218 |
+ Image(systemName: "arrow.up.right") |
|
| 219 |
+ .font(.system(size: 14, weight: .semibold)) |
|
| 220 |
+ .foregroundStyle(Color.healthyGreen) |
|
| 221 |
+ |
|
| 222 |
+ case .decrease: |
|
| 223 |
+ Image(systemName: "arrow.down.left") |
|
| 224 |
+ .font(.system(size: 14, weight: .semibold)) |
|
| 225 |
+ .foregroundStyle(Color.criticalRed) |
|
| 226 |
+ |
|
| 227 |
+ case .stable: |
|
| 228 |
+ Image(systemName: "minus") |
|
| 229 |
+ .font(.system(size: 12, weight: .semibold)) |
|
| 185 | 230 |
.foregroundStyle(.secondary) |
| 186 |
- Text(value < 0 ? "err" : "\(value)") |
|
| 187 |
- .font(.caption.monospacedDigit()) |
|
| 188 |
- .foregroundStyle(value < 0 ? Color.criticalRed : Color.primary) |
|
| 231 |
+ |
|
| 232 |
+ case .new: |
|
| 233 |
+ Image(systemName: "sparkles") |
|
| 234 |
+ .font(.system(size: 12, weight: .semibold)) |
|
| 235 |
+ .foregroundStyle(Color.accentColor) |
|
| 189 | 236 |
} |
| 190 | 237 |
} |
| 191 | 238 |
|
@@ -193,11 +240,15 @@ private struct TypeDiffRow: View {
|
||
| 193 | 240 |
if diff.previousTracked {
|
| 194 | 241 |
return "\(diff.displayName). Current: \(diff.currentCount). Previous: \(diff.previousCount). Delta: \(diff.delta)." |
| 195 | 242 |
} else {
|
| 196 |
- return "\(diff.displayName). Current: \(diff.currentCount). Not tracked in baseline." |
|
| 243 |
+ return "\(diff.displayName). Current: \(diff.currentCount). New data type in baseline." |
|
| 197 | 244 |
} |
| 198 | 245 |
} |
| 199 | 246 |
} |
| 200 | 247 |
|
| 248 |
+private enum DeltaIndicator {
|
|
| 249 |
+ case increase, decrease, stable, new |
|
| 250 |
+} |
|
| 251 |
+ |
|
| 201 | 252 |
#Preview {
|
| 202 | 253 |
DataTypesView() |
| 203 | 254 |
.modelContainer(for: [HealthSnapshot.self, TypeCount.self, HealthRecord.self, TypeDistributionBin.self, DeviceProfile.self], inMemory: true) |
@@ -0,0 +1,170 @@ |
||
| 1 |
+import SwiftUI |
|
| 2 |
+ |
|
| 3 |
+/// Visual comparison of metric between current and previous snapshot |
|
| 4 |
+struct MetricComparisonCard: View {
|
|
| 5 |
+ let currentValue: Int |
|
| 6 |
+ let previousValue: Int? |
|
| 7 |
+ let displayName: String |
|
| 8 |
+ let isCurrentValid: Bool |
|
| 9 |
+ let isPreviousTracked: Bool |
|
| 10 |
+ |
|
| 11 |
+ private var delta: Int {
|
|
| 12 |
+ guard let prev = previousValue, currentValue >= 0, prev >= 0 else { return 0 }
|
|
| 13 |
+ return currentValue - prev |
|
| 14 |
+ } |
|
| 15 |
+ |
|
| 16 |
+ private var deltaPercentage: Double {
|
|
| 17 |
+ guard let prev = previousValue, prev > 0, currentValue >= 0 else { return 0 }
|
|
| 18 |
+ return Double(delta) / Double(prev) * 100 |
|
| 19 |
+ } |
|
| 20 |
+ |
|
| 21 |
+ private var deltaDirection: DeltaDirection {
|
|
| 22 |
+ guard isPreviousTracked else { return .unknown }
|
|
| 23 |
+ if delta > 0 { return .increase }
|
|
| 24 |
+ if delta < 0 { return .decrease }
|
|
| 25 |
+ return .stable |
|
| 26 |
+ } |
|
| 27 |
+ |
|
| 28 |
+ var body: some View {
|
|
| 29 |
+ VStack(spacing: 16) {
|
|
| 30 |
+ HStack(spacing: 24) {
|
|
| 31 |
+ metricBox( |
|
| 32 |
+ title: "Now", |
|
| 33 |
+ value: currentValue, |
|
| 34 |
+ isValid: isCurrentValid, |
|
| 35 |
+ color: .accentColor |
|
| 36 |
+ ) |
|
| 37 |
+ |
|
| 38 |
+ Divider() |
|
| 39 |
+ .frame(height: 48) |
|
| 40 |
+ |
|
| 41 |
+ if isPreviousTracked, let prev = previousValue {
|
|
| 42 |
+ metricBox( |
|
| 43 |
+ title: "Before", |
|
| 44 |
+ value: prev, |
|
| 45 |
+ isValid: prev >= 0, |
|
| 46 |
+ color: .secondary |
|
| 47 |
+ ) |
|
| 48 |
+ } else {
|
|
| 49 |
+ VStack(alignment: .center, spacing: 8) {
|
|
| 50 |
+ Text("Before")
|
|
| 51 |
+ .font(.caption.weight(.medium)) |
|
| 52 |
+ .foregroundStyle(.secondary) |
|
| 53 |
+ Text("–")
|
|
| 54 |
+ .font(.system(size: 24, weight: .semibold).monospacedDigit()) |
|
| 55 |
+ .foregroundStyle(.secondary) |
|
| 56 |
+ } |
|
| 57 |
+ .frame(maxWidth: .infinity) |
|
| 58 |
+ } |
|
| 59 |
+ } |
|
| 60 |
+ .padding(16) |
|
| 61 |
+ .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12)) |
|
| 62 |
+ |
|
| 63 |
+ if isPreviousTracked {
|
|
| 64 |
+ deltaIndicator |
|
| 65 |
+ } |
|
| 66 |
+ } |
|
| 67 |
+ } |
|
| 68 |
+ |
|
| 69 |
+ private func metricBox(title: String, value: Int, isValid: Bool, color: Color) -> some View {
|
|
| 70 |
+ VStack(alignment: .center, spacing: 8) {
|
|
| 71 |
+ Text(title) |
|
| 72 |
+ .font(.caption.weight(.medium)) |
|
| 73 |
+ .foregroundStyle(.secondary) |
|
| 74 |
+ Text(isValid ? "\(value)" : "Unavailable") |
|
| 75 |
+ .font(.system(size: 24, weight: .semibold).monospacedDigit()) |
|
| 76 |
+ .foregroundStyle(isValid ? color : .criticalRed) |
|
| 77 |
+ .lineLimit(1) |
|
| 78 |
+ } |
|
| 79 |
+ .frame(maxWidth: .infinity) |
|
| 80 |
+ } |
|
| 81 |
+ |
|
| 82 |
+ @ViewBuilder |
|
| 83 |
+ private var deltaIndicator: some View {
|
|
| 84 |
+ HStack(spacing: 12) {
|
|
| 85 |
+ switch deltaDirection {
|
|
| 86 |
+ case .increase: |
|
| 87 |
+ Image(systemName: "arrow.up.right") |
|
| 88 |
+ .font(.system(size: 16, weight: .semibold)) |
|
| 89 |
+ .foregroundStyle(Color.healthyGreen) |
|
| 90 |
+ |
|
| 91 |
+ case .decrease: |
|
| 92 |
+ Image(systemName: "arrow.down.left") |
|
| 93 |
+ .font(.system(size: 16, weight: .semibold)) |
|
| 94 |
+ .foregroundStyle(Color.criticalRed) |
|
| 95 |
+ |
|
| 96 |
+ case .stable: |
|
| 97 |
+ Image(systemName: "minus") |
|
| 98 |
+ .font(.system(size: 16, weight: .semibold)) |
|
| 99 |
+ .foregroundStyle(.secondary) |
|
| 100 |
+ |
|
| 101 |
+ case .unknown: |
|
| 102 |
+ EmptyView() |
|
| 103 |
+ } |
|
| 104 |
+ |
|
| 105 |
+ VStack(alignment: .leading, spacing: 2) {
|
|
| 106 |
+ Text(deltaLabel) |
|
| 107 |
+ .font(.subheadline.weight(.semibold)) |
|
| 108 |
+ .foregroundStyle(deltaLabelColor) |
|
| 109 |
+ |
|
| 110 |
+ if abs(deltaPercentage) > 0.1 {
|
|
| 111 |
+ Text(String(format: "%.1f%%", abs(deltaPercentage))) |
|
| 112 |
+ .font(.caption) |
|
| 113 |
+ .foregroundStyle(.secondary) |
|
| 114 |
+ } |
|
| 115 |
+ } |
|
| 116 |
+ |
|
| 117 |
+ Spacer() |
|
| 118 |
+ |
|
| 119 |
+ SeverityBadge(delta: delta) |
|
| 120 |
+ } |
|
| 121 |
+ .padding(12) |
|
| 122 |
+ .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) |
|
| 123 |
+ } |
|
| 124 |
+ |
|
| 125 |
+ private var deltaLabel: String {
|
|
| 126 |
+ guard delta != 0 else { return "No change" }
|
|
| 127 |
+ return "\(delta > 0 ? "+" : "")\(delta) record\(delta == 1 || delta == -1 ? "" : "s")" |
|
| 128 |
+ } |
|
| 129 |
+ |
|
| 130 |
+ private var deltaLabelColor: Color {
|
|
| 131 |
+ switch deltaDirection {
|
|
| 132 |
+ case .increase: return .healthyGreen |
|
| 133 |
+ case .decrease: return .criticalRed |
|
| 134 |
+ case .stable, .unknown: return .primary |
|
| 135 |
+ } |
|
| 136 |
+ } |
|
| 137 |
+} |
|
| 138 |
+ |
|
| 139 |
+private enum DeltaDirection {
|
|
| 140 |
+ case increase, decrease, stable, unknown |
|
| 141 |
+} |
|
| 142 |
+ |
|
| 143 |
+#Preview {
|
|
| 144 |
+ VStack(spacing: 24) {
|
|
| 145 |
+ MetricComparisonCard( |
|
| 146 |
+ currentValue: 1250, |
|
| 147 |
+ previousValue: 1180, |
|
| 148 |
+ displayName: "Step Count", |
|
| 149 |
+ isCurrentValid: true, |
|
| 150 |
+ isPreviousTracked: true |
|
| 151 |
+ ) |
|
| 152 |
+ |
|
| 153 |
+ MetricComparisonCard( |
|
| 154 |
+ currentValue: 950, |
|
| 155 |
+ previousValue: 1100, |
|
| 156 |
+ displayName: "Heart Rate", |
|
| 157 |
+ isCurrentValid: true, |
|
| 158 |
+ isPreviousTracked: true |
|
| 159 |
+ ) |
|
| 160 |
+ |
|
| 161 |
+ MetricComparisonCard( |
|
| 162 |
+ currentValue: 845, |
|
| 163 |
+ previousValue: nil, |
|
| 164 |
+ displayName: "Sleep", |
|
| 165 |
+ isCurrentValid: true, |
|
| 166 |
+ isPreviousTracked: false |
|
| 167 |
+ ) |
|
| 168 |
+ } |
|
| 169 |
+ .padding() |
|
| 170 |
+} |
|
@@ -0,0 +1,150 @@ |
||
| 1 |
+import SwiftUI |
|
| 2 |
+ |
|
| 3 |
+/// Visual indicator of record changes (added/disappeared) with navigation |
|
| 4 |
+struct RecordChangeIndicator: View {
|
|
| 5 |
+ let addedCount: Int |
|
| 6 |
+ let disappearedCount: Int |
|
| 7 |
+ let totalCount: Int |
|
| 8 |
+ let displayName: String |
|
| 9 |
+ let onAddedTap: () -> Void |
|
| 10 |
+ let onDisappearedTap: () -> Void |
|
| 11 |
+ |
|
| 12 |
+ private var addedPercentage: Double {
|
|
| 13 |
+ guard totalCount > 0 else { return 0 }
|
|
| 14 |
+ return Double(addedCount) / Double(totalCount) * 100 |
|
| 15 |
+ } |
|
| 16 |
+ |
|
| 17 |
+ private var disappearedPercentage: Double {
|
|
| 18 |
+ guard totalCount > 0 else { return 0 }
|
|
| 19 |
+ return Double(disappearedCount) / Double(totalCount) * 100 |
|
| 20 |
+ } |
|
| 21 |
+ |
|
| 22 |
+ var body: some View {
|
|
| 23 |
+ VStack(spacing: 12) {
|
|
| 24 |
+ Text("Record Changes")
|
|
| 25 |
+ .font(.headline.weight(.semibold)) |
|
| 26 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 27 |
+ |
|
| 28 |
+ HStack(spacing: 12) {
|
|
| 29 |
+ changeButton( |
|
| 30 |
+ title: "Added", |
|
| 31 |
+ count: addedCount, |
|
| 32 |
+ percentage: addedPercentage, |
|
| 33 |
+ icon: "plus.circle.fill", |
|
| 34 |
+ color: .healthyGreen, |
|
| 35 |
+ action: onAddedTap |
|
| 36 |
+ ) |
|
| 37 |
+ |
|
| 38 |
+ changeButton( |
|
| 39 |
+ title: "Gone", |
|
| 40 |
+ count: disappearedCount, |
|
| 41 |
+ percentage: disappearedPercentage, |
|
| 42 |
+ icon: "xmark.circle.fill", |
|
| 43 |
+ color: .criticalRed, |
|
| 44 |
+ action: onDisappearedTap |
|
| 45 |
+ ) |
|
| 46 |
+ } |
|
| 47 |
+ |
|
| 48 |
+ if totalCount > 0 {
|
|
| 49 |
+ progressBar |
|
| 50 |
+ } |
|
| 51 |
+ } |
|
| 52 |
+ .padding(16) |
|
| 53 |
+ .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12)) |
|
| 54 |
+ } |
|
| 55 |
+ |
|
| 56 |
+ private func changeButton( |
|
| 57 |
+ title: String, |
|
| 58 |
+ count: Int, |
|
| 59 |
+ percentage: Double, |
|
| 60 |
+ icon: String, |
|
| 61 |
+ color: Color, |
|
| 62 |
+ action: @escaping () -> Void |
|
| 63 |
+ ) -> some View {
|
|
| 64 |
+ Button(action: action) {
|
|
| 65 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 66 |
+ HStack(spacing: 6) {
|
|
| 67 |
+ Image(systemName: icon) |
|
| 68 |
+ .font(.system(size: 14, weight: .semibold)) |
|
| 69 |
+ |
|
| 70 |
+ Text(title) |
|
| 71 |
+ .font(.subheadline.weight(.semibold)) |
|
| 72 |
+ |
|
| 73 |
+ Spacer() |
|
| 74 |
+ |
|
| 75 |
+ Text("\(count)")
|
|
| 76 |
+ .font(.subheadline.weight(.semibold).monospacedDigit()) |
|
| 77 |
+ } |
|
| 78 |
+ .foregroundStyle(color) |
|
| 79 |
+ |
|
| 80 |
+ if percentage > 0 {
|
|
| 81 |
+ HStack(spacing: 4) {
|
|
| 82 |
+ Text(String(format: "%.0f%%", percentage)) |
|
| 83 |
+ .font(.caption.weight(.medium)) |
|
| 84 |
+ Spacer() |
|
| 85 |
+ } |
|
| 86 |
+ .foregroundStyle(.secondary) |
|
| 87 |
+ } |
|
| 88 |
+ } |
|
| 89 |
+ .padding(12) |
|
| 90 |
+ .background(color.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) |
|
| 91 |
+ .contentShape(Rectangle()) |
|
| 92 |
+ } |
|
| 93 |
+ .buttonStyle(.plain) |
|
| 94 |
+ .opacity(count > 0 ? 1 : 0.5) |
|
| 95 |
+ .disabled(count == 0) |
|
| 96 |
+ } |
|
| 97 |
+ |
|
| 98 |
+ @ViewBuilder |
|
| 99 |
+ private var progressBar: some View {
|
|
| 100 |
+ if addedCount > 0 || disappearedCount > 0 {
|
|
| 101 |
+ GeometryReader { geometry in
|
|
| 102 |
+ HStack(spacing: 0) {
|
|
| 103 |
+ if addedCount > 0 {
|
|
| 104 |
+ Color.healthyGreen |
|
| 105 |
+ .frame(width: geometry.size.width * addedPercentage / 100) |
|
| 106 |
+ } |
|
| 107 |
+ if disappearedCount > 0 {
|
|
| 108 |
+ Color.criticalRed |
|
| 109 |
+ .frame(width: geometry.size.width * disappearedPercentage / 100) |
|
| 110 |
+ } |
|
| 111 |
+ } |
|
| 112 |
+ .cornerRadius(4) |
|
| 113 |
+ } |
|
| 114 |
+ .frame(height: 6) |
|
| 115 |
+ .background(Color(.systemBackground), in: RoundedRectangle(cornerRadius: 4)) |
|
| 116 |
+ } |
|
| 117 |
+ } |
|
| 118 |
+} |
|
| 119 |
+ |
|
| 120 |
+#Preview {
|
|
| 121 |
+ VStack(spacing: 20) {
|
|
| 122 |
+ RecordChangeIndicator( |
|
| 123 |
+ addedCount: 45, |
|
| 124 |
+ disappearedCount: 12, |
|
| 125 |
+ totalCount: 500, |
|
| 126 |
+ displayName: "Steps", |
|
| 127 |
+ onAddedTap: { print("Added tapped") },
|
|
| 128 |
+ onDisappearedTap: { print("Disappeared tapped") }
|
|
| 129 |
+ ) |
|
| 130 |
+ |
|
| 131 |
+ RecordChangeIndicator( |
|
| 132 |
+ addedCount: 0, |
|
| 133 |
+ disappearedCount: 8, |
|
| 134 |
+ totalCount: 200, |
|
| 135 |
+ displayName: "Heart Rate", |
|
| 136 |
+ onAddedTap: { print("Added tapped") },
|
|
| 137 |
+ onDisappearedTap: { print("Disappeared tapped") }
|
|
| 138 |
+ ) |
|
| 139 |
+ |
|
| 140 |
+ RecordChangeIndicator( |
|
| 141 |
+ addedCount: 150, |
|
| 142 |
+ disappearedCount: 0, |
|
| 143 |
+ totalCount: 1000, |
|
| 144 |
+ displayName: "Sleep", |
|
| 145 |
+ onAddedTap: { print("Added tapped") },
|
|
| 146 |
+ onDisappearedTap: { print("Disappeared tapped") }
|
|
| 147 |
+ ) |
|
| 148 |
+ } |
|
| 149 |
+ .padding() |
|
| 150 |
+} |
|
@@ -0,0 +1,177 @@ |
||
| 1 |
+import SwiftUI |
|
| 2 |
+ |
|
| 3 |
+/// Visual timeline showing how a data type's count evolved across snapshots |
|
| 4 |
+struct TypeEvolutionTimeline: View {
|
|
| 5 |
+ let snapshots: [HealthSnapshot] |
|
| 6 |
+ let typeIdentifier: String |
|
| 7 |
+ let displayName: String |
|
| 8 |
+ let currentSnapshotID: UUID |
|
| 9 |
+ |
|
| 10 |
+ private var validDataPoints: [(snapshot: HealthSnapshot, count: Int)] {
|
|
| 11 |
+ snapshots |
|
| 12 |
+ .compactMap { snapshot in
|
|
| 13 |
+ guard let typeCount = snapshot.typeCounts?.first(where: { $0.typeIdentifier == typeIdentifier }),
|
|
| 14 |
+ !typeCount.isUnsupported, |
|
| 15 |
+ typeCount.count >= 0 else {
|
|
| 16 |
+ return nil |
|
| 17 |
+ } |
|
| 18 |
+ return (snapshot, typeCount.count) |
|
| 19 |
+ } |
|
| 20 |
+ .sorted { $0.snapshot.timestamp < $1.snapshot.timestamp }
|
|
| 21 |
+ } |
|
| 22 |
+ |
|
| 23 |
+ private var minCount: Int {
|
|
| 24 |
+ validDataPoints.map(\.count).min() ?? 0 |
|
| 25 |
+ } |
|
| 26 |
+ |
|
| 27 |
+ private var maxCount: Int {
|
|
| 28 |
+ validDataPoints.map(\.count).max() ?? 1 |
|
| 29 |
+ } |
|
| 30 |
+ |
|
| 31 |
+ private var countRange: Int {
|
|
| 32 |
+ max(maxCount - minCount, 1) |
|
| 33 |
+ } |
|
| 34 |
+ |
|
| 35 |
+ var body: some View {
|
|
| 36 |
+ if validDataPoints.count < 2 {
|
|
| 37 |
+ return AnyView(EmptyView()) |
|
| 38 |
+ } |
|
| 39 |
+ |
|
| 40 |
+ return AnyView( |
|
| 41 |
+ VStack(alignment: .leading, spacing: 8) {
|
|
| 42 |
+ Text("Evolution")
|
|
| 43 |
+ .font(.headline.weight(.semibold)) |
|
| 44 |
+ |
|
| 45 |
+ HStack(spacing: 4) {
|
|
| 46 |
+ ForEach(Array(validDataPoints.enumerated()), id: \.offset) { index, item in
|
|
| 47 |
+ timelinePoint( |
|
| 48 |
+ snapshot: item.snapshot, |
|
| 49 |
+ count: item.count, |
|
| 50 |
+ index: index, |
|
| 51 |
+ isLast: index == validDataPoints.count - 1 |
|
| 52 |
+ ) |
|
| 53 |
+ |
|
| 54 |
+ if index < validDataPoints.count - 1 {
|
|
| 55 |
+ timelineConnector( |
|
| 56 |
+ from: validDataPoints[index].count, |
|
| 57 |
+ to: validDataPoints[index + 1].count |
|
| 58 |
+ ) |
|
| 59 |
+ } |
|
| 60 |
+ } |
|
| 61 |
+ } |
|
| 62 |
+ |
|
| 63 |
+ compactStatsRow |
|
| 64 |
+ } |
|
| 65 |
+ .padding(16) |
|
| 66 |
+ .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12)) |
|
| 67 |
+ ) |
|
| 68 |
+ } |
|
| 69 |
+ |
|
| 70 |
+ private func timelinePoint( |
|
| 71 |
+ snapshot: HealthSnapshot, |
|
| 72 |
+ count: Int, |
|
| 73 |
+ index: Int, |
|
| 74 |
+ isLast: Bool |
|
| 75 |
+ ) -> some View {
|
|
| 76 |
+ let normalizedHeight = CGFloat(count - minCount) / CGFloat(countRange) |
|
| 77 |
+ let isCurrentSnapshot = snapshot.id == currentSnapshotID |
|
| 78 |
+ let barHeight = 40 + normalizedHeight * 40 |
|
| 79 |
+ |
|
| 80 |
+ return VStack(spacing: 8) {
|
|
| 81 |
+ VStack(spacing: 0) {
|
|
| 82 |
+ Spacer(minLength: 80 - barHeight) |
|
| 83 |
+ |
|
| 84 |
+ RoundedRectangle(cornerRadius: 4) |
|
| 85 |
+ .fill(isCurrentSnapshot ? Color.accentColor : Color.secondary.opacity(0.6)) |
|
| 86 |
+ .frame(height: barHeight) |
|
| 87 |
+ |
|
| 88 |
+ Spacer(minLength: 0) |
|
| 89 |
+ } |
|
| 90 |
+ .frame(height: 80) |
|
| 91 |
+ |
|
| 92 |
+ Text("\(count)")
|
|
| 93 |
+ .font(.caption.weight(.medium).monospacedDigit()) |
|
| 94 |
+ .foregroundStyle(.secondary) |
|
| 95 |
+ |
|
| 96 |
+ Text(snapshot.timestamp, format: .dateTime.month().day()) |
|
| 97 |
+ .font(.caption2) |
|
| 98 |
+ .foregroundStyle(.tertiary) |
|
| 99 |
+ .lineLimit(1) |
|
| 100 |
+ } |
|
| 101 |
+ .frame(maxWidth: .infinity) |
|
| 102 |
+ } |
|
| 103 |
+ |
|
| 104 |
+ private func timelineConnector(from: Int, to: Int) -> some View {
|
|
| 105 |
+ let fromHeight = 40 + CGFloat(from - minCount) / CGFloat(countRange) * 40 |
|
| 106 |
+ let toHeight = 40 + CGFloat(to - minCount) / CGFloat(countRange) * 40 |
|
| 107 |
+ |
|
| 108 |
+ let isIncreasing = to > from |
|
| 109 |
+ let changeIndicator = isIncreasing ? "▲" : "▼" |
|
| 110 |
+ let color = isIncreasing ? Color.healthyGreen : Color.criticalRed |
|
| 111 |
+ |
|
| 112 |
+ return VStack {
|
|
| 113 |
+ Spacer() |
|
| 114 |
+ .frame(height: fromHeight) |
|
| 115 |
+ |
|
| 116 |
+ HStack(spacing: 2) {
|
|
| 117 |
+ Spacer(minLength: 0) |
|
| 118 |
+ Text(changeIndicator) |
|
| 119 |
+ .font(.system(size: 10, weight: .bold)) |
|
| 120 |
+ .foregroundStyle(color) |
|
| 121 |
+ Spacer(minLength: 0) |
|
| 122 |
+ } |
|
| 123 |
+ .frame(height: abs(toHeight - fromHeight).isZero ? 1 : abs(toHeight - fromHeight)) |
|
| 124 |
+ |
|
| 125 |
+ Spacer() |
|
| 126 |
+ } |
|
| 127 |
+ .frame(height: 80) |
|
| 128 |
+ .foregroundStyle(color) |
|
| 129 |
+ } |
|
| 130 |
+ |
|
| 131 |
+ private var compactStatsRow: some View {
|
|
| 132 |
+ HStack(spacing: 16) {
|
|
| 133 |
+ statItem(label: "Min", value: minCount) |
|
| 134 |
+ Divider() |
|
| 135 |
+ statItem(label: "Max", value: maxCount) |
|
| 136 |
+ Divider() |
|
| 137 |
+ if let latest = validDataPoints.last?.count {
|
|
| 138 |
+ statItem(label: "Latest", value: latest) |
|
| 139 |
+ } |
|
| 140 |
+ } |
|
| 141 |
+ .font(.caption) |
|
| 142 |
+ .foregroundStyle(.secondary) |
|
| 143 |
+ } |
|
| 144 |
+ |
|
| 145 |
+ private func statItem(label: String, value: Int) -> some View {
|
|
| 146 |
+ VStack(alignment: .center, spacing: 2) {
|
|
| 147 |
+ Text(label) |
|
| 148 |
+ .font(.caption2.weight(.medium)) |
|
| 149 |
+ Text("\(value)")
|
|
| 150 |
+ .font(.caption.weight(.semibold).monospacedDigit()) |
|
| 151 |
+ .foregroundStyle(.primary) |
|
| 152 |
+ } |
|
| 153 |
+ .frame(maxWidth: .infinity) |
|
| 154 |
+ } |
|
| 155 |
+} |
|
| 156 |
+ |
|
| 157 |
+#Preview {
|
|
| 158 |
+ TypeEvolutionTimeline( |
|
| 159 |
+ snapshots: [ |
|
| 160 |
+ HealthSnapshot(timestamp: Date.now.addingTimeInterval(-86400 * 5), osVersion: "iOS 26", deviceName: "iPhone", deviceID: "device1"), |
|
| 161 |
+ HealthSnapshot(timestamp: Date.now.addingTimeInterval(-86400 * 4), osVersion: "iOS 26", deviceName: "iPhone", deviceID: "device1"), |
|
| 162 |
+ HealthSnapshot(timestamp: Date.now.addingTimeInterval(-86400 * 3), osVersion: "iOS 26", deviceName: "iPhone", deviceID: "device1"), |
|
| 163 |
+ HealthSnapshot(timestamp: Date.now.addingTimeInterval(-86400 * 2), osVersion: "iOS 26", deviceName: "iPhone", deviceID: "device1"), |
|
| 164 |
+ HealthSnapshot(timestamp: Date.now.addingTimeInterval(-86400), osVersion: "iOS 26", deviceName: "iPhone", deviceID: "device1"), |
|
| 165 |
+ HealthSnapshot(timestamp: Date.now, osVersion: "iOS 26", deviceName: "iPhone", deviceID: "device1") |
|
| 166 |
+ ].map { snap in
|
|
| 167 |
+ snap.typeCounts = [ |
|
| 168 |
+ TypeCount(typeIdentifier: "HKQuantityTypeIdentifierStepCount", displayName: "Steps", count: Int.random(in: 800...1500)) |
|
| 169 |
+ ] |
|
| 170 |
+ return snap |
|
| 171 |
+ }, |
|
| 172 |
+ typeIdentifier: "HKQuantityTypeIdentifierStepCount", |
|
| 173 |
+ displayName: "Step Count", |
|
| 174 |
+ currentSnapshotID: UUID() |
|
| 175 |
+ ) |
|
| 176 |
+ .padding() |
|
| 177 |
+} |
|
@@ -10,6 +10,8 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 10 | 10 |
|
| 11 | 11 |
@State private var displayedSnapshot: HealthSnapshot? |
| 12 | 12 |
@State private var diffState: RecordDiffState = .idle |
| 13 |
+ @State private var showAddedRecords = false |
|
| 14 |
+ @State private var showDisappearedRecords = false |
|
| 13 | 15 |
|
| 14 | 16 |
private var currentSnapshot: HealthSnapshot {
|
| 15 | 17 |
displayedSnapshot ?? snapshot |
@@ -70,17 +72,20 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 70 | 72 |
} |
| 71 | 73 |
|
| 72 | 74 |
var body: some View {
|
| 73 |
- List {
|
|
| 74 |
- summarySection |
|
| 75 |
- rangeSection |
|
| 76 |
- if previousSnapshot == nil {
|
|
| 77 |
- noPreviousSection |
|
| 78 |
- } else if currentTypeCount == nil && previousTypeCount == nil {
|
|
| 79 |
- missingCurrentSection |
|
| 80 |
- } else {
|
|
| 81 |
- changeSummarySection |
|
| 82 |
- recordNavigationSection |
|
| 75 |
+ ScrollView {
|
|
| 76 |
+ VStack(spacing: 16) {
|
|
| 77 |
+ if previousSnapshot == nil {
|
|
| 78 |
+ emptyStateContent("No baseline available for this device.", icon: "clock.badge.questionmark")
|
|
| 79 |
+ } else if currentTypeCount == nil && previousTypeCount == nil {
|
|
| 80 |
+ emptyStateContent("Data type not tracked in selected snapshots.", icon: "eye.slash")
|
|
| 81 |
+ } else {
|
|
| 82 |
+ metricComparison |
|
| 83 |
+ typeEvolutionSection |
|
| 84 |
+ dataRangeSection |
|
| 85 |
+ recordChangesSection |
|
| 86 |
+ } |
|
| 83 | 87 |
} |
| 88 |
+ .padding(16) |
|
| 84 | 89 |
} |
| 85 | 90 |
.navigationTitle(displayName) |
| 86 | 91 |
.navigationBarTitleDisplayMode(.inline) |
@@ -250,116 +255,68 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 250 | 255 |
} |
| 251 | 256 |
} |
| 252 | 257 |
|
| 253 |
- private var summarySection: some View {
|
|
| 254 |
- Section("Data Type") {
|
|
| 255 |
- DataTypeDetailRow(label: "Name") {
|
|
| 256 |
- Text(displayName) |
|
| 257 |
- .foregroundStyle(.secondary) |
|
| 258 |
- } |
|
| 259 |
- DataTypeDetailRow(label: "Identifier") {
|
|
| 260 |
- Text(typeIdentifier) |
|
| 261 |
- .font(.caption) |
|
| 262 |
- .foregroundStyle(.secondary) |
|
| 263 |
- .lineLimit(1) |
|
| 264 |
- .truncationMode(.middle) |
|
| 265 |
- } |
|
| 266 |
- DataTypeDetailRow(label: "Current Count") {
|
|
| 267 |
- Text(currentCountText) |
|
| 268 |
- .foregroundStyle((currentTypeCount?.count ?? 0) < 0 ? Color.criticalRed : Color.primary) |
|
| 269 |
- .monospacedDigit() |
|
| 270 |
- } |
|
| 271 |
- DataTypeDetailRow(label: "Previous Count") {
|
|
| 272 |
- Text(previousCountText) |
|
| 273 |
- .foregroundStyle((previousTypeCount?.count ?? 0) < 0 ? Color.criticalRed : .secondary) |
|
| 274 |
- .monospacedDigit() |
|
| 275 |
- } |
|
| 276 |
- } |
|
| 277 |
- } |
|
| 258 |
+ // MARK: - Optimized Content |
|
| 278 | 259 |
|
| 279 |
- private var rangeSection: some View {
|
|
| 280 |
- Section("Range") {
|
|
| 281 |
- DataTypeDateRow(label: "Earliest", date: currentTypeCount?.earliestDate) |
|
| 282 |
- DataTypeDateRow(label: "Latest", date: currentTypeCount?.latestDate) |
|
| 283 |
- if currentTypeCount?.quality != SnapshotQuality.complete {
|
|
| 284 |
- Label("Incomplete type capture", systemImage: "exclamationmark.triangle")
|
|
| 285 |
- .font(.caption) |
|
| 286 |
- .foregroundStyle(Color.warningAmber) |
|
| 287 |
- } |
|
| 260 |
+ @ViewBuilder |
|
| 261 |
+ private var metricComparison: some View {
|
|
| 262 |
+ if previousSnapshot == nil {
|
|
| 263 |
+ EmptyView() |
|
| 264 |
+ } else {
|
|
| 265 |
+ MetricComparisonCard( |
|
| 266 |
+ currentValue: currentTypeCount?.count ?? 0, |
|
| 267 |
+ previousValue: previousTypeCount?.count, |
|
| 268 |
+ displayName: displayName, |
|
| 269 |
+ isCurrentValid: (currentTypeCount?.count ?? 0) >= 0, |
|
| 270 |
+ isPreviousTracked: previousTypeCount != nil |
|
| 271 |
+ ) |
|
| 288 | 272 |
} |
| 289 | 273 |
} |
| 290 | 274 |
|
| 291 |
- private var noPreviousSection: some View {
|
|
| 292 |
- Section("Changes") {
|
|
| 293 |
- Text("No previous snapshot is available for this device.")
|
|
| 294 |
- .font(.subheadline) |
|
| 295 |
- .foregroundStyle(.secondary) |
|
| 275 |
+ @ViewBuilder |
|
| 276 |
+ private var typeEvolutionSection: some View {
|
|
| 277 |
+ if previousSnapshot != nil {
|
|
| 278 |
+ TypeEvolutionTimeline( |
|
| 279 |
+ snapshots: timelineSnapshots, |
|
| 280 |
+ typeIdentifier: typeIdentifier, |
|
| 281 |
+ displayName: displayName, |
|
| 282 |
+ currentSnapshotID: currentSnapshot.id |
|
| 283 |
+ ) |
|
| 296 | 284 |
} |
| 297 | 285 |
} |
| 298 | 286 |
|
| 299 |
- private var missingCurrentSection: some View {
|
|
| 300 |
- Section("Changes") {
|
|
| 301 |
- Text("This data type is not tracked in the selected snapshot.")
|
|
| 302 |
- .font(.subheadline) |
|
| 303 |
- .foregroundStyle(.secondary) |
|
| 287 |
+ @ViewBuilder |
|
| 288 |
+ private var dataRangeSection: some View {
|
|
| 289 |
+ if currentTypeCount != nil {
|
|
| 290 |
+ DataTypeRangeIndicator( |
|
| 291 |
+ earliestDate: currentTypeCount?.earliestDate, |
|
| 292 |
+ latestDate: currentTypeCount?.latestDate, |
|
| 293 |
+ quality: currentTypeCount?.quality ?? .complete |
|
| 294 |
+ ) |
|
| 304 | 295 |
} |
| 305 | 296 |
} |
| 306 | 297 |
|
| 307 |
- private var changeSummarySection: some View {
|
|
| 308 |
- Section("Changes") {
|
|
| 309 |
- DataTypeDetailRow(label: "Compared To") {
|
|
| 310 |
- if let previousSnapshot {
|
|
| 311 |
- Text(previousSnapshot.timestamp, format: .dateTime.year().month().day().hour().minute()) |
|
| 312 |
- .foregroundStyle(.secondary) |
|
| 313 |
- } else {
|
|
| 314 |
- Text("None")
|
|
| 315 |
- .foregroundStyle(.secondary) |
|
| 316 |
- } |
|
| 317 |
- } |
|
| 318 |
- DataTypeDetailRow(label: "Net Change") {
|
|
| 319 |
- if let totalDelta {
|
|
| 320 |
- SeverityBadge(delta: totalDelta) |
|
| 321 |
- } else {
|
|
| 322 |
- Text("Unavailable")
|
|
| 323 |
- .foregroundStyle(.secondary) |
|
| 324 |
- } |
|
| 325 |
- } |
|
| 326 |
- recordCountSummaryRow(title: "Added Records", countText: addedRecordCountText, color: addedRecordCountColor) |
|
| 327 |
- recordCountSummaryRow(title: "Disappeared Records", countText: disappearedRecordCountText, color: disappearedRecordCountColor) |
|
| 298 |
+ @ViewBuilder |
|
| 299 |
+ private var recordChangesSection: some View {
|
|
| 300 |
+ if previousSnapshot != nil {
|
|
| 328 | 301 |
switch diffState {
|
| 329 |
- case .idle, .loading: |
|
| 330 |
- Label("Preparing record comparison in the background.", systemImage: "clock")
|
|
| 331 |
- .font(.caption) |
|
| 332 |
- .foregroundStyle(.secondary) |
|
| 333 |
- case .unavailable: |
|
| 334 |
- Label("This snapshot uses a legacy record format. Recreate the database to inspect record-level loss.", systemImage: "exclamationmark.triangle")
|
|
| 335 |
- .font(.caption) |
|
| 336 |
- .foregroundStyle(Color.warningAmber) |
|
| 337 |
- case .failed(let message): |
|
| 338 |
- Label(message, systemImage: "exclamationmark.triangle") |
|
| 339 |
- .font(.caption) |
|
| 340 |
- .foregroundStyle(Color.warningAmber) |
|
| 341 | 302 |
case .loaded(let diff): |
| 342 |
- if diff.isPreviewLimited {
|
|
| 343 |
- Text("Showing newest \(DataTypeRecordDiff.previewLimit) records in each list.")
|
|
| 344 |
- .font(.caption) |
|
| 345 |
- .foregroundStyle(.secondary) |
|
| 346 |
- } |
|
| 347 |
- } |
|
| 348 |
- } |
|
| 349 |
- } |
|
| 350 |
- |
|
| 351 |
- private func recordCountSummaryRow(title: String, countText: String, color: Color) -> some View {
|
|
| 352 |
- DataTypeDetailRow(label: title) {
|
|
| 353 |
- Text(countText) |
|
| 354 |
- .foregroundStyle(color) |
|
| 355 |
- .monospacedDigit() |
|
| 356 |
- } |
|
| 357 |
- } |
|
| 358 |
- |
|
| 359 |
- private var recordNavigationSection: some View {
|
|
| 360 |
- Section("Records") {
|
|
| 361 |
- if case .loaded(let diff) = diffState {
|
|
| 362 |
- NavigationLink {
|
|
| 303 |
+ RecordChangeIndicator( |
|
| 304 |
+ addedCount: diff.addedCount, |
|
| 305 |
+ disappearedCount: diff.disappearedCount, |
|
| 306 |
+ totalCount: diff.addedCount + diff.disappearedCount, |
|
| 307 |
+ displayName: displayName, |
|
| 308 |
+ onAddedTap: {
|
|
| 309 |
+ if diff.addedCount > 0 {
|
|
| 310 |
+ showAddedRecords = true |
|
| 311 |
+ } |
|
| 312 |
+ }, |
|
| 313 |
+ onDisappearedTap: {
|
|
| 314 |
+ if diff.disappearedCount > 0 {
|
|
| 315 |
+ showDisappearedRecords = true |
|
| 316 |
+ } |
|
| 317 |
+ } |
|
| 318 |
+ ) |
|
| 319 |
+ .navigationDestination(isPresented: $showAddedRecords) {
|
|
| 363 | 320 |
DataTypeRecordListView( |
| 364 | 321 |
title: "Added Records", |
| 365 | 322 |
displayName: displayName, |
@@ -367,16 +324,8 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 367 | 324 |
totalCount: diff.addedCount, |
| 368 | 325 |
tint: Color.healthyGreen |
| 369 | 326 |
) |
| 370 |
- } label: {
|
|
| 371 |
- RecordNavigationRow( |
|
| 372 |
- title: "Added Records", |
|
| 373 |
- count: diff.addedCount, |
|
| 374 |
- tint: Color.healthyGreen |
|
| 375 |
- ) |
|
| 376 | 327 |
} |
| 377 |
- .disabled(diff.addedCount == 0) |
|
| 378 |
- |
|
| 379 |
- NavigationLink {
|
|
| 328 |
+ .navigationDestination(isPresented: $showDisappearedRecords) {
|
|
| 380 | 329 |
DataTypeRecordListView( |
| 381 | 330 |
title: "Disappeared Records", |
| 382 | 331 |
displayName: displayName, |
@@ -384,72 +333,55 @@ struct DataTypeSnapshotDetailView: View {
|
||
| 384 | 333 |
totalCount: diff.disappearedCount, |
| 385 | 334 |
tint: Color.criticalRed |
| 386 | 335 |
) |
| 387 |
- } label: {
|
|
| 388 |
- RecordNavigationRow( |
|
| 389 |
- title: "Disappeared Records", |
|
| 390 |
- count: diff.disappearedCount, |
|
| 391 |
- tint: Color.criticalRed |
|
| 392 |
- ) |
|
| 393 | 336 |
} |
| 394 |
- .disabled(diff.disappearedCount == 0) |
|
| 395 |
- } else {
|
|
| 396 |
- HStack {
|
|
| 337 |
+ |
|
| 338 |
+ case .unavailable: |
|
| 339 |
+ Label( |
|
| 340 |
+ "Legacy record format. Recreate database to inspect details.", |
|
| 341 |
+ systemImage: "exclamationmark.triangle.fill" |
|
| 342 |
+ ) |
|
| 343 |
+ .font(.subheadline) |
|
| 344 |
+ .foregroundStyle(Color.warningAmber) |
|
| 345 |
+ .padding(12) |
|
| 346 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 347 |
+ .background(Color.warningAmber.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) |
|
| 348 |
+ |
|
| 349 |
+ case .failed(let message): |
|
| 350 |
+ Label(message, systemImage: "exclamationmark.triangle.fill") |
|
| 351 |
+ .font(.subheadline) |
|
| 352 |
+ .foregroundStyle(Color.warningAmber) |
|
| 353 |
+ .padding(12) |
|
| 354 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 355 |
+ .background(Color.warningAmber.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) |
|
| 356 |
+ |
|
| 357 |
+ case .idle, .loading: |
|
| 358 |
+ HStack(spacing: 8) {
|
|
| 397 | 359 |
ProgressView() |
| 398 |
- Text("Preparing record lists")
|
|
| 360 |
+ Text("Analyzing record changes...")
|
|
| 361 |
+ .font(.subheadline) |
|
| 399 | 362 |
.foregroundStyle(.secondary) |
| 400 | 363 |
} |
| 364 |
+ .frame(maxWidth: .infinity, alignment: .leading) |
|
| 365 |
+ .padding(12) |
|
| 366 |
+ .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) |
|
| 401 | 367 |
} |
| 402 | 368 |
} |
| 403 | 369 |
} |
| 404 |
-} |
|
| 405 |
- |
|
| 406 |
-private struct RecordNavigationRow: View {
|
|
| 407 |
- let title: String |
|
| 408 |
- let count: Int |
|
| 409 |
- let tint: Color |
|
| 410 |
- |
|
| 411 |
- var body: some View {
|
|
| 412 |
- HStack {
|
|
| 413 |
- Text(title) |
|
| 414 |
- Spacer() |
|
| 415 |
- Text("\(count)")
|
|
| 416 |
- .foregroundStyle(count > 0 ? tint : .secondary) |
|
| 417 |
- .monospacedDigit() |
|
| 418 |
- } |
|
| 419 |
- } |
|
| 420 |
-} |
|
| 421 |
- |
|
| 422 |
-private extension DataTypeSnapshotDetailView {
|
|
| 423 |
- private var addedRecordCountText: String {
|
|
| 424 |
- switch diffState {
|
|
| 425 |
- case .loaded(let diff): return "\(diff.addedCount)" |
|
| 426 |
- case .unavailable: return "Legacy" |
|
| 427 |
- case .failed: return "Failed" |
|
| 428 |
- case .idle, .loading: return "Loading" |
|
| 429 |
- } |
|
| 430 |
- } |
|
| 431 | 370 |
|
| 432 |
- private var disappearedRecordCountText: String {
|
|
| 433 |
- switch diffState {
|
|
| 434 |
- case .loaded(let diff): return "\(diff.disappearedCount)" |
|
| 435 |
- case .unavailable: return "Legacy" |
|
| 436 |
- case .failed: return "Failed" |
|
| 437 |
- case .idle, .loading: return "Loading" |
|
| 438 |
- } |
|
| 439 |
- } |
|
| 440 |
- |
|
| 441 |
- private var addedRecordCountColor: Color {
|
|
| 442 |
- if case .loaded(let diff) = diffState, diff.addedCount > 0 {
|
|
| 443 |
- return Color.healthyGreen |
|
| 444 |
- } |
|
| 445 |
- return .secondary |
|
| 446 |
- } |
|
| 371 |
+ private func emptyStateContent(_ message: String, icon: String) -> some View {
|
|
| 372 |
+ VStack(spacing: 12) {
|
|
| 373 |
+ Image(systemName: icon) |
|
| 374 |
+ .font(.system(size: 32, weight: .semibold)) |
|
| 375 |
+ .foregroundStyle(.secondary) |
|
| 447 | 376 |
|
| 448 |
- private var disappearedRecordCountColor: Color {
|
|
| 449 |
- if case .loaded(let diff) = diffState, diff.disappearedCount > 0 {
|
|
| 450 |
- return Color.criticalRed |
|
| 377 |
+ Text(message) |
|
| 378 |
+ .font(.subheadline) |
|
| 379 |
+ .foregroundStyle(.secondary) |
|
| 380 |
+ .multilineTextAlignment(.center) |
|
| 451 | 381 |
} |
| 452 |
- return .secondary |
|
| 382 |
+ .padding(24) |
|
| 383 |
+ .frame(maxWidth: .infinity) |
|
| 384 |
+ .background(Color(.systemBackground).opacity(0.5), in: RoundedRectangle(cornerRadius: 12)) |
|
| 453 | 385 |
} |
| 454 | 386 |
|
| 455 | 387 |
@MainActor |
@@ -619,23 +551,6 @@ private struct DataTypeRecordRow: View {
|
||
| 619 | 551 |
} |
| 620 | 552 |
} |
| 621 | 553 |
|
| 622 |
-private struct DataTypeDateRow: View {
|
|
| 623 |
- let label: String |
|
| 624 |
- let date: Date? |
|
| 625 |
- |
|
| 626 |
- var body: some View {
|
|
| 627 |
- DataTypeDetailRow(label: label) {
|
|
| 628 |
- if let date {
|
|
| 629 |
- Text(date, format: .dateTime.year().month().day().hour().minute()) |
|
| 630 |
- .foregroundStyle(.secondary) |
|
| 631 |
- } else {
|
|
| 632 |
- Text("Unavailable")
|
|
| 633 |
- .foregroundStyle(.secondary) |
|
| 634 |
- } |
|
| 635 |
- } |
|
| 636 |
- } |
|
| 637 |
-} |
|
| 638 |
- |
|
| 639 | 554 |
private struct DataTypeDetailRow<Content: View>: View {
|
| 640 | 555 |
let label: String |
| 641 | 556 |
@ViewBuilder let content: () -> Content |
@@ -0,0 +1,143 @@ |
||
| 1 |
+# Data Type Views Optimization – Visual Redesign |
|
| 2 |
+ |
|
| 3 |
+## Summary |
|
| 4 |
+ |
|
| 5 |
+Optimized data type detail views by replacing table-heavy layouts with graphics-based visual indicators. Emphasis on **deduplication**, **symbol-driven transitions**, and **evolution visualization** rather than textual data rows. |
|
| 6 |
+ |
|
| 7 |
+--- |
|
| 8 |
+ |
|
| 9 |
+## New Components Created |
|
| 10 |
+ |
|
| 11 |
+### 1. **MetricComparisonCard** (`Views/DataTypes/MetricComparisonCard.swift`) |
|
| 12 |
+Visual metric comparison between current and previous snapshot with: |
|
| 13 |
+- Side-by-side value boxes for "Now" vs "Before" |
|
| 14 |
+- Delta indicator with directional arrow (↑ ↓ -) |
|
| 15 |
+- Percentage change display |
|
| 16 |
+- Color-coded severity badge |
|
| 17 |
+- Deduplicates textual row-based comparison |
|
| 18 |
+ |
|
| 19 |
+### 2. **TypeEvolutionTimeline** (`Views/DataTypes/TypeEvolutionTimeline.swift`) |
|
| 20 |
+Timeline visualization across multiple snapshots showing: |
|
| 21 |
+- Bar chart of count evolution over time |
|
| 22 |
+- Current snapshot highlighted |
|
| 23 |
+- Vertical connectors showing increase/decrease transitions (▲ ▼) |
|
| 24 |
+- Compact stats row (Min/Max/Latest) |
|
| 25 |
+- Deduplicates redundant date/count rows |
|
| 26 |
+ |
|
| 27 |
+### 3. **RecordChangeIndicator** (`Views/DataTypes/RecordChangeIndicator.swift`) |
|
| 28 |
+Visual record change summary with: |
|
| 29 |
+- Two interactive buttons: "Added" (green) and "Gone" (red) |
|
| 30 |
+- Percentage breakdowns |
|
| 31 |
+- Stacked progress bar visualization |
|
| 32 |
+- Navigates to detail lists on tap |
|
| 33 |
+- More compact than section-based rows |
|
| 34 |
+ |
|
| 35 |
+### 4. **DataTypeRangeIndicator** (`Views/DataTypes/DataTypeRangeIndicator.swift`) |
|
| 36 |
+Data range visualization showing: |
|
| 37 |
+- Earliest and latest date with calendar icons |
|
| 38 |
+- Day span counter in center |
|
| 39 |
+- Gradient timeline bar |
|
| 40 |
+- Quality badge for incomplete captures |
|
| 41 |
+- Single card replaces 3 rows (Earliest, Latest, Quality) |
|
| 42 |
+ |
|
| 43 |
+--- |
|
| 44 |
+ |
|
| 45 |
+## Refactored Views |
|
| 46 |
+ |
|
| 47 |
+### `DataTypeSnapshotDetailView` (Snapshots folder) |
|
| 48 |
+ |
|
| 49 |
+**Before:** |
|
| 50 |
+- List-based layout with 8+ sections |
|
| 51 |
+- `summarySection`: repeated count values in rows |
|
| 52 |
+- `rangeSection`: 2 separate date rows + quality row |
|
| 53 |
+- `changeSummarySection`: 5 rows of comparison data |
|
| 54 |
+- `recordNavigationSection`: navigation links as rows |
|
| 55 |
+ |
|
| 56 |
+**After:** |
|
| 57 |
+- ScrollView with VStack layout |
|
| 58 |
+- Uses 4 optimized components: |
|
| 59 |
+ - `MetricComparisonCard` (replaces summary + comparison rows) |
|
| 60 |
+ - `TypeEvolutionTimeline` (replaces implicit count history) |
|
| 61 |
+ - `DataTypeRangeIndicator` (replaces 3 date/quality rows) |
|
| 62 |
+ - `RecordChangeIndicator` (replaces navigation rows) |
|
| 63 |
+- 220 lines removed (40% reduction) |
|
| 64 |
+- Empty state graphics instead of generic text |
|
| 65 |
+ |
|
| 66 |
+### `DataTypesView` (DataTypes folder) |
|
| 67 |
+ |
|
| 68 |
+**Before:** |
|
| 69 |
+- `TypeDiffRow`: dense text-only row |
|
| 70 |
+ - Stacked metrics with labels and values |
|
| 71 |
+ - Hard to scan visually |
|
| 72 |
+ - Minimal visual hierarchy |
|
| 73 |
+ |
|
| 74 |
+**After:** |
|
| 75 |
+- Enhanced `TypeDiffRow` with: |
|
| 76 |
+ - Color-coded metric boxes (Now / Before) |
|
| 77 |
+ - Directional delta indicator (↑ green, ↓ red, - gray, ✨ new) |
|
| 78 |
+ - Severity badge on same row |
|
| 79 |
+ - Better visual rhythm and scanability |
|
| 80 |
+- Added navigation to detail view |
|
| 81 |
+- Compact metrics display with proper typography hierarchy |
|
| 82 |
+ |
|
| 83 |
+--- |
|
| 84 |
+ |
|
| 85 |
+## Design Improvements |
|
| 86 |
+ |
|
| 87 |
+### Deduplication Achieved |
|
| 88 |
+- **Before:** Same information repeated in text form (rows) + badge visualization |
|
| 89 |
+- **After:** Information expressed once through visual components |
|
| 90 |
+ - Counts → colored metric boxes |
|
| 91 |
+ - Delta direction → arrow icons |
|
| 92 |
+ - Change severity → color + badge |
|
| 93 |
+ - Date ranges → timeline visualization |
|
| 94 |
+ |
|
| 95 |
+### Graphics > Tables |
|
| 96 |
+- Vertical bar chart for evolution (TypeEvolutionTimeline) |
|
| 97 |
+- Gradient timeline for date ranges (DataTypeRangeIndicator) |
|
| 98 |
+- Progress bar for record distribution (RecordChangeIndicator) |
|
| 99 |
+- Directional arrows for transitions (↑ ↓ -) instead of text labels |
|
| 100 |
+ |
|
| 101 |
+### Visual Transitions & Evolution |
|
| 102 |
+- **Direction indicators:** ↑ increase (green), ↓ decrease (red), - stable (gray), ✨ new |
|
| 103 |
+- **Timeline bars:** Show 5-point progression of counts over time |
|
| 104 |
+- **Gradient fills:** Earliest → Latest date range |
|
| 105 |
+- **Icon rendering:** Hierarchical SF Symbols for signal priority |
|
| 106 |
+ |
|
| 107 |
+--- |
|
| 108 |
+ |
|
| 109 |
+## File Changes Summary |
|
| 110 |
+ |
|
| 111 |
+| File | Change | Impact | |
|
| 112 |
+|------|--------|--------| |
|
| 113 |
+| `DataTypeSnapshotDetailView.swift` | Replaced List + sections with ScrollView + cards | -220 lines, more visual | |
|
| 114 |
+| `DataTypesView.swift` | Enhanced row design + navigation | Better scannability | |
|
| 115 |
+| `MetricComparisonCard.swift` | NEW | Shows current vs previous at a glance | |
|
| 116 |
+| `TypeEvolutionTimeline.swift` | NEW | Visualizes count trends over time | |
|
| 117 |
+| `RecordChangeIndicator.swift` | NEW | Visual record diff summary | |
|
| 118 |
+| `DataTypeRangeIndicator.swift` | NEW | Compact date range + quality display | |
|
| 119 |
+ |
|
| 120 |
+--- |
|
| 121 |
+ |
|
| 122 |
+## Testing Checklist |
|
| 123 |
+ |
|
| 124 |
+- [ ] `MetricComparisonCard` preview renders in Light/Dark modes |
|
| 125 |
+- [ ] `TypeEvolutionTimeline` handles 2+ snapshots correctly |
|
| 126 |
+- [ ] `RecordChangeIndicator` buttons navigate to record lists |
|
| 127 |
+- [ ] `DataTypeRangeIndicator` shows complete state with/without dates |
|
| 128 |
+- [ ] `DataTypeSnapshotDetailView` displays all 4 cards in correct order |
|
| 129 |
+- [ ] Navigation from list to detail view works |
|
| 130 |
+- [ ] Empty states show proper icons |
|
| 131 |
+- [ ] Accessibility labels set on interactive elements |
|
| 132 |
+- [ ] VoiceOver reads evolution timeline sensibly |
|
| 133 |
+- [ ] No health values visible without explicit tap |
|
| 134 |
+ |
|
| 135 |
+--- |
|
| 136 |
+ |
|
| 137 |
+## Next Steps |
|
| 138 |
+ |
|
| 139 |
+- Integrate updated views into live app preview |
|
| 140 |
+- Verify record list navigation paths |
|
| 141 |
+- Test with varying data volumes (1 snapshot, 10+, sparse data) |
|
| 142 |
+- Adjust spacing/typography based on visual testing |
|
| 143 |
+- Consider adding animation to timeline transitions |
|