fix: move Live Activity ownership to ViewModel, fix timer-at-0 and background freeze

**Architecture (PlayerViewModel):**
- Move ActivityKit lifecycle from SwiftUI View to ViewModel (MVVM correction)
- call syncActivity() at END of enterPhase() — after all state is set,
  eliminating the race where phase was Published before timeRemaining
- Always recalculate phaseEndDate = Date() + timeRemaining (no stale cache)
- Dedicated Timer in ViewModel for periodic heart-rate/track sync (5s)
- Start/stop activity sync timer on play/pause/resume/abandon/finish
- stale activity reference discard + recreate-on-failure fallback
- Modern iOS 16.2+ API: ActivityContent, non-throwing update()

**PlayerView:**
- Remove all ActivityKit code (import, @State workoutActivity,
  phaseEndDate, dynamicIslandAvailable, 4 methods, .onReceive timer)
- Delegate to ViewModel: onChange(musicVM.currentTrack) sets vm.trackTitle/Artist
  and calls vm.syncActivity(); onDisappear calls await vm.endActivity()
- Music/audio onChange handlers no longer contain activity logic

**Info.plist:**
- Add UIBackgroundModes → audio so music continues and app stays alive
  in background, allowing Timer-based activity updates
- Widget Info.plist: add NSSupportsLiveActivitiesFrequentUpdates

**WorkoutActivityAttributes.ContentState:**
- Add Sendable conformance for Swift 6 strict concurrency

Fixes: timer stuck at 0 on first work phase, exercise name missing,
       music stopping in background, Dynamic Island freezing in background,
       widget drift due to cached phaseEndDate
This commit is contained in:
Millian Lamiaux
2026-05-03 15:40:36 +02:00
parent b0d364eca2
commit c715c797f9
5 changed files with 115 additions and 82 deletions

View File

