feat: redesign player with Dynamic Island, compact timer, and fix Live Activity timer drift #2
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────
|
||||
|
||||
@@ -27,5 +27,7 @@
|
||||
</dict>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
Reference in New Issue
Block a user