From e42c1217dbcc0d95ddf6d8f5b356f6ba66110574 Mon Sep 17 00:00:00 2001 From: Millian Lamiaux Date: Sat, 23 May 2026 00:40:41 +0200 Subject: [PATCH] fix: Live Activity persists after workout cancel/background Root cause: observeActivityState() prematurely set workoutActivity=nil when the activity went .stale (e.g. app backgrounded >2 minutes). This prevented endActivity() from calling .end() on the stale activity, leaving it visible on the Lock Screen and Dynamic Island indefinitely. Fixes (all in PlayerViewModel.swift): 1. observeActivityState(): Split the monolithic stale/ended/dismissed handler. .stale now only stops the sync timer but keeps the workoutActivity reference so endActivity() can still call .end() to properly dismiss the stale Live Activity. 2. syncActivity() nil guard: Changed from != .active to explicit == .ended || == .dismissed so stale activities are not prematurely discarded when tick() re-enters syncActivity(). 3. endActivity(): Added stopActivitySyncTimer() defensive call at top to prevent orphaned timer from racing in and recreating the activity during .end(). Also relaxed the guard from == .active to != .ended && != .dismissed so stale activities can be ended. 4. abandonWorkout(): Explicitly set isRunning=false + isPaused=false before cleanup to prevent accidental Live Activity recreation. --- .../TabataGo/ViewModels/PlayerViewModel.swift | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift b/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift index 38de8fc..69ba7b5 100644 --- a/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift +++ b/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift @@ -116,6 +116,8 @@ final class PlayerViewModel: ObservableObject { } func abandonWorkout() { + isRunning = false + isPaused = false timer?.invalidate() stopActivitySyncTimer() Task { try? await liveSession.end() } @@ -401,7 +403,8 @@ final class PlayerViewModel: ObservableObject { phaseElapsedSeconds: max(0, Double(totalPhaseTime) - Double(timeRemaining)) ) - if let existing = workoutActivity, existing.activityState != .active { + if let existing = workoutActivity, + existing.activityState == .ended || existing.activityState == .dismissed { workoutActivity = nil } @@ -449,12 +452,14 @@ final class PlayerViewModel: ObservableObject { } func endActivity() async { + stopActivitySyncTimer() guard let activity = workoutActivity else { return } workoutActivity = nil activityStateTask?.cancel() activityStateTask = nil nonisolated(unsafe) let safeActivity = activity - guard safeActivity.activityState == .active else { return } + guard safeActivity.activityState != .ended, + safeActivity.activityState != .dismissed else { return } let finalState = WorkoutActivityAttributes.ContentState( exerciseName: safeActivity.content.state.exerciseName, phase: .complete, @@ -497,7 +502,11 @@ final class PlayerViewModel: ObservableObject { activityStateTask = Task { @MainActor [weak self] in for await state in activity.activityStateUpdates { guard let self else { return } - if state == .stale || state == .ended || state == .dismissed { + if state == .stale { + // Stop sync timer, but keep the activity reference + // so endActivity() can still call .end() to properly dismiss it. + self.stopActivitySyncTimer() + } else if state == .ended || state == .dismissed { self.workoutActivity = nil self.stopActivitySyncTimer() }