@@ -2,7 +2,7 @@ import ActivityKit
import Foundation
struct WorkoutActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
public struct ContentState: Codable, Hashable, Sendable {
var exerciseName: String
var phase: String
var phaseEndDate: Date

View File

@@ -18,8 +18,6 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -29,6 +27,8 @@
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSHealthShareUsageDescription</key>
<string>TabataGo reads your health data to show fitness stats and personalize your workouts.</string>
<key>NSHealthUpdateUsageDescription</key>
@@ -37,6 +37,12 @@
<string>TabataGo uses motion data to improve calorie estimates during workouts.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>POSTHOG_API_KEY</key>
<string>$(POSTHOG_API_KEY)</string>
<key>REVENUECAT_API_KEY</key>

View File

@@ -2,6 +2,7 @@ import Foundation
import SwiftData
import SwiftUI
import UIKit
@preconcurrency import ActivityKit
// Timer Phase
@@ -39,12 +40,18 @@ final class PlayerViewModel: ObservableObject {
@Published var showExitConfirmation: Bool = false
@Published private(set) var completedSession: WorkoutSession? = nil
// Track info for Live Activity set by View when music changes
var currentTrackTitle = ""
var currentTrackArtist = ""
// Private
private let program: WorkoutProgram
private var timer: Timer? = nil
private var startedAt: Date? = nil
private var modelContext: ModelContext? = nil
private var liveSession = LiveWorkoutSession()
private var workoutActivity: Activity<WorkoutActivityAttributes>?
private var activitySyncTimer: Timer?
// Warmup phase index (step through warmup movements)
private var warmupIndex: Int = 0
@@ -99,7 +106,9 @@ final class PlayerViewModel: ObservableObject {
func abandonWorkout() {
timer?.invalidate()
stopActivitySyncTimer()
Task { try? await liveSession.end() }
Task { await endActivity() }
AnalyticsService.shared.workoutAbandoned(
programId: program.id,
atRound: currentRound,
@@ -133,11 +142,14 @@ final class PlayerViewModel: ObservableObject {
)
startTimer()
syncActivity()
startActivitySyncTimer()
}
private func pauseWorkout() {
isPaused = true
timer?.invalidate()
stopActivitySyncTimer()
liveSession.pause()
softHaptics.impactOccurred()
}
@@ -146,6 +158,9 @@ final class PlayerViewModel: ObservableObject {
isPaused = false
liveSession.resume()
startTimer()
startActivitySyncTimer()
// Fresh phaseEndDate after unpause
syncActivity()
}
private func startTimer() {
@@ -295,11 +310,16 @@ final class PlayerViewModel: ObservableObject {
if isRunning && !isPaused {
startTimer()
}
syncActivity()
}
// Workout Completion
private func finishWorkout() async {
stopActivitySyncTimer()
await endActivity()
let now = Date()
let duration = Int(now.timeIntervalSince(startedAt ?? now))
@@ -340,4 +360,83 @@ final class PlayerViewModel: ObservableObject {
private func estimateCalories() -> Double {
Double(program.estimatedCalories)
}
// Dynamic Island / Live Activity
func syncActivity() {
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
guard isRunning else { return }
let isPlayingMusic = (phase == .work || phase == .rest) && isRunning && !isPaused
let phaseEnd = Date().addingTimeInterval(Double(timeRemaining))
let state = WorkoutActivityAttributes.ContentState(
exerciseName: currentExercise?.nameEn ?? "",
phase: phase.rawValue,
phaseEndDate: phaseEnd,
roundCurrent: currentRound,
roundTotal: totalRoundsInBlock,
heartRate: heartRate,
trackTitle: currentTrackTitle,
trackArtist: currentTrackArtist,
isPlaying: isPlayingMusic
)
// Discard stale reference user may have dismissed the Dynamic Island
if let existing = workoutActivity, existing.activityState != .active {
workoutActivity = nil
}
if let existing = workoutActivity {
Task { @MainActor in
await existing.update(ActivityContent(state: state, staleDate: nil))
}
} else {
createOrUpdateActivity(with: state)
}
}
private func createOrUpdateActivity(with state: WorkoutActivityAttributes.ContentState) {
let attrs = WorkoutActivityAttributes()
do {
workoutActivity = try Activity.request(
attributes: attrs,
content: ActivityContent(state: state, staleDate: nil)
)
} catch {
// Will retry on next syncActivity() call
}
}
func endActivity() async {
guard let activity = workoutActivity else { return }
workoutActivity = nil
guard activity.activityState == .active else { return }
let finalState = WorkoutActivityAttributes.ContentState(
exerciseName: activity.content.state.exerciseName,
phase: "complete",
phaseEndDate: activity.content.state.phaseEndDate,
roundCurrent: activity.content.state.roundCurrent,
roundTotal: activity.content.state.roundTotal,
heartRate: activity.content.state.heartRate,
trackTitle: activity.content.state.trackTitle,
trackArtist: activity.content.state.trackArtist,
isPlaying: false
)
await activity.end(ActivityContent(state: finalState, staleDate: nil), dismissalPolicy: .immediate)
}
private func startActivitySyncTimer() {
stopActivitySyncTimer()
activitySyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
self?.syncActivity()
}
}
}
private func stopActivitySyncTimer() {
activitySyncTimer?.invalidate()
activitySyncTimer = nil
}
}

View File

@@ -1,5 +1,4 @@
import SwiftUI
@preconcurrency import ActivityKit
/// Full-screen Tabata workout player video-first layout with overlay timer.
struct PlayerView: View {
@@ -12,8 +11,6 @@ struct PlayerView: View {
@State private var topBarVisible = true
@State private var nowPlayingExpanded = false
@State private var autoHideTask: Task<Void, Never>?
@State private var workoutActivity: Activity<WorkoutActivityAttributes>?
@State private var phaseEndDate: Date?
init(program: WorkoutProgram) {
self.program = program
@@ -142,36 +139,29 @@ struct PlayerView: View {
autoHideTask?.cancel()
UIApplication.shared.isIdleTimerDisabled = false
musicVM.stop()
endWorkoutActivity()
Task { await vm.endActivity() }
}
.onChange(of: vm.isRunning) { _, running in
let musicPhase = vm.phase == .work || vm.phase == .rest
let shouldPlay = running && !vm.isPaused && musicPhase
musicVM.setPlaying(shouldPlay)
if running { phaseEndDate = nil }
updateWorkoutActivity()
}
.onChange(of: vm.isPaused) { _, paused in
let musicPhase = vm.phase == .work || vm.phase == .rest
let shouldPlay = vm.isRunning && !paused && musicPhase
musicVM.setPlaying(shouldPlay)
if !paused { phaseEndDate = nil }
updateWorkoutActivity()
if paused { showTopBar() }
}
.onChange(of: vm.phase) { _, phase in
let musicPhase = phase == .work || phase == .rest
let shouldPlay = vm.isRunning && !vm.isPaused && musicPhase
musicVM.setPlaying(shouldPlay)
phaseEndDate = nil
updateWorkoutActivity()
showTopBar()
}
.onChange(of: musicVM.currentTrack) { _, _ in
updateWorkoutActivity()
}
.onReceive(Timer.publish(every: 5, on: .main, in: .common).autoconnect()) { _ in
updateWorkoutActivity()
.onChange(of: musicVM.currentTrack) { _, track in
vm.currentTrackTitle = track?.title ?? ""
vm.currentTrackArtist = track?.artist ?? ""
vm.syncActivity()
}
.onReceive(NotificationCenter.default.publisher(for: .skipTrackFromActivity)) { _ in
musicVM.skipTrack()
@@ -204,70 +194,6 @@ struct PlayerView: View {
}
}
// Dynamic Island
private var dynamicIslandAvailable: Bool {
#if targetEnvironment(simulator)
true
#else
ActivityAuthorizationInfo().areActivitiesEnabled
#endif
}
@MainActor
private func updateWorkoutActivity() {
guard dynamicIslandAvailable else { return }
guard vm.isRunning else { return }
let phaseEnd: Date
if let stored = phaseEndDate {
phaseEnd = stored
} else {
let calculated = Date().addingTimeInterval(Double(vm.timeRemaining))
phaseEndDate = calculated
phaseEnd = calculated
}
let isPlaying = (vm.phase == .work || vm.phase == .rest) && vm.isRunning && !vm.isPaused
let track = musicVM.currentTrack
let state = WorkoutActivityAttributes.ContentState(
exerciseName: vm.currentExercise?.nameEn ?? "",
phase: vm.phase.rawValue,
phaseEndDate: phaseEnd,
roundCurrent: vm.currentRound,
roundTotal: vm.totalRoundsInBlock,
heartRate: vm.heartRate,
trackTitle: track?.title ?? "",
trackArtist: track?.artist ?? "",
isPlaying: isPlaying
)
if let existing = workoutActivity {
Task { await existing.update(using: state) }
} else {
let attrs = WorkoutActivityAttributes()
workoutActivity = try? Activity.request(attributes: attrs, contentState: state, pushType: nil)
}
}
@MainActor
private func endWorkoutActivity() {
guard let activity = workoutActivity else { return }
let state = WorkoutActivityAttributes.ContentState(
exerciseName: activity.contentState.exerciseName,
phase: activity.contentState.phase,
phaseEndDate: activity.contentState.phaseEndDate,
roundCurrent: activity.contentState.roundCurrent,
roundTotal: activity.contentState.roundTotal,
heartRate: activity.contentState.heartRate,
trackTitle: activity.contentState.trackTitle,
trackArtist: activity.contentState.trackArtist,
isPlaying: false
)
Task {
await activity.end(using: state, dismissalPolicy: .immediate)
workoutActivity = nil
}
}
}
// Sub-components