2 Commits

Author SHA1 Message Date
Millian Lamiaux
df9fd48964 chore: update tabatago-swift submodule (Live Activity fix)
Some checks failed
CI / TypeScript (pull_request) Failing after 4s
CI / ESLint (pull_request) Failing after 4s
CI / Tests (pull_request) Failing after 6s
CI / Build Check (pull_request) Has been skipped
CI / Admin Web Tests (pull_request) Successful in 2m7s
CI / Deploy Edge Functions (pull_request) Has been skipped
Includes fix for stale Live Activity persisting after workout
cancel/background. See submodule commit e42c121 for details.
2026-05-23 00:41:41 +02:00
Millian Lamiaux
e42c1217db 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.
2026-05-23 00:40:41 +02:00
4 changed files with 20 additions and 5 deletions

1
.gitignore vendored
View File

@@ -54,3 +54,4 @@ coverage/
node-compile-cache/
.gitnexus
Config/Secrets.xcconfig
tabatago-swift/build/

View File

@@ -7,12 +7,12 @@
<key>TabataGo.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>4</integer>
<integer>1</integer>
</dict>
<key>TabataGoTests.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
<integer>0</integer>
</dict>
<key>TabataGoUITests.xcscheme_^#shared#^_</key>
<dict>
@@ -29,6 +29,11 @@
<key>orderHint</key>
<integer>1</integer>
</dict>
<key>TabataGoWidget.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>

View File

@@ -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()
}