The phone no longer writes estimated Health workouts: the watch, which runs the live session, is the sole recorder. Replaces WorkoutHealthWriter with a WorkoutHealthDeleter that only removes a legacy phone-estimate workout when its record is deleted here, drops the MET calorie table and the phone's write/read Health scopes, and keeps phoneEstimate decodable for existing documents. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
181 lines
6.4 KiB
Swift
181 lines
6.4 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.splitName ?? Split.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).
|
|
struct HRZoneBar: View {
|
|
let zoneSeconds: [Double]
|
|
|
|
private let colors: [Color] = [.blue, .green, .yellow, .orange, .red]
|
|
|
|
var body: some View {
|
|
let total = max(zoneSeconds.reduce(0, +), 1)
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Heart Rate Zones")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
GeometryReader { geo in
|
|
HStack(spacing: 2) {
|
|
ForEach(0..<5, id: \.self) { zone in
|
|
if zoneSeconds[zone] > 0 {
|
|
colors[zone]
|
|
.frame(width: max(2, geo.size.width * (zoneSeconds[zone] / total)))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 12)
|
|
.clipShape(Capsule())
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|