// // 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 @Environment(\.modelContext) private var modelContext /// 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)) } /// The split this workout came from (read-only on the watch), used to offer /// additional exercises that aren't logged yet. Fetched imperatively — *not* via /// `@Query` — so the list body never observes the live `Split` or traverses its /// `exercises` relationship during a render. Doing so (a `@Query`-observed model /// whose to-many relationship is read in `body`) drove a SwiftData re-render loop /// that hung the watch. `availableExercises` is therefore only ever evaluated from /// the picker sheet's closure, not from `body`. private var split: Split? { guard let splitID = doc.splitID else { return nil } return CacheMapper.fetchSplit(id: splitID, in: modelContext) } 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 doc.splitID != nil { 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(doc.splitID == nil ? "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) }, onLive: { bridge.sendLiveProgress($0) }, onLiveEnded: { bridge.sendLiveEnded(workoutID: doc.id, logID: logID) } ) } .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" } } }