// // WorkoutLogsView.swift // Workouts // // Created by rzen on 7/13/25 at 6:52 PM. // // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // import SwiftUI import SwiftData struct WorkoutLogsView: View { @Environment(SyncEngine.self) private var sync @Query(sort: \Workout.start, order: .reverse) private var workouts: [Workout] @State private var showingSplitPicker = false @State private var showingSettings = false @State private var itemToDelete: Workout? // WorkoutLogsView is the app's root screen, so it owns its NavigationStack. var body: some View { NavigationStack { List { ForEach(workouts) { workout in NavigationLink { WorkoutLogListView(workout: workout) } label: { CalendarListItem( date: workout.start, title: workout.splitName ?? Split.unnamed, subtitle: getSubtitle(for: workout), subtitle2: workout.statusName ) } .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button { itemToDelete = workout } label: { Label("Delete", systemImage: "trash") } .tint(.red) } } } .overlay { if workouts.isEmpty { ContentUnavailableView( "No Workouts Yet", systemImage: "list.bullet.clipboard", description: Text("Start a new workout from one of your splits.") ) } } .navigationTitle("Workout Logs") .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button { showingSettings = true } label: { Image(systemName: "gearshape.2") } } ToolbarItem(placement: .navigationBarTrailing) { Button("Start New") { showingSplitPicker.toggle() } } } .sheet(isPresented: $showingSettings) { SettingsView() } .sheet(isPresented: $showingSplitPicker) { SplitPickerSheet() } .confirmationDialog( "Delete Workout?", isPresented: Binding( get: { itemToDelete != nil }, set: { if !$0 { itemToDelete = nil } } ), titleVisibility: .visible, presenting: itemToDelete ) { workout in Button("Delete", role: .destructive) { Task { await sync.delete(workout: workout) } itemToDelete = nil } Button("Cancel", role: .cancel) { itemToDelete = nil } } } } private func getSubtitle(for workout: Workout) -> String { if workout.status == .completed, let endDate = workout.end { return workout.start.humanTimeInterval(to: endDate) } else { return workout.start.formattedDate() } } } // MARK: - Split Picker Sheet struct SplitPickerSheet: View { @Environment(SyncEngine.self) private var sync @Environment(AppServices.self) private var services @Environment(\.dismiss) private var dismiss @Query(sort: [SortDescriptor(\Split.order), SortDescriptor(\Split.name)]) private var splits: [Split] @Query(sort: \Workout.start, order: .reverse) private var workouts: [Workout] /// Set when the user picks a split while other workouts are still going — drives the /// "end the current one(s) or run in parallel?" prompt. @State private var splitAwaitingConfirmation: Split? private var activeWorkouts: [Workout] { workouts.filter { $0.status == .inProgress || $0.status == .notStarted } } var body: some View { NavigationStack { List { ForEach(splits) { split in Button { confirmAndStart(with: split) } label: { HStack { Image(systemName: split.systemImage) .foregroundColor(Color.color(from: split.color)) Text(split.name) Spacer() Text("\(split.exercisesArray.count) exercises") .font(.caption) .foregroundColor(.secondary) } } } } .navigationTitle("Select a Split") .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("Cancel") { dismiss() } } } .confirmationDialog( activePromptTitle, isPresented: Binding( get: { splitAwaitingConfirmation != nil }, set: { if !$0 { splitAwaitingConfirmation = nil } } ), titleVisibility: .visible, presenting: splitAwaitingConfirmation ) { split in Button("End Current & Start New") { endActiveThenStart(with: split) } Button("Start in Parallel") { start(with: split) } Button("Cancel", role: .cancel) { splitAwaitingConfirmation = nil } } message: { _ in Text(activePromptMessage) } } } 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(with split: Split) { if activeWorkouts.isEmpty { start(with: split) } else { splitAwaitingConfirmation = split } } /// End every in-flight workout (keeping its progress), then start the picked split. private func endActiveThenStart(with split: Split) { let toEnd = activeWorkouts.map { WorkoutDocument(from: $0) } splitAwaitingConfirmation = nil Task { for var doc in toEnd { doc.endKeepingProgress() await sync.save(workout: doc) } } start(with: split) } private func start(with split: Split) { let startDate = Date() let logs = split.exercisesArray.enumerated().map { index, exercise in WorkoutLogDocument( id: ULID.make(), exerciseName: exercise.name, order: index, 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: startDate ) } // A freshly started workout has no `end` — only completion stamps it. 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) } // Bring the Apple Watch up into the session so the user can run it from the wrist. services.workoutLauncher.launchWatchWorkout() dismiss() } }