diff --git a/tabatago-swift/TabataGo/Models/WorkoutActivityAttributes.swift b/tabatago-swift/TabataGo/Models/WorkoutActivityAttributes.swift
index 91c65c9..afeb841 100644
--- a/tabatago-swift/TabataGo/Models/WorkoutActivityAttributes.swift
+++ b/tabatago-swift/TabataGo/Models/WorkoutActivityAttributes.swift
@@ -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
diff --git a/tabatago-swift/TabataGo/Resources/Info.plist b/tabatago-swift/TabataGo/Resources/Info.plist
index ca35efb..9af55fc 100644
--- a/tabatago-swift/TabataGo/Resources/Info.plist
+++ b/tabatago-swift/TabataGo/Resources/Info.plist
@@ -18,8 +18,6 @@
APPL
CFBundleShortVersionString
1.0
- CFBundleVersion
- 1
CFBundleURLTypes
@@ -29,6 +27,8 @@
+ CFBundleVersion
+ 1
NSHealthShareUsageDescription
TabataGo reads your health data to show fitness stats and personalize your workouts.
NSHealthUpdateUsageDescription
@@ -37,6 +37,12 @@
TabataGo uses motion data to improve calorie estimates during workouts.
NSSupportsLiveActivities
+ NSSupportsLiveActivitiesFrequentUpdates
+
+ UIBackgroundModes
+
+ audio
+
POSTHOG_API_KEY
$(POSTHOG_API_KEY)
REVENUECAT_API_KEY
diff --git a/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift b/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift
index 45fe3a6..779c5a7 100644
--- a/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift
+++ b/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift
@@ -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?
+ 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
+ }
}
diff --git a/tabatago-swift/TabataGo/Views/Player/PlayerView.swift b/tabatago-swift/TabataGo/Views/Player/PlayerView.swift
index 176a0c6..c70cfc2 100644
--- a/tabatago-swift/TabataGo/Views/Player/PlayerView.swift
+++ b/tabatago-swift/TabataGo/Views/Player/PlayerView.swift
@@ -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?
- @State private var workoutActivity: Activity?
- @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 ───────────────────────────────────────────────
diff --git a/tabatago-swift/TabataGoWidget/Info.plist b/tabatago-swift/TabataGoWidget/Info.plist
index b658455..fb382bf 100644
--- a/tabatago-swift/TabataGoWidget/Info.plist
+++ b/tabatago-swift/TabataGoWidget/Info.plist
@@ -27,5 +27,7 @@
NSSupportsLiveActivities
+ NSSupportsLiveActivitiesFrequentUpdates
+