Files
workouts/Workouts/Views/WorkoutLogs/WorkoutSummaryView.swift
T
rzen 6e440317c4 Restructure into a three-tab app with Progress, goals, and Meditation
The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView
becomes a Today / Progress / Settings TabView, "Routine" replaces
"Split" in every user-facing string and view name (code-level types
keep their names), and workout starting moves to shared
WorkoutStarter / StartedWorkoutNavigator plumbing.

- New Progress tab: weekly goal streaks, workout trends, per-exercise
  weight progression, achievements, and the full history list
  (WorkoutLogsView -> WorkoutHistoryView).
- Goals: stable categories workouts roll up to, managed from Settings.
- New Meditation exercise + starter routine; timed sits record to
  Apple Health as Mind & Body sessions.

Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
2026-07-11 07:53:01 -04:00

219 lines
8.0 KiB
Swift

//
// 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<Workout> { $0.id == workoutID })
}
private var workout: Workout? { workouts.first }
var body: some View {
NavigationStack {
Group {
if let workout {
ScrollView {
VStack(spacing: 20) {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 48))
.foregroundStyle(.green)
.padding(.top, 8)
Text(workout.routineName ?? Routine.unnamed)
.font(.title2.bold())
WorkoutMetricsView(workout: workout)
if workout.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
private var metrics: WorkoutMetrics? { workout.metrics }
private var durationSeconds: Int? {
guard let end = workout.end else { return nil }
return max(0, Int(end.timeIntervalSince(workout.start)))
}
private var volume: Double {
metrics?.totalVolume ?? WorkoutVolume.total(WorkoutDocument(from: workout).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..<min(zoneSeconds.count, colors.count)).filter { zoneSeconds[$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"
}
}