// // ExerciseListView.swift // Workouts // // Created by rzen on 7/18/25 at 8:38 AM. // // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // import SwiftUI import SwiftData struct ExerciseListView: View { @Environment(SyncEngine.self) private var sync @Environment(\.modelContext) private var modelContext var split: Split @State private var showingAddSheet: Bool = false @State private var itemToEdit: Exercise? = nil @State private var itemToDelete: Exercise? = nil /// ID of the just-created workout; drives programmatic navigation once the /// cache observer delivers the entity a beat after the file write. @State private var pendingWorkoutID: String? = nil @State private var resolvedWorkout: Workout? = nil @Query(sort: \Workout.start, order: .reverse) private var workouts: [Workout] @State private var showingActivePrompt = false private var activeWorkouts: [Workout] { workouts.filter { $0.status == .inProgress || $0.status == .notStarted } } var body: some View { Form { let sortedExercises = split.exercisesArray if !sortedExercises.isEmpty { ForEach(sortedExercises) { item in ListItem( title: item.name, subtitle: "\(item.sets) × \(item.reps) × \(item.weight) lbs" ) .swipeActions { Button { itemToDelete = item } label: { Label("Delete", systemImage: "trash") } .tint(.red) Button { itemToEdit = item } label: { Label("Edit", systemImage: "pencil") } .tint(.indigo) } } .onMove(perform: moveExercises) Button { showingAddSheet = true } label: { ListItem(title: "Add Exercise") } } else { Text("No exercises added yet.") Button(action: { showingAddSheet.toggle() }) { ListItem(title: "Add Exercise") } } } .navigationTitle(split.name) .toolbar { ToolbarItem(placement: .primaryAction) { Button("Start This Split") { confirmAndStart() } .disabled(split.exercisesArray.isEmpty) } } // Navigate to the workout log once the entity appears in the cache. .navigationDestination(item: $resolvedWorkout) { workout in WorkoutLogListView(workout: workout) } // Poll for the entity after we write the document. .onChange(of: pendingWorkoutID) { _, id in guard let id else { return } pollForWorkout(id: id) } .sheet(isPresented: $showingAddSheet) { ExercisePickerView(onExerciseSelected: { exerciseNames in addExercises(names: exerciseNames) }, allowMultiSelect: true) } .sheet(item: $itemToEdit) { item in ExerciseAddEditView(exercise: item, split: split) } .confirmationDialog( "Delete Exercise?", isPresented: Binding( get: { itemToDelete != nil }, set: { if !$0 { itemToDelete = nil } } ), titleVisibility: .visible, presenting: itemToDelete ) { item in Button("Delete", role: .destructive) { deleteExercise(item) itemToDelete = nil } Button("Cancel", role: .cancel) { itemToDelete = nil } } message: { item in Text("Remove \"\(item.name)\" from this split?") } .confirmationDialog( activePromptTitle, isPresented: $showingActivePrompt, titleVisibility: .visible ) { Button("End Current & Start New") { endActiveThenStart() } Button("Start in Parallel") { start() } Button("Cancel", role: .cancel) { showingActivePrompt = false } } message: { Text(activePromptMessage) } } // MARK: - Helpers private var activePromptTitle: String { activeWorkouts.count == 1 ? "Workout in Progress" : "\(activeWorkouts.count) Workouts in Progress" } private var activePromptMessage: String { let n = activeWorkouts.count let those = n == 1 ? "it" : "them" return "You already have \(n == 1 ? "a workout" : "\(n) workouts") going. End \(those) first, or run this one alongside." } /// Prompt before starting if other workouts are still going; otherwise start straight away. private func confirmAndStart() { if activeWorkouts.isEmpty { start() } else { showingActivePrompt = true } } /// End every in-flight workout (keeping its progress), then start this split. private func endActiveThenStart() { let toEnd = activeWorkouts.map { WorkoutDocument(from: $0) } showingActivePrompt = false Task { for var doc in toEnd { doc.endKeepingProgress() await sync.save(workout: doc) } } start() } private func pollForWorkout(id: String) { Task { // Give the file→observer→cache loop a moment to complete (typically < 1 s). for _ in 0..<20 { try? await Task.sleep(for: .milliseconds(150)) if let workout = CacheMapper.fetchWorkout(id: id, in: modelContext) { resolvedWorkout = workout pendingWorkoutID = nil return } } // If still not available after ~3 s, clear the pending ID silently. pendingWorkoutID = nil } } private func moveExercises(from source: IndexSet, to destination: Int) { var exercises = split.exercisesArray exercises.move(fromOffsets: source, toOffset: destination) var doc = SplitDocument(from: split) doc.exercises = exercises.enumerated().map { i, ex in var ed = ExerciseDocument(from: ex) ed.order = i return ed } doc.updatedAt = Date() Task { await sync.save(split: doc) } } private func start() { let startDate = Date() let logs = split.exercisesArray.enumerated().map { i, ex in WorkoutLogDocument( id: ULID.make(), exerciseName: ex.name, order: i, sets: ex.sets, reps: ex.reps, weight: ex.weight, loadType: ex.loadType, durationSeconds: ex.durationTotalSeconds, currentStateIndex: 0, completed: false, status: WorkoutStatus.notStarted.rawValue, notes: nil, date: startDate ) } let doc = WorkoutDocument( schemaVersion: WorkoutDocument.currentSchema, id: ULID.make(), splitID: split.id, splitName: split.name, start: startDate, end: nil, status: WorkoutStatus.notStarted.rawValue, createdAt: startDate, updatedAt: startDate, logs: logs ) Task { await sync.save(workout: doc) pendingWorkoutID = doc.id } } private func addExercises(names: [String]) { var doc = SplitDocument(from: split) let existingNames = Set(doc.exercises.map { $0.name }) let base = doc.exercises.count let newDocs = names .filter { !existingNames.contains($0) } .enumerated() .map { i, exName in ExerciseDocument( id: ULID.make(), name: exName, order: base + i, sets: 3, reps: 10, weight: 40, loadType: LoadType.weight.rawValue, durationSeconds: 0, weightLastUpdated: nil, weightReminderWeeks: 2 ) } doc.exercises.append(contentsOf: newDocs) doc.updatedAt = Date() Task { await sync.save(split: doc) } } private func deleteExercise(_ exercise: Exercise) { var doc = SplitDocument(from: split) doc.exercises.removeAll { $0.id == exercise.id } for i in doc.exercises.indices { doc.exercises[i].order = i } doc.updatedAt = Date() Task { await sync.save(split: doc) } } }