// // WorkoutLogListView.swift // Workouts Watch App // // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // import SwiftUI import SwiftData struct WorkoutLogListView: View { @Environment(WatchConnectivityBridge.self) private var bridge /// The split this workout came from (read-only on the watch), used to offer /// additional exercises that aren't logged yet. @Query private var matchingSplits: [Split] /// Working copy of the workout. We drive the UI from this and mutate it on /// every edit (then forward through the bridge) to avoid the read-after-write /// race against the cache, which lags local writes by a beat. @State private var doc: WorkoutDocument @State private var showingExercisePicker = false @State private var selectedLogID: String? init(workout: Workout) { _doc = State(initialValue: WorkoutDocument(from: workout)) if let splitID = workout.splitID { _matchingSplits = Query(filter: #Predicate { $0.id == splitID }) } else { // No source split: never match anything. _matchingSplits = Query(filter: #Predicate { _ in false }) } } private var split: Split? { matchingSplits.first } private var sortedLogs: [WorkoutLogDocument] { doc.logs.sorted { $0.order < $1.order } } private var availableExercises: [Exercise] { guard let split else { return [] } let existingNames = Set(doc.logs.map { $0.exerciseName }) return split.exercisesArray.filter { !existingNames.contains($0.name) } } var body: some View { List { Section(header: Text(label)) { ForEach(sortedLogs) { log in Button { selectedLogID = log.id } label: { WorkoutLogRowLabel(log: log) } .buttonStyle(.plain) } } if !availableExercises.isEmpty { Section { Button { showingExercisePicker = true } label: { HStack { Image(systemName: "plus.circle.fill") .foregroundColor(.green) Text("Add Exercise") } } } } } .overlay { if sortedLogs.isEmpty { ContentUnavailableView( "No Exercises", systemImage: "figure.strengthtraining.traditional", description: Text(availableExercises.isEmpty ? "No exercises in this workout." : "Tap + to add exercises.") ) } } .navigationTitle(doc.splitName ?? Split.unnamed) .navigationDestination(item: $selectedLogID) { logID in ExerciseProgressView(doc: $doc, logID: logID, onChange: { bridge.update(workout: doc) }) } .sheet(isPresented: $showingExercisePicker) { ExercisePickerView(exercises: availableExercises) { exercise in addExercise(exercise) } } } private var label: String { let start = doc.start if doc.status == WorkoutStatus.completed.rawValue, let end = doc.end { if start.isSameDay(as: end) { return "\(start.formattedDate())—\(end.formattedTime())" } else { return "\(start.formattedDate())—\(end.formattedDate())" } } return start.formattedDate() } private func addExercise(_ exercise: Exercise) { let newLog = WorkoutLogDocument( id: ULID.make(), exerciseName: exercise.name, order: doc.logs.count, sets: exercise.sets, reps: exercise.reps, weight: exercise.weight, loadType: exercise.loadType, durationSeconds: exercise.durationTotalSeconds, currentStateIndex: 0, completed: false, status: WorkoutStatus.notStarted.rawValue, notes: nil, date: doc.start ) doc.logs.append(newLog) doc.updatedAt = Date() bridge.update(workout: doc) showingExercisePicker = false } } // MARK: - Workout Log Row Label struct WorkoutLogRowLabel: View { let log: WorkoutLogDocument var body: some View { HStack { statusIcon .foregroundColor(statusColor) VStack(alignment: .leading, spacing: 2) { Text(log.exerciseName) .font(.headline) .lineLimit(1) Text(subtitle) .font(.caption2) .foregroundColor(.secondary) } Spacer() } } private var status: WorkoutStatus { WorkoutStatus(rawValue: log.status) ?? .notStarted } private var statusIcon: Image { switch status { case .completed: Image(systemName: "checkmark.circle.fill") case .inProgress: Image(systemName: "circle.dotted") case .notStarted: Image(systemName: "circle") case .skipped: Image(systemName: "xmark.circle") } } private var statusColor: Color { switch status { case .completed: .accentColor case .inProgress: .gray case .notStarted: .secondary case .skipped: .secondary } } private var subtitle: String { if LoadType(rawValue: log.loadType) == .duration { let mins = log.durationSeconds / 60 let secs = log.durationSeconds % 60 if mins > 0 && secs > 0 { return "\(log.sets) × \(mins)m \(secs)s" } else if mins > 0 { return "\(log.sets) × \(mins) min" } else { return "\(log.sets) × \(secs) sec" } } else { return "\(log.sets) × \(log.reps) × \(log.weight) lbs" } } } // MARK: - Exercise Picker View struct ExercisePickerView: View { @Environment(\.dismiss) private var dismiss let exercises: [Exercise] let onSelect: (Exercise) -> Void var body: some View { NavigationStack { List { if exercises.isEmpty { Text("All exercises added") .foregroundColor(.secondary) } else { ForEach(exercises) { exercise in Button { onSelect(exercise) dismiss() } label: { VStack(alignment: .leading) { Text(exercise.name) .font(.headline) Text(exerciseSubtitle(exercise)) .font(.caption2) .foregroundColor(.secondary) } } } } } .navigationTitle("Add Exercise") .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } } } private func exerciseSubtitle(_ exercise: Exercise) -> String { if exercise.loadTypeEnum == .duration { let mins = exercise.durationMinutes let secs = exercise.durationSeconds if mins > 0 && secs > 0 { return "\(exercise.sets) × \(mins)m \(secs)s" } else if mins > 0 { return "\(exercise.sets) × \(mins) min" } else { return "\(exercise.sets) × \(secs) sec" } } else { return "\(exercise.sets) × \(exercise.reps) × \(exercise.weight) lbs" } } }