// // WorkoutSummaryView.swift // Workouts // // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // import SwiftUI import SwiftData /// Post-workout summary shown right after a workout finishes. Queried by id so the /// health metrics (calories, heart rate) fill in live as they arrive from the watch /// over the WatchConnectivity bridge. struct WorkoutSummaryView: View { @Environment(\.dismiss) private var dismiss @Query private var workouts: [Workout] let onDone: () -> Void init(workoutID: String, onDone: @escaping () -> Void) { self.onDone = onDone _workouts = Query(filter: #Predicate { $0.id == workoutID }) } private var workout: Workout? { workouts.first } var body: some View { NavigationStack { Group { // The fromLive guard also covers the query vending a workout whose // cache row was deleted within the same view-graph update (remote // delete / reconcile prune) — reading a dead @Model traps. if let workout, let doc = WorkoutDocument(fromLive: workout) { ScrollView { VStack(spacing: 20) { Image(systemName: "checkmark.seal.fill") .font(.system(size: 48)) .foregroundStyle(.green) .padding(.top, 8) Text(doc.routineName ?? Routine.unnamed) .font(.title2.bold()) WorkoutMetricsView(workout: workout) if doc.metrics == nil { Label("Saving to Apple Health…", systemImage: "heart.text.square") .font(.footnote) .foregroundStyle(.secondary) } } .padding() } } else { // The workout was removed (e.g. discarded) — nothing to summarize. ContentUnavailableView("Workout Ended", systemImage: "checkmark.circle") } } .navigationTitle("Summary") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { onDone() } } } } } } // MARK: - Metric tiles (shared by the summary sheet and the per-workout detail) /// Renders the available health/strength metrics for a finished workout as a tile /// grid plus an optional heart-rate-zone bar. Reused by `WorkoutSummaryView` and the /// completed-workout detail section. struct WorkoutMetricsView: View { let workout: Workout @AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb // Every read goes through this liveness-guarded snapshot: the cache row can be // deleted out from under a retained `Workout` (remote delete, reconcile prune) // while a parent re-evaluates mid-transition, and reading a persisted property // on a dead @Model traps — see `WorkoutDocument.init?(fromLive:)`. private var doc: WorkoutDocument? { WorkoutDocument(fromLive: workout) } private var metrics: WorkoutMetrics? { doc?.metrics } private var durationSeconds: Int? { guard let doc, let end = doc.end else { return nil } return max(0, Int(end.timeIntervalSince(doc.start))) } private var volume: Double { metrics?.totalVolume ?? WorkoutVolume.total(doc?.logs ?? []) } var body: some View { VStack(spacing: 16) { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { if let durationSeconds { MetricTile(icon: "clock.fill", tint: .blue, value: durationText(durationSeconds), label: "Duration") } if let kcal = metrics?.activeEnergyKcal { MetricTile(icon: "flame.fill", tint: .orange, value: "\(Int(kcal.rounded()))", label: "Active cal") } if let avg = metrics?.avgHeartRate { MetricTile(icon: "heart.fill", tint: .red, value: "\(Int(avg.rounded()))", label: "Avg BPM") } if let max = metrics?.maxHeartRate { MetricTile(icon: "bolt.heart.fill", tint: .pink, value: "\(Int(max.rounded()))", label: "Max BPM") } if volume > 0 { MetricTile(icon: "scalemass.fill", tint: .purple, value: volumeText, label: "Volume") } } if let zones = metrics?.hrZoneSeconds, zones.contains(where: { $0 > 0 }) { HRZoneBar(zoneSeconds: zones) } if metrics?.source == .phoneEstimate { Text("Calories estimated — wear your Apple Watch for measured stats.") .font(.caption2) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } } } private var volumeText: String { "\(Int(volume.rounded())) \(weightUnit.abbreviation)" } private func durationText(_ seconds: Int) -> String { let minutes = seconds / 60 if minutes >= 60 { return "\(minutes / 60)h \(minutes % 60)m" } return "\(minutes)m \(seconds % 60)s" } } struct MetricTile: View { let icon: String let tint: Color let value: String let label: String var body: some View { VStack(spacing: 4) { Image(systemName: icon) .font(.title2) .foregroundStyle(tint) Text(value) .font(.title3.bold()) .monospacedDigit() Text(label) .font(.caption2) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) .padding(.vertical, 10) .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 12)) } } /// Proportional stacked bar of time spent in each of the 5 heart-rate zones (low→high), /// with a numbered legend so the zones read without relying on color, and a single /// spoken summary of time per zone for VoiceOver. struct HRZoneBar: View { let zoneSeconds: [Double] private let colors: [Color] = [.blue, .green, .yellow, .orange, .red] /// Indices of the zones that actually accrued time — the only ones drawn and listed. private var activeZones: [Int] { (0.. 0 } } var body: some View { let total = max(zoneSeconds.reduce(0, +), 1) VStack(alignment: .leading, spacing: 8) { Text("Heart Rate Zones") .font(.caption) .foregroundStyle(.secondary) GeometryReader { geo in HStack(spacing: 2) { ForEach(activeZones, id: \.self) { zone in colors[zone] .frame(width: max(2, geo.size.width * (zoneSeconds[zone] / total))) } } } .frame(height: 12) .clipShape(Capsule()) // Numbered legend — each active zone's swatch beside its number, so the // split reads for color-blind (and all) sighted users, not by hue alone. HStack(spacing: 12) { ForEach(activeZones, id: \.self) { zone in HStack(spacing: 4) { RoundedRectangle(cornerRadius: 3) .fill(colors[zone]) .frame(width: 10, height: 10) Text("Zone \(zone + 1)") .font(.caption2) .foregroundStyle(.secondary) } } } } .frame(maxWidth: .infinity, alignment: .leading) // Collapse the whole bar+legend into one element with a spoken per-zone breakdown. .accessibilityElement(children: .ignore) .accessibilityLabel("Heart rate zones") .accessibilityValue(spokenSummary) } /// "Zone 1, 2 min 30 sec. Zone 2, 5 min." — time in each zone that saw activity. private var spokenSummary: String { activeZones .map { "Zone \($0 + 1), \(spokenDuration(Int(zoneSeconds[$0].rounded())))" } .joined(separator: ". ") } private func spokenDuration(_ seconds: Int) -> String { let m = seconds / 60, s = seconds % 60 if m > 0 && s > 0 { return "\(m) min \(s) sec" } if m > 0 { return "\(m) min" } return "\(s) sec" } }