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
219 lines
8.0 KiB
Swift
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"
|
|
}
|
|
}
|