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,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
}
}