@@ -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> {
|