Showing 1 changed files with 167 additions and 22 deletions
+167 -22
HealthProbe/Views/Snapshots/SnapshotDetailView.swift
@@ -13,10 +13,15 @@ struct SnapshotDetailView: View {
13 13
 
14 14
     private let diffService = SnapshotDiffService.shared
15 15
 
16
-    @State private var xAxisMode: EvolutionXAxisMode = .time
16
+    @State private var xAxisMode: EvolutionXAxisMode = .snapshots
17
+    @State private var displayedSnapshot: HealthSnapshot?
18
+
19
+    private var currentSnapshot: HealthSnapshot {
20
+        displayedSnapshot ?? snapshot
21
+    }
17 22
 
18 23
     private var sortedTypeCounts: [TypeCount] {
19
-        (snapshot.typeCounts ?? []).sorted {
24
+        (currentSnapshot.typeCounts ?? []).sorted {
20 25
             $0.displayName.localizedCompare($1.displayName) == .orderedAscending
21 26
         }
22 27
     }
@@ -35,21 +40,21 @@ struct SnapshotDetailView: View {
35 40
 
36 41
     private var deviceDisplayName: String {
37 42
         if let name = profile?.name, !name.isEmpty { return name }
38
-        return snapshot.deviceName.isEmpty ? "Unknown device" : snapshot.deviceName
43
+        return currentSnapshot.deviceName.isEmpty ? "Unknown device" : currentSnapshot.deviceName
39 44
     }
40 45
 
41 46
     private var timelineSnapshots: [HealthSnapshot] {
42 47
         allSnapshots.filter { candidate in
43
-            if snapshot.deviceID.isEmpty {
48
+            if currentSnapshot.deviceID.isEmpty {
44 49
                 return candidate.deviceID.isEmpty
45 50
             }
46
-            return candidate.deviceID == snapshot.deviceID
51
+            return candidate.deviceID == currentSnapshot.deviceID
47 52
         }
48 53
     }
49 54
 
50 55
     private var timelineContextSnapshots: [HealthSnapshot] {
51
-        guard let currentIndex = timelineSnapshots.firstIndex(where: { $0.id == snapshot.id }) else {
52
-            return [snapshot]
56
+        guard let currentIndex = timelineSnapshots.firstIndex(where: { $0.id == currentSnapshot.id }) else {
57
+            return [currentSnapshot]
53 58
         }
54 59
 
55 60
         let desiredCount = contextRadius * 2 + 1
@@ -83,6 +88,28 @@ struct SnapshotDetailView: View {
83 88
         timelineContextSnapshots.count < timelineSnapshots.count
84 89
     }
85 90
 
91
+    private var timelineSnapshotNumbers: [UUID: Int] {
92
+        Dictionary(
93
+            uniqueKeysWithValues: timelineSnapshots.enumerated().map { index, snapshot in
94
+                (snapshot.id, index + 1)
95
+            }
96
+        )
97
+    }
98
+
99
+    private var currentSnapshotIndex: Int? {
100
+        timelineSnapshots.firstIndex(where: { $0.id == currentSnapshot.id })
101
+    }
102
+
103
+    private var previousSnapshot: HealthSnapshot? {
104
+        guard let currentIndex = currentSnapshotIndex, currentIndex > 0 else { return nil }
105
+        return timelineSnapshots[currentIndex - 1]
106
+    }
107
+
108
+    private var nextSnapshot: HealthSnapshot? {
109
+        guard let currentIndex = currentSnapshotIndex, currentIndex < timelineSnapshots.count - 1 else { return nil }
110
+        return timelineSnapshots[currentIndex + 1]
111
+    }
112
+
86 113
     private var evolutionSeries: [TypeEvolutionSeries] {
87 114
         sortedTypeCounts.compactMap { typeCount in
88 115
             let points = timelineContextSnapshots.compactMap { candidate -> TypeEvolutionPoint? in
@@ -125,7 +152,14 @@ struct SnapshotDetailView: View {
125 152
         }
126 153
         .navigationTitle("Snapshot")
127 154
         .navigationBarTitleDisplayMode(.inline)
155
+        .safeAreaInset(edge: .top, spacing: 0) {
156
+            snapshotNavigationHeader
157
+                .frame(height: 64)
158
+        }
128 159
         .toolbar {
160
+            ToolbarItem(placement: .principal) {
161
+                snapshotToolbarTitle
162
+            }
129 163
             ToolbarItem(placement: .navigationBarTrailing) {
130 164
                 if isExporting {
131 165
                     ProgressView()
@@ -151,11 +185,11 @@ struct SnapshotDetailView: View {
151 185
     private func exportAsPDF() {
152 186
         isExporting = true
153 187
         let reportData = SnapshotPDFExporter.extractReportData(
154
-            snapshot: snapshot,
188
+            snapshot: currentSnapshot,
155 189
             baseline: baseline,
156 190
             profile: profile
157 191
         )
158
-        let timestamp = snapshot.timestamp
192
+        let timestamp = currentSnapshot.timestamp
159 193
         Task(priority: .userInitiated) {
160 194
             let pdfData = SnapshotPDFExporter.generatePDF(from: reportData)
161 195
             let formatter = DateFormatter()
@@ -169,10 +203,124 @@ struct SnapshotDetailView: View {
169 203
         }
170 204
     }
171 205
 
206
+    @ViewBuilder
207
+    private var snapshotToolbarTitle: some View {
208
+        if #available(iOS 26.0, *) {
209
+            Text("Snapshot")
210
+                .font(.headline.weight(.semibold))
211
+                .padding(.horizontal, 18)
212
+                .frame(height: 36)
213
+                .background(Color(.systemBackground).opacity(0.08), in: Capsule())
214
+                .glassEffect(
215
+                    .regular.tint(Color(.systemBackground).opacity(0.12)),
216
+                    in: Capsule()
217
+                )
218
+        } else {
219
+            Text("Snapshot")
220
+                .font(.headline.weight(.semibold))
221
+                .padding(.horizontal, 18)
222
+                .frame(height: 36)
223
+                .background(.ultraThinMaterial, in: Capsule())
224
+        }
225
+    }
226
+
227
+    @ViewBuilder
228
+    private var snapshotNavigationHeader: some View {
229
+        if #available(iOS 26.0, *) {
230
+            GlassEffectContainer(spacing: 10) {
231
+                snapshotNavigationHeaderContent
232
+                    .padding(.horizontal, 12)
233
+                    .frame(height: 52)
234
+                    .background(Color(.systemBackground).opacity(0.08), in: Capsule())
235
+                    .glassEffect(
236
+                        .regular.tint(Color(.systemBackground).opacity(0.14)),
237
+                        in: Capsule()
238
+                    )
239
+                    .shadow(color: .black.opacity(0.18), radius: 18, x: 0, y: 8)
240
+            }
241
+            .padding(.horizontal, 12)
242
+            .padding(.vertical, 6)
243
+        } else {
244
+            snapshotNavigationHeaderContent
245
+                .padding(.horizontal, 12)
246
+                .frame(height: 52)
247
+                .background(.ultraThinMaterial, in: Capsule())
248
+                .overlay(
249
+                    Capsule()
250
+                        .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
251
+                )
252
+                .shadow(color: .black.opacity(0.14), radius: 16, x: 0, y: 8)
253
+                .padding(.horizontal, 12)
254
+                .padding(.vertical, 6)
255
+        }
256
+    }
257
+
258
+    private var snapshotNavigationHeaderContent: some View {
259
+        HStack(spacing: 12) {
260
+            snapshotNavigationButton(
261
+                systemName: "chevron.left",
262
+                label: "Prev",
263
+                target: previousSnapshot,
264
+                accessibilityLabel: "Previous snapshot"
265
+            )
266
+
267
+            Spacer(minLength: 8)
268
+
269
+            VStack(spacing: 2) {
270
+                Text("Snapshot")
271
+                    .font(.headline.weight(.semibold))
272
+                Text(currentSnapshot.timestamp, format: .dateTime.month().day().hour().minute())
273
+                    .font(.caption)
274
+                    .foregroundStyle(.secondary)
275
+            }
276
+            .lineLimit(1)
277
+
278
+            Spacer(minLength: 8)
279
+
280
+            snapshotNavigationButton(
281
+                systemName: "chevron.right",
282
+                label: "Next",
283
+                target: nextSnapshot,
284
+                accessibilityLabel: "Next snapshot"
285
+            )
286
+        }
287
+    }
288
+
289
+    @ViewBuilder
290
+    private func snapshotNavigationButton(
291
+        systemName: String,
292
+        label: String,
293
+        target: HealthSnapshot?,
294
+        accessibilityLabel: String
295
+    ) -> some View {
296
+        if let target {
297
+            Button {
298
+                displayedSnapshot = target
299
+            } label: {
300
+                VStack(spacing: 2) {
301
+                    Image(systemName: systemName)
302
+                        .font(.system(size: 23, weight: .regular))
303
+                        .symbolRenderingMode(.hierarchical)
304
+                    Text(label)
305
+                        .font(.caption2.weight(.medium))
306
+                        .lineLimit(1)
307
+                }
308
+                .frame(width: 70, height: 50)
309
+                .contentShape(Rectangle())
310
+            }
311
+            .buttonStyle(.plain)
312
+            .foregroundStyle(Color.primary)
313
+            .accessibilityLabel(accessibilityLabel)
314
+        } else {
315
+            Color.clear
316
+                .frame(width: 70, height: 50)
317
+        }
318
+    }
319
+
172 320
     private var summarySection: some View {
173 321
         Section("Summary") {
174 322
             DetailRow(label: "Captured") {
175
-                Text(snapshot.timestamp, format: .dateTime.year().month().day().hour().minute())
323
+                Text(currentSnapshot.timestamp, format: .dateTime.year().month().day().hour().minute())
176 324
                     .foregroundStyle(.secondary)
177 325
             }
178 326
             DetailRow(label: "Tracked Types") {
@@ -194,7 +342,7 @@ struct SnapshotDetailView: View {
194 342
                     .foregroundStyle(.secondary)
195 343
             }
196 344
             DetailRow(label: "OS") {
197
-                Text(snapshot.osVersion)
345
+                Text(currentSnapshot.osVersion)
198 346
                     .foregroundStyle(.secondary)
199 347
             }
200 348
         }
@@ -207,7 +355,7 @@ struct SnapshotDetailView: View {
207 355
                     .foregroundStyle(.secondary)
208 356
             }
209 357
             DetailRow(label: "Changes") {
210
-                let delta = diffService.totalAbsoluteChange(current: snapshot, baseline: baseline)
358
+                let delta = diffService.totalAbsoluteChange(current: currentSnapshot, baseline: baseline)
211 359
                 Text(delta == 0 ? "None" : "\(delta) records")
212 360
                     .foregroundStyle(delta == 0 ? Color.healthyGreen : Color.warningAmber)
213 361
             }
@@ -247,8 +395,9 @@ struct SnapshotDetailView: View {
247 395
                         series: series,
248 396
                         contextSnapshots: timelineContextSnapshots,
249 397
                         xAxisMode: xAxisMode,
250
-                        selectedSnapshotID: snapshot.id,
251
-                        selectedTimestamp: snapshot.timestamp,
398
+                        selectedSnapshotID: currentSnapshot.id,
399
+                        selectedTimestamp: currentSnapshot.timestamp,
400
+                        snapshotNumbers: timelineSnapshotNumbers,
252 401
                         baselineTypeCount: baselineTypeMap[series.typeIdentifier]
253 402
                     )
254 403
                 }
@@ -323,6 +472,7 @@ private struct TypeEvolutionChart: View {
323 472
     let xAxisMode: EvolutionXAxisMode
324 473
     let selectedSnapshotID: UUID
325 474
     let selectedTimestamp: Date
475
+    let snapshotNumbers: [UUID: Int]
326 476
     let baselineTypeCount: TypeCount?
327 477
 
328 478
     private struct SnapshotAxisPoint: Identifiable {
@@ -405,14 +555,9 @@ private struct TypeEvolutionChart: View {
405 555
     }
406 556
 
407 557
     private func snapshotAxisLabel(for index: Int) -> String {
408
-        guard let selectedContextIndex else {
409
-            return "S\(index + 1)"
410
-        }
411
-
412
-        let offset = index - selectedContextIndex
413
-        if offset == 0 { return "Current" }
414
-        if offset > 0 { return "S+\(offset)" }
415
-        return "S\(offset)"
558
+        guard contextSnapshots.indices.contains(index) else { return "\(index + 1)" }
559
+        let snapshotID = contextSnapshots[index].id
560
+        return "\(snapshotNumbers[snapshotID] ?? index + 1)"
416 561
     }
417 562
 
418 563
     private var snapshotAxisDomain: ClosedRange<Int> {