// // 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(\.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 } // 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() { let statuses = doc.logs.map { WorkoutStatus(rawValue: $0.status) ?? .notStarted } let allCompleted = !statuses.isEmpty && statuses.allSatisfy { $0 == .completed } let anyInProgress = statuses.contains { $0 == .inProgress } let allNotStarted = statuses.allSatisfy { $0 == .notStarted } if allCompleted { doc.status = WorkoutStatus.completed.rawValue doc.end = Date() } else if anyInProgress || !allNotStarted { doc.status = WorkoutStatus.inProgress.rawValue doc.end = nil } else { doc.status = WorkoutStatus.notStarted.rawValue doc.end = nil } } /// 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 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: "\(log.weight) lbs") } } } 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) } }