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 +