Files
workouts/Workouts/Views/WorkoutLogs/ExerciseView.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

277 lines
9.8 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
let workout: Workout
let logID: String
/// Working copy of the parent workout. Driving the UI from local state (not the
/// cache entity) lets `seedDoc` show a just-added log before the file→cache
/// round-trip catches up.
@State private var doc: WorkoutDocument
@State private var showingPlanEdit = false
@State private var showingNotesEdit = false
@State private var showingMachineSettings = false
/// `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
_doc = State(initialValue: seedDoc ?? WorkoutDocument(from: workout))
}
/// 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 {
// Deliberately NOT the half-and-half figure layout: this screen is
// dense with the plan, notes, settings, and the progression chart —
// the form guide lives in the run flow and the exercise library.
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)
}
.sheet(isPresented: $showingMachineSettings) {
if let log {
MachineSettingsSheet(
exerciseName: log.exerciseName,
initialSettings: log.machineSettings ?? []
) { updated in
applyMachineSettings(updated)
}
}
}
.onAppear {
refreshDocIfNeeded()
// 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 plan and notes
/// update without leaving and re-entering the screen.
private func absorbExternalUpdate() {
doc = WorkoutDocument(from: workout)
}
@ViewBuilder
private func content(for log: WorkoutLogDocument) -> some View {
Form {
// 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: - Machine Settings — shown when the log carries settings, or for
// any exercise the authored library identifies as machine-based, so the
// section is discoverable before settings are first recorded.
if log.machineSettings != nil
|| ExerciseInfoLibrary.info(for: log.exerciseName)?.isMachineBased == true {
let settings = log.machineSettings ?? []
Section {
if settings.isEmpty {
Text("No settings recorded")
.foregroundColor(.secondary)
.italic()
} else {
ForEach(Array(settings.enumerated()), id: \.offset) { _, setting in
HStack(alignment: .firstTextBaseline) {
Text(setting.name)
.foregroundStyle(.secondary)
Spacer()
Text(setting.value)
.fontWeight(.semibold)
.monospacedDigit()
}
}
}
} header: {
HStack {
Text("Machine Settings")
Spacer()
Button("Edit") {
showingMachineSettings = 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 (weighted exercises only — a weight
// chart is meaningless for bodyweight/timed logs)
if LoadType(rawValue: log.loadType) == .weight {
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() }
}
}
/// Apply edited machine settings: update the log's plan-time snapshot (persisted
/// with the workout), then write the same settings back to the originating
/// routine's exercise as the durable default — same sequencing as the workout
/// list's machine sheet.
private func applyMachineSettings(_ settings: [MachineSetting]) {
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].machineSettings = settings
doc.logs[i].touch()
doc.updatedAt = Date()
let snapshot = doc
let exerciseName = doc.logs[i].exerciseName
let routineID = doc.routineID
Task {
await sync.save(workout: snapshot)
await sync.writeBackMachineSettings(settings, exerciseName: exerciseName, routineID: routineID)
}
}
/// 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).
private func refreshDocFromCache() {
doc = WorkoutDocument(from: workout)
}
}
// 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)
}
}