// // WorkoutLogsView.swift // Workouts // // Created by rzen on 7/13/25 at 6:52 PM. // // Copyright 2025 Rouslan Zenetl. All Rights Reserved. // import IndieSync import SwiftUI import SwiftData struct WorkoutLogsView: View { @Environment(SyncEngine.self) private var sync @Environment(AppServices.self) private var services @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? /// ID of the just-started workout; drives the push into its log screen once the /// cache observer delivers the entity a beat after the file write (see /// `navigatesToStartedWorkout`). @State private var pendingWorkoutID: String? // 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") // Write-queue banner tiers (calm "pending" capsule / prominent fault). // On the root screen so a stalled save is visible where editing happens. .safeAreaInset(edge: .bottom) { SyncStatusBanner() } .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button { showingSettings = true } label: { Image(systemName: "gearshape.2") } .accessibilityLabel("Settings") } ToolbarItem(placement: .navigationBarTrailing) { Button("Start New") { showingSplitPicker.toggle() } } } .sheet(isPresented: $showingSettings) { SettingsView() } .sheet(isPresented: $showingSplitPicker) { SplitPickerSheet { pendingWorkoutID = $0 } } // Once "Start New" saves a workout, drop straight into its log screen — // the same landing the split's exercise-list start path uses. .navigatesToStartedWorkout(pendingWorkoutID: $pendingWorkoutID) .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 } // Only offered when the workout actually has a Health record. Capture // the UUID before the delete prunes the cache entity. if let healthUUID = workout.metricHealthKitWorkoutUUID { Button("Delete + Remove from Apple Health", role: .destructive) { services.workoutHealthDeleter.deleteFromHealth(uuidString: healthUUID) Task { await sync.delete(workout: workout) } itemToDelete = nil } } Button("Cancel", role: .cancel) { itemToDelete = nil } } message: { workout in if workout.metricSourceRaw == MetricSource.watch.rawValue { Text("This workout was recorded by Apple Watch; if it can't be removed from Apple Health here, delete it in the Health app.") } } } } 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 /// Called with the new workout's id right after it's saved, so the presenting /// screen can navigate into it once the cache catches up. var onStarted: (String) -> Void = { _ in } @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 } } /// The splits most recently trained, newest first, each with the start date /// of its latest workout — derived from the workout log (`workouts` is /// already sorted by start descending). Each workout's `splitID` follows the /// seed clone-on-edit redirect so a workout started from a since-forked seed /// still credits the live clone. Deduped, capped, and limited to splits that /// still exist. private var recentSplits: [(split: Split, lastStart: Date)] { var seen = Set() var recents: [(split: Split, lastStart: Date)] = [] for workout in workouts { guard let splitID = workout.splitID else { continue } let liveID = sync.currentSplitID(for: splitID) guard !seen.contains(liveID) else { continue } seen.insert(liveID) if let split = splits.first(where: { $0.id == liveID }) { recents.append((split, workout.start)) if recents.count == 3 { break } } } return recents } /// One selectable split row, shared by the Recent and All Splits sections. /// Recent rows pass `lastTrained` to show how long ago the split was run. private func splitRow(_ split: Split, lastTrained: Date? = nil) -> some View { Button { confirmAndStart(with: split) } label: { HStack { Image(systemName: split.systemImage) .foregroundColor(Color.color(from: split.color)) .frame(width: 28) VStack(alignment: .leading, spacing: 2) { Text(split.name) if let lastTrained { Text(lastTrained.daysAgoLabel()) .font(.caption) .foregroundColor(.secondary) } } Spacer() Text("\(split.exercisesArray.count) exercises") .font(.caption) .foregroundColor(.secondary) } } } var body: some View { NavigationStack { List { if !recentSplits.isEmpty { Section("Recent") { ForEach(recentSplits, id: \.split.id) { recent in splitRow(recent.split, lastTrained: recent.lastStart) } } } Section(recentSplits.isEmpty ? "" : "All Splits") { ForEach(splits) { split in splitRow(split) } } } .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(planFrom: ExerciseDocument(from: exercise), order: index, date: startDate) } // A freshly started workout has no `end` — only completion stamps it. let doc = WorkoutDocument( schemaVersion: WorkoutDocument.currentSchemaVersion, id: ULID.make(), splitID: split.id, splitName: split.name, start: startDate, end: nil, status: WorkoutStatus.notStarted.rawValue, createdAt: startDate, updatedAt: startDate, logs: logs, restSeconds: split.restSeconds, autoAdvance: split.autoAdvance ) // Hand the id back after the save so the presenter can poll the cache and // navigate into the run — mirroring the exercise-list start path. Task { await sync.save(workout: doc) onStarted(doc.id) } // Bring the Apple Watch up into the session so the user can run it from the wrist, // tagged with the split's activity type so the saved Health workout is categorized. services.workoutLauncher.launchWatchWorkout(activityType: split.activityTypeEnum.hkActivityType) dismiss() } } // MARK: - Started-Workout Navigation /// Drives a programmatic push into a freshly-started workout's log screen. Both start /// paths — the split picker sheet and a split's exercise list — mint a `WorkoutDocument`, /// write it, then hand its id here; the workout row only materializes once the /// file→observer→cache loop round-trips, so we poll the cache for the entity and push it /// the moment it lands. private struct StartedWorkoutNavigator: ViewModifier { @Environment(\.modelContext) private var modelContext @Binding var pendingWorkoutID: String? @State private var resolvedWorkout: Workout? func body(content: Content) -> some View { content .navigationDestination(item: $resolvedWorkout) { workout in WorkoutLogListView(workout: workout) } .onChange(of: pendingWorkoutID) { _, id in guard let id else { return } pollForWorkout(id: id) } } /// Poll for the entity after we write the document (the file→observer→cache loop /// typically completes in well under a second). Clear the pending id once it /// resolves, or silently after ~3 s if it never arrives. private func pollForWorkout(id: String) { Task { 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 } } pendingWorkoutID = nil } } } extension View { /// Navigate into a workout's log screen once it appears in the cache after being /// started. Bind to the state you set to the new workout's id right after saving it. func navigatesToStartedWorkout(pendingWorkoutID: Binding) -> some View { modifier(StartedWorkoutNavigator(pendingWorkoutID: pendingWorkoutID)) } }