diff --git a/CHANGELOG.md b/CHANGELOG.md index 05d807e..21d183c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ **June 2026** +Starting a new workout while another is still going now asks whether to end the current one first or run both in parallel. + +Discarding or deleting an in-progress workout on iPhone now takes your Apple Watch out of the workout too, so the watch no longer keeps waking to the workout app when you raise your wrist. + You can now end a workout before finishing every exercise: open the ⋯ menu and choose End Workout, then Save to keep what you did — any exercises you didn't get to are marked skipped — or Discard to remove the workout. Right after you turn on iCloud Drive, Workouts now waits for it to finish coming online instead of giving up and showing the "turn on iCloud Drive" screen too soon. The connecting screen also keeps you posted as it works, letting you know the first connect can take a moment. diff --git a/Shared/Model/Documents.swift b/Shared/Model/Documents.swift index bd28084..88aa15f 100644 --- a/Shared/Model/Documents.swift +++ b/Shared/Model/Documents.swift @@ -94,6 +94,19 @@ extension WorkoutDocument { end = nil } } + + /// End the workout now, keeping progress: mark every not-completed log as skipped, then + /// recompute so it resolves to `.completed` (with `end` stamped). This is the + /// "End Workout → Save" operation, shared by the in-workout menu and the + /// start-a-new-split prompt. + mutating func endKeepingProgress() { + for i in logs.indices where (WorkoutStatus(rawValue: logs[i].status) ?? .notStarted) != .completed { + logs[i].status = WorkoutStatus.skipped.rawValue + logs[i].completed = false + } + recomputeStatusFromLogs() + updatedAt = Date() + } } struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable { diff --git a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift index a7d629f..220deb3 100644 --- a/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift +++ b/Workouts Watch App/Connectivity/WatchConnectivityBridge.swift @@ -185,15 +185,22 @@ final class WatchConnectivityBridge: NSObject { CacheMapper.upsertSplit(s, relativePath: s.relativePath, into: context) liveSplitIDs.insert(s.id) } + var liveWorkoutIDs = Set() for w in workouts { CacheMapper.upsertWorkout(w, relativePath: w.relativePath, into: context) + liveWorkoutIDs.insert(w.id) } - // Splits are sent in full → prune any the phone no longer has. Workouts are - // sent as a recent window, so they're upserted but never pruned (avoids a - // race deleting a workout just created on the watch). + // Both are authoritative sets → prune anything the phone no longer sends. For + // workouts that set is every active run plus recently-completed ones (~24h), so a + // run that was discarded/deleted on the phone (or aged out of the window) drops out + // of the push and is pruned here — which empties the active list and ends the + // session. The watch never originates a workout, so pruning can't lose local data. if let allSplits = try? context.fetch(FetchDescriptor()) { for s in allSplits where !liveSplitIDs.contains(s.id) { context.delete(s) } } + if let allWorkouts = try? context.fetch(FetchDescriptor()) { + for w in allWorkouts where !liveWorkoutIDs.contains(w.id) { context.delete(w) } + } try? context.save() lastSyncDate = Date() } diff --git a/Workouts Watch App/Views/ActiveWorkoutGateView.swift b/Workouts Watch App/Views/ActiveWorkoutGateView.swift index 729d06b..605b31d 100644 --- a/Workouts Watch App/Views/ActiveWorkoutGateView.swift +++ b/Workouts Watch App/Views/ActiveWorkoutGateView.swift @@ -78,9 +78,11 @@ struct ActiveWorkoutGateView: View { if noActiveWorkouts { sessionManager.end() } } // The phone just entered (or left) an editor — if we're inside the now-locked run, - // pop back to the gate so re-entry rebuilds a fresh working copy. - .onChange(of: bridge.editingWorkoutID) { _, _ in popIfNavigatedRunLocked() } - .onChange(of: bridge.editingSplitID) { _, _ in popIfNavigatedRunLocked() } + // pop back to the gate so re-entry rebuilds a fresh working copy. Also pop if the run + // we're inside was pruned (discarded/deleted on the phone, or aged out of the push). + .onChange(of: bridge.editingWorkoutID) { _, _ in popIfNavigatedRunUnavailable() } + .onChange(of: bridge.editingSplitID) { _, _ in popIfNavigatedRunUnavailable() } + .onChange(of: workouts.map(\.id)) { _, _ in popIfNavigatedRunUnavailable() } } @ViewBuilder @@ -94,10 +96,15 @@ struct ActiveWorkoutGateView: View { } } - /// If the run we're currently navigated into has become locked, pop to the gate. - private func popIfNavigatedRunLocked() { - guard let route = path.last, - let workout = workouts.first(where: { $0.id == route.workoutID }) else { return } + /// If the run we're currently navigated into is no longer available — pruned from the + /// cache (discarded/deleted on the phone, or aged out of the pushed set), or locked + /// because the phone took over editing it — pop back to the gate. + private func popIfNavigatedRunUnavailable() { + guard let route = path.last else { return } + guard let workout = workouts.first(where: { $0.id == route.workoutID }) else { + path.removeAll() + return + } if isLockedForEditing(workout) { path.removeAll() } } diff --git a/Workouts/Connectivity/PhoneConnectivityBridge.swift b/Workouts/Connectivity/PhoneConnectivityBridge.swift index 9fdb714..05ddc2a 100644 --- a/Workouts/Connectivity/PhoneConnectivityBridge.swift +++ b/Workouts/Connectivity/PhoneConnectivityBridge.swift @@ -60,9 +60,30 @@ final class PhoneConnectivityBridge: NSObject { session.isWatchAppInstalled else { return } let splits = (try? context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.order)]))) ?? [] - var wDesc = FetchDescriptor(sortBy: [SortDescriptor(\.start, order: .reverse)]) - wDesc.fetchLimit = 25 - let workouts = (try? context.fetch(wDesc)) ?? [] + + // The watch only needs what it can act on: every active run (in-progress / + // not-started) plus recently-completed ones, kept ~24h so a run that just + // finished still renders before the watch prunes it. The watch treats this as an + // authoritative set and prunes anything absent — that's what ends its session on a + // Discard/Delete. Active runs are sent in full (no cap): there are only ever a + // handful, so "absent" unambiguously means "no longer active". + let inProgressRaw = WorkoutStatus.inProgress.rawValue + let notStartedRaw = WorkoutStatus.notStarted.rawValue + let completedRaw = WorkoutStatus.completed.rawValue + let cutoff = Date(timeIntervalSinceNow: -86_400) + + let activeDesc = FetchDescriptor( + predicate: #Predicate { $0.statusRaw == inProgressRaw || $0.statusRaw == notStartedRaw }, + sortBy: [SortDescriptor(\.start, order: .reverse)] + ) + var completedDesc = FetchDescriptor( + predicate: #Predicate { $0.statusRaw == completedRaw }, + sortBy: [SortDescriptor(\.start, order: .reverse)] + ) + completedDesc.fetchLimit = 25 + let active = (try? context.fetch(activeDesc)) ?? [] + let recentCompleted = ((try? context.fetch(completedDesc)) ?? []).filter { ($0.end ?? $0.start) > cutoff } + let workouts = active + recentCompleted let restSeconds = UserDefaults.standard.object(forKey: WCPayload.restSecondsKey) as? Int ?? 45 let doneCountdownSeconds = UserDefaults.standard.object(forKey: WCPayload.doneCountdownSecondsKey) as? Int ?? 5 diff --git a/Workouts/Views/Exercises/ExerciseListView.swift b/Workouts/Views/Exercises/ExerciseListView.swift index 70861e5..a7bf84d 100644 --- a/Workouts/Views/Exercises/ExerciseListView.swift +++ b/Workouts/Views/Exercises/ExerciseListView.swift @@ -24,6 +24,15 @@ struct ExerciseListView: View { @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 @@ -67,7 +76,7 @@ struct ExerciseListView: View { .toolbar { ToolbarItem(placement: .primaryAction) { Button("Start This Split") { - startWorkout() + confirmAndStart() } .disabled(split.exercisesArray.isEmpty) } @@ -108,10 +117,53 @@ struct ExerciseListView: View { } 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). @@ -141,8 +193,8 @@ struct ExerciseListView: View { Task { await sync.save(split: doc) } } - private func startWorkout() { - let start = Date() + private func start() { + let startDate = Date() let logs = split.exercisesArray.enumerated().map { i, ex in WorkoutLogDocument( id: ULID.make(), exerciseName: ex.name, order: i, @@ -150,7 +202,7 @@ struct ExerciseListView: View { loadType: ex.loadType, durationSeconds: ex.durationTotalSeconds, currentStateIndex: 0, completed: false, status: WorkoutStatus.notStarted.rawValue, - notes: nil, date: start + notes: nil, date: startDate ) } let doc = WorkoutDocument( @@ -158,11 +210,11 @@ struct ExerciseListView: View { id: ULID.make(), splitID: split.id, splitName: split.name, - start: start, + start: startDate, end: nil, status: WorkoutStatus.notStarted.rawValue, - createdAt: start, - updatedAt: start, + createdAt: startDate, + updatedAt: startDate, logs: logs ) Task { diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift index cd7ba72..86dec89 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogListView.swift @@ -26,7 +26,6 @@ struct WorkoutLogListView: View { @State private var doc: WorkoutDocument @State private var showingAddSheet = false - @State private var showingActions = false @State private var showingEndOptions = false @State private var logToDelete: WorkoutLogDocument? @State private var addedLog: LogRoute? @@ -132,8 +131,19 @@ struct WorkoutLogListView: View { } .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - Button { - showingActions = true + Menu { + Button { + showingAddSheet = true + } label: { + Label("Add Exercise", systemImage: "plus") + } + if !sortedLogs.isEmpty { + Button { + showingEndOptions = true + } label: { + Label("End Workout", systemImage: "flag.checkered") + } + } } label: { Image(systemName: "ellipsis.circle") } @@ -164,17 +174,6 @@ struct WorkoutLogListView: View { logToDelete = nil } } - .confirmationDialog( - "Workout", - isPresented: $showingActions, - titleVisibility: .hidden - ) { - Button("Add Exercise") { showingAddSheet = true } - if !sortedLogs.isEmpty { - Button("End Workout") { showingEndOptions = true } - } - Button("Cancel", role: .cancel) {} - } .confirmationDialog( "End Workout?", isPresented: $showingEndOptions, @@ -313,11 +312,9 @@ struct WorkoutLogListView: View { /// the recompute resolves the workout to `.completed` (stamping `end`). The status flip /// is what drops the run off the watch's active list and ends its HealthKit session. private func endWorkout() { - for i in doc.logs.indices where workoutStatus(doc.logs[i]) != .completed { - doc.logs[i].status = WorkoutStatus.skipped.rawValue - doc.logs[i].completed = false - } - save() + doc.endKeepingProgress() + let snapshot = doc + Task { await sync.save(workout: snapshot) } dismiss() } diff --git a/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift b/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift index 655bd3d..9b2aad9 100644 --- a/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift +++ b/Workouts/Views/WorkoutLogs/WorkoutLogsView.swift @@ -114,12 +114,23 @@ struct SplitPickerSheet: View { @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 { - startWorkout(with: split) + confirmAndStart(with: split) } label: { HStack { Image(systemName: split.systemImage) @@ -141,11 +152,58 @@ struct SplitPickerSheet: View { } } } + .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 func startWorkout(with split: Split) { - let start = Date() + 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(), @@ -160,7 +218,7 @@ struct SplitPickerSheet: View { completed: false, status: WorkoutStatus.notStarted.rawValue, notes: nil, - date: start + date: startDate ) } @@ -170,11 +228,11 @@ struct SplitPickerSheet: View { id: ULID.make(), splitID: split.id, splitName: split.name, - start: start, + start: startDate, end: nil, status: WorkoutStatus.notStarted.rawValue, - createdAt: start, - updatedAt: start, + createdAt: startDate, + updatedAt: startDate, logs: logs )