// // 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 /// split'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.updatedAt = Date() let snapshot = doc let exerciseName = doc.logs[i].exerciseName let splitID = doc.splitID Task { await sync.save(workout: snapshot) await sync.writeBackMachineSettings(settings, exerciseName: exerciseName, splitID: splitID) } } /// 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) } }