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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user