Watch sessions now run an HKLiveWorkoutBuilder: heart rate and active energy are collected live (shown in an in-workout HUD), saved to Health as a real HKWorkout on completion, and carried back to the phone as WorkoutMetrics on the workout document. Phone-only workouts get an estimated Health workout (MET x bodyweight x duration) after a 10s debounce that a late-arriving watch session cancels; a launch sweep backfills workouts completed within the last day. Dedupe is keyed on metrics.healthKitWorkoutUUID plus the metrics source. Splits gain an activity type (strength, functional, HIIT, core, cardio, cycling) that categorizes the Health workout and picks the MET value. A post-workout summary sheet (duration, calories, avg/max HR, HR zones, total volume) fills in live and is also shown on completed workouts. Weights can now display in lb or kg (display-only relabel), synced to the watch over the existing application context. WorkoutDocument schema 1->2 (metrics are irreplaceable, so older apps must quarantine rather than strip them); cache schema 2 rebuilds the SwiftData store with the new metric columns. Deleting a workout in the app intentionally leaves its Health record in place.
301 lines
11 KiB
Swift
301 lines
11 KiB
Swift
//
|
|
// ExerciseView.swift
|
|
// Workouts
|
|
//
|
|
// Created by rzen on 7/18/25 at 5:44 PM.
|
|
//
|
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
import Charts
|
|
|
|
struct ExerciseView: View {
|
|
@Environment(SyncEngine.self) private var sync
|
|
@Environment(AppServices.self) private var services
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
let workout: Workout
|
|
let logID: String
|
|
|
|
/// Working copy of the parent workout. Editing a log = editing this doc and
|
|
/// re-saving the whole aggregate. Driving the UI from local state (not the
|
|
/// cache entity) keeps rapid set taps from racing the file→cache update.
|
|
@State private var doc: WorkoutDocument
|
|
@State private var progress: Int = 0
|
|
@State private var showingPlanEdit = false
|
|
@State private var showingNotesEdit = false
|
|
|
|
let notStartedColor = Color.white
|
|
let completedColor = Color.green
|
|
|
|
/// `seedDoc` lets the caller hand over an in-memory document (e.g. the parent's
|
|
/// working copy right after adding an exercise) so the screen doesn't wait on
|
|
/// the file→cache round-trip to find the just-created log.
|
|
init(workout: Workout, logID: String, seedDoc: WorkoutDocument? = nil) {
|
|
self.workout = workout
|
|
self.logID = logID
|
|
let initialDoc = seedDoc ?? WorkoutDocument(from: workout)
|
|
_doc = State(initialValue: initialDoc)
|
|
// Seed progress from the log so the set grid is correct on the first frame
|
|
// (onAppear also refreshes it, but that lags the initial render).
|
|
_progress = State(initialValue: initialDoc.logs.first { $0.id == logID }?.currentStateIndex ?? 0)
|
|
}
|
|
|
|
/// The log being edited within the working doc.
|
|
private var log: WorkoutLogDocument? {
|
|
doc.logs.first { $0.id == logID }
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let log {
|
|
content(for: log)
|
|
} else {
|
|
// The just-added log hasn't reached the cache yet; refresh shortly.
|
|
ProgressView()
|
|
}
|
|
}
|
|
.navigationTitle(log?.exerciseName ?? "")
|
|
.sheet(isPresented: $showingPlanEdit) {
|
|
PlanEditView(workout: workout, logID: logID)
|
|
}
|
|
.sheet(isPresented: $showingNotesEdit) {
|
|
NotesEditView(workout: workout, logID: logID)
|
|
}
|
|
.onAppear {
|
|
refreshDocIfNeeded()
|
|
progress = log?.currentStateIndex ?? 0
|
|
// Take over this run: the watch parks and locks it while we're editing here.
|
|
services.watchBridge.setEditingWorkout(workout.id)
|
|
}
|
|
.onDisappear {
|
|
services.watchBridge.setEditingWorkout(nil)
|
|
}
|
|
// Reflect external changes (e.g. a set completed on the watch) live. Each edit
|
|
// rewrites the whole workout file, so the cache always holds the latest — pulling
|
|
// it in can't revert a newer local tap.
|
|
.onChange(of: workout.updatedAt) { _, _ in
|
|
absorbExternalUpdate()
|
|
}
|
|
}
|
|
|
|
/// Pull an externally-changed workout into the working copy so the set grid and plan
|
|
/// update without leaving and re-entering the screen.
|
|
private func absorbExternalUpdate() {
|
|
let fresh = WorkoutDocument(from: workout)
|
|
doc = fresh
|
|
progress = fresh.logs.first(where: { $0.id == logID })?.currentStateIndex ?? progress
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func content(for log: WorkoutLogDocument) -> some View {
|
|
Form {
|
|
// MARK: - Progress Section
|
|
Section(header: Text("Progress")) {
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: max(1, log.sets)), spacing: 4) {
|
|
ForEach(1...max(1, log.sets), id: \.self) { index in
|
|
ZStack {
|
|
let completed = index <= progress
|
|
let color = completed ? completedColor : notStartedColor
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(
|
|
LinearGradient(
|
|
gradient: Gradient(colors: [color, color.darker(by: 0.2)]),
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.aspectRatio(0.618, contentMode: .fit)
|
|
.shadow(radius: 2)
|
|
Text("\(index)")
|
|
.font(.title)
|
|
.fontWeight(.bold)
|
|
.foregroundColor(.primary)
|
|
.colorInvert()
|
|
}
|
|
.onTapGesture {
|
|
let totalSets = log.sets
|
|
let isLastTile = index == totalSets
|
|
let wasAlreadyAtThisProgress = progress == index
|
|
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
progress = wasAlreadyAtThisProgress ? 0 : index
|
|
}
|
|
|
|
updateLogStatus()
|
|
|
|
// Tapping the final tile to complete returns to the list.
|
|
if isLastTile && !wasAlreadyAtThisProgress {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Plan Section (Read-only with Edit button)
|
|
Section {
|
|
PlanTilesView(log: log)
|
|
} header: {
|
|
HStack {
|
|
Text("Plan")
|
|
Spacer()
|
|
Button("Edit") {
|
|
showingPlanEdit = true
|
|
}
|
|
.font(.subheadline)
|
|
.textCase(.none)
|
|
}
|
|
}
|
|
|
|
// MARK: - Notes Section (Read-only with Edit button)
|
|
Section {
|
|
if let notes = log.notes, !notes.isEmpty {
|
|
Text(notes)
|
|
.foregroundColor(.primary)
|
|
} else {
|
|
Text("No notes")
|
|
.foregroundColor(.secondary)
|
|
.italic()
|
|
}
|
|
} header: {
|
|
HStack {
|
|
Text("Notes")
|
|
Spacer()
|
|
Button("Edit") {
|
|
showingNotesEdit = true
|
|
}
|
|
.font(.subheadline)
|
|
.textCase(.none)
|
|
}
|
|
}
|
|
|
|
// MARK: - Progress Tracking Chart
|
|
Section(header: Text("Progress Tracking")) {
|
|
WeightProgressionChartView(exerciseName: log.exerciseName)
|
|
}
|
|
}
|
|
// Pull plan/notes edits made in the sheets back into the live doc.
|
|
.onChange(of: showingPlanEdit) { _, presenting in
|
|
if !presenting { refreshDocFromCache() }
|
|
}
|
|
.onChange(of: showingNotesEdit) { _, presenting in
|
|
if !presenting { refreshDocFromCache() }
|
|
}
|
|
}
|
|
|
|
// MARK: - Mutations
|
|
|
|
private func updateLogStatus() {
|
|
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
|
|
doc.logs[i].currentStateIndex = progress
|
|
|
|
if progress >= doc.logs[i].sets {
|
|
doc.logs[i].status = WorkoutStatus.completed.rawValue
|
|
doc.logs[i].completed = true
|
|
} else if progress > 0 {
|
|
doc.logs[i].status = WorkoutStatus.inProgress.rawValue
|
|
doc.logs[i].completed = false
|
|
} else {
|
|
doc.logs[i].status = WorkoutStatus.notStarted.rawValue
|
|
doc.logs[i].completed = false
|
|
}
|
|
|
|
recomputeWorkoutStatus()
|
|
doc.updatedAt = Date()
|
|
let snapshot = doc
|
|
Task { await sync.save(workout: snapshot) }
|
|
}
|
|
|
|
/// Recompute the workout's status/end from its logs.
|
|
private func recomputeWorkoutStatus() {
|
|
doc.recomputeStatusFromLogs()
|
|
}
|
|
|
|
/// If the requested log isn't in the working doc yet (just-added race), pull a
|
|
/// fresh copy from the cache entity once it catches up.
|
|
private func refreshDocIfNeeded() {
|
|
guard log == nil else { return }
|
|
refreshDocFromCache()
|
|
}
|
|
|
|
/// Re-read the workout from the cache to absorb edits made by child sheets
|
|
/// (plan/notes) without clobbering progress edits made here.
|
|
private func refreshDocFromCache() {
|
|
let fresh = WorkoutDocument(from: workout)
|
|
// Preserve the locally edited progress for the open log if the cache lags.
|
|
if let i = fresh.logs.firstIndex(where: { $0.id == logID }),
|
|
let mine = doc.logs.first(where: { $0.id == logID }),
|
|
fresh.logs[i].currentStateIndex != mine.currentStateIndex {
|
|
doc = fresh
|
|
doc.logs[i].currentStateIndex = mine.currentStateIndex
|
|
} else {
|
|
doc = fresh
|
|
}
|
|
if let current = log {
|
|
progress = current.currentStateIndex
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Plan Tiles View
|
|
|
|
struct PlanTilesView: View {
|
|
let log: WorkoutLogDocument
|
|
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
|
|
|
|
var body: some View {
|
|
if LoadType(rawValue: log.loadType) == .duration {
|
|
// Duration layout: Sets | Duration
|
|
HStack(spacing: 0) {
|
|
PlanTile(label: "Sets", value: "\(log.sets)")
|
|
PlanTile(label: "Duration", value: formattedDuration)
|
|
}
|
|
} else {
|
|
// Weight layout: Sets | Reps | Weight
|
|
HStack(spacing: 0) {
|
|
PlanTile(label: "Sets", value: "\(log.sets)")
|
|
PlanTile(label: "Reps", value: "\(log.reps)")
|
|
PlanTile(label: "Weight", value: weightUnit.format(log.weight))
|
|
}
|
|
}
|
|
}
|
|
|
|
private var formattedDuration: String {
|
|
let mins = log.durationSeconds / 60
|
|
let secs = log.durationSeconds % 60
|
|
if mins > 0 && secs > 0 {
|
|
return "\(mins)m \(secs)s"
|
|
} else if mins > 0 {
|
|
return "\(mins) min"
|
|
} else if secs > 0 {
|
|
return "\(secs) sec"
|
|
} else {
|
|
return "0 sec"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PlanTile: View {
|
|
let label: String
|
|
let value: String
|
|
|
|
var body: some View {
|
|
VStack(spacing: 4) {
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
Text(value)
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.primary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
.background(Color(.systemGray6))
|
|
.cornerRadius(8)
|
|
}
|
|
}
|