remove Expo project and all related files
Remove the entire Expo/React Native application: routes (app/), source code (src/), assets, iOS native build, config plugins, StoreKit config, npm dependencies, TypeScript/ESLint/Vitest configs, and Expo-specific documentation. The repository now contains only: admin-web, supabase, youtube-worker, tabatago-swift, docs, scripts, and CI/tooling configs.
This commit is contained in:
23
tabatago-swift/TabataGo/ViewModels/HealthViewModel.swift
Normal file
23
tabatago-swift/TabataGo/ViewModels/HealthViewModel.swift
Normal file
@@ -0,0 +1,23 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
final class HealthViewModel: ObservableObject {
|
||||
|
||||
@Published var snapshot: HealthSnapshot? = nil
|
||||
@Published var isLoading = false
|
||||
|
||||
func refresh() async {
|
||||
guard HealthKitService.shared.isAvailable else { return }
|
||||
guard await HealthKitService.shared.isAuthorized else { return }
|
||||
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
snapshot = try await HealthKitService.shared.fetchSnapshot()
|
||||
} catch {
|
||||
print("[HealthVM] Failed to fetch snapshot: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
60
tabatago-swift/TabataGo/ViewModels/HomeViewModel.swift
Normal file
60
tabatago-swift/TabataGo/ViewModels/HomeViewModel.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Loads workout programs from Supabase with local SwiftData cache fallback.
|
||||
@MainActor
|
||||
final class HomeViewModel: ObservableObject {
|
||||
|
||||
@Published var allPrograms: [WorkoutProgram] = []
|
||||
@Published var isLoading = false
|
||||
@Published var error: String? = nil
|
||||
|
||||
var featuredPrograms: [WorkoutProgram] {
|
||||
allPrograms.filter { $0.isFree }.prefix(5).map { $0 }
|
||||
}
|
||||
|
||||
/// Unique body zones derived from fetched programs, falling back to known zones before data loads.
|
||||
var availableZones: [String] {
|
||||
let preferred = ["full-body", "upper-body", "lower-body"]
|
||||
guard !allPrograms.isEmpty else { return preferred }
|
||||
let unique = Array(Set(allPrograms.map(\.bodyZone)))
|
||||
return unique.sorted { a, b in
|
||||
let ia = preferred.firstIndex(of: a) ?? Int.max
|
||||
let ib = preferred.firstIndex(of: b) ?? Int.max
|
||||
return ia < ib
|
||||
}
|
||||
}
|
||||
|
||||
private let cacheKey = "tabatafit-programs-v1"
|
||||
|
||||
/// Designated init for production use.
|
||||
init() {}
|
||||
|
||||
/// Preview/test init — injects programs directly, no async loading needed.
|
||||
init(previewPrograms: [WorkoutProgram]) {
|
||||
self.allPrograms = previewPrograms
|
||||
}
|
||||
|
||||
func loadPrograms() async {
|
||||
guard allPrograms.isEmpty else { return }
|
||||
await fetchPrograms()
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
await fetchPrograms()
|
||||
}
|
||||
|
||||
private func fetchPrograms() async {
|
||||
// isConfigured guard inside SupabaseService handles preview/unconfigured cases.
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
if let remote = try await SupabaseService.shared.fetchAllPrograms() {
|
||||
allPrograms = remote
|
||||
}
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
124
tabatago-swift/TabataGo/ViewModels/MusicPlayerViewModel.swift
Normal file
124
tabatago-swift/TabataGo/ViewModels/MusicPlayerViewModel.swift
Normal file
@@ -0,0 +1,124 @@
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
/// Streams workout music via AVPlayer, synced to the workout timer state.
|
||||
/// Fetches tracks from Supabase via MusicService, auto-advances on finish.
|
||||
@MainActor
|
||||
final class MusicPlayerViewModel: ObservableObject {
|
||||
|
||||
// ── Public State ──────────────────────────────────────────────
|
||||
@Published private(set) var currentTrack: MusicTrack?
|
||||
@Published private(set) var isReady = false
|
||||
@Published private(set) var error: String?
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────
|
||||
private let vibe: MusicVibe
|
||||
private let audio = AudioService.shared
|
||||
|
||||
// ── Player ────────────────────────────────────────────────────
|
||||
private var player: AVPlayer?
|
||||
private var tracks: [MusicTrack] = []
|
||||
private var currentIndex = 0
|
||||
private var endObserver: Any?
|
||||
|
||||
// ── Init / Deinit ─────────────────────────────────────────────
|
||||
|
||||
init(vibe: MusicVibe) {
|
||||
self.vibe = vibe
|
||||
}
|
||||
|
||||
/// Call once after init (e.g. in .onAppear / .task).
|
||||
func load() async {
|
||||
tracks = await MusicService.shared.loadTracks(for: vibe)
|
||||
guard !tracks.isEmpty else {
|
||||
error = "No tracks available"
|
||||
return
|
||||
}
|
||||
|
||||
currentIndex = Int.random(in: 0..<tracks.count)
|
||||
let track = tracks[currentIndex]
|
||||
currentTrack = track
|
||||
preparePlayer(for: track)
|
||||
isReady = true
|
||||
}
|
||||
|
||||
/// Call from .onDisappear to tear down player and observers.
|
||||
func stop() {
|
||||
if let obs = endObserver {
|
||||
NotificationCenter.default.removeObserver(obs)
|
||||
endObserver = nil
|
||||
}
|
||||
player?.pause()
|
||||
player = nil
|
||||
}
|
||||
|
||||
// ── Playback Control ──────────────────────────────────────────
|
||||
|
||||
func play() {
|
||||
guard audio.isMusicEnabled, player != nil else { return }
|
||||
player?.volume = audio.musicVolume
|
||||
player?.play()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player?.pause()
|
||||
}
|
||||
|
||||
func setPlaying(_ playing: Bool) {
|
||||
playing ? play() : pause()
|
||||
}
|
||||
|
||||
func updateVolume() {
|
||||
player?.volume = audio.musicVolume
|
||||
}
|
||||
|
||||
func skipTrack() {
|
||||
advanceToNext()
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────
|
||||
|
||||
private func preparePlayer(for track: MusicTrack) {
|
||||
// Remove previous end-of-track observer
|
||||
if let obs = endObserver {
|
||||
NotificationCenter.default.removeObserver(obs)
|
||||
endObserver = nil
|
||||
}
|
||||
|
||||
let item = AVPlayerItem(url: track.url)
|
||||
|
||||
if player == nil {
|
||||
player = AVPlayer(playerItem: item)
|
||||
} else {
|
||||
player?.replaceCurrentItem(with: item)
|
||||
}
|
||||
|
||||
player?.volume = audio.musicVolume
|
||||
|
||||
// Skip into track (avoid intros) — start at 10s like the Expo app
|
||||
let startOffset = CMTime(seconds: min(10, Double(track.duration) / 2), preferredTimescale: 1)
|
||||
player?.seek(to: startOffset)
|
||||
|
||||
// Auto-advance when track ends
|
||||
endObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemDidPlayToEndTime,
|
||||
object: item,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in self?.advanceToNext() }
|
||||
}
|
||||
}
|
||||
|
||||
private func advanceToNext() {
|
||||
guard !tracks.isEmpty else { return }
|
||||
currentIndex = (currentIndex + 1) % tracks.count
|
||||
let track = tracks[currentIndex]
|
||||
currentTrack = track
|
||||
preparePlayer(for: track)
|
||||
|
||||
if audio.isMusicEnabled {
|
||||
player?.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
334
tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift
Normal file
334
tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift
Normal file
@@ -0,0 +1,334 @@
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// ─── Timer Phase ──────────────────────────────────────────────────
|
||||
|
||||
enum TimerPhase: String, Equatable {
|
||||
case prep
|
||||
case warmup
|
||||
case work
|
||||
case rest
|
||||
case interBlockRest
|
||||
case cooldown
|
||||
case complete
|
||||
}
|
||||
|
||||
// ─── Player ViewModel ─────────────────────────────────────────────
|
||||
|
||||
/// Drives the workout player. Manages timer, phase transitions,
|
||||
/// haptics, audio, HealthKit live session, and session recording.
|
||||
@MainActor
|
||||
final class PlayerViewModel: ObservableObject {
|
||||
|
||||
// ── Published State ───────────────────────────────────────────
|
||||
@Published var phase: TimerPhase = .prep
|
||||
@Published var timeRemaining: Int = 5
|
||||
@Published var totalPhaseTime: Int = 5
|
||||
@Published var currentRound: Int = 1
|
||||
@Published var totalRoundsInBlock: Int = 8
|
||||
@Published var currentBlockIndex: Int = 0
|
||||
@Published var isRunning: Bool = false
|
||||
@Published var isPaused: Bool = false
|
||||
@Published var calories: Double = 0
|
||||
@Published var heartRate: Double = 0
|
||||
@Published var liveCalories: Double = 0
|
||||
@Published var currentExercise: TabataExercise? = nil
|
||||
@Published var isComplete: Bool = false
|
||||
@Published var showExitConfirmation: Bool = false
|
||||
@Published private(set) var completedSession: WorkoutSession? = nil
|
||||
|
||||
// ── Private ───────────────────────────────────────────────────
|
||||
private let program: WorkoutProgram
|
||||
private var timer: Timer? = nil
|
||||
private var startedAt: Date? = nil
|
||||
private var modelContext: ModelContext? = nil
|
||||
private var liveSession = LiveWorkoutSession()
|
||||
|
||||
// Warmup phase index (step through warmup movements)
|
||||
private var warmupIndex: Int = 0
|
||||
// Cooldown phase index
|
||||
private var cooldownIndex: Int = 0
|
||||
|
||||
private var currentBlock: TabataBlock? {
|
||||
guard currentBlockIndex < program.blocks.count else { return nil }
|
||||
return program.blocks[currentBlockIndex]
|
||||
}
|
||||
|
||||
private let audio = AudioService.shared
|
||||
private let haptics = UIImpactFeedbackGenerator(style: .rigid)
|
||||
private let softHaptics = UIImpactFeedbackGenerator(style: .soft)
|
||||
|
||||
init(program: WorkoutProgram) {
|
||||
self.program = program
|
||||
}
|
||||
|
||||
func setup(modelContext: ModelContext) {
|
||||
self.modelContext = modelContext
|
||||
haptics.prepare()
|
||||
softHaptics.prepare()
|
||||
enterPhase(.prep)
|
||||
}
|
||||
|
||||
// ─── Controls ─────────────────────────────────────────────────
|
||||
|
||||
func togglePlayPause() {
|
||||
if !isRunning {
|
||||
startWorkout()
|
||||
} else if isPaused {
|
||||
resumeWorkout()
|
||||
} else {
|
||||
pauseWorkout()
|
||||
}
|
||||
}
|
||||
|
||||
func skipPhase() {
|
||||
timer?.invalidate()
|
||||
advancePhase()
|
||||
}
|
||||
|
||||
func abandonWorkout() {
|
||||
timer?.invalidate()
|
||||
Task { try? await liveSession.end() }
|
||||
AnalyticsService.shared.workoutAbandoned(
|
||||
programId: program.id,
|
||||
atRound: currentRound,
|
||||
totalRounds: program.totalRounds
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Timer Engine ─────────────────────────────────────────────
|
||||
|
||||
private func startWorkout() {
|
||||
startedAt = Date()
|
||||
isRunning = true
|
||||
|
||||
// Start HealthKit live session
|
||||
Task {
|
||||
try? await HealthKitService.shared.requestAuthorization()
|
||||
try? await liveSession.start(startDate: startedAt!)
|
||||
liveSession.onHeartRateUpdate = { [weak self] hr in
|
||||
Task { @MainActor in self?.heartRate = hr }
|
||||
}
|
||||
liveSession.onCaloriesUpdate = { [weak self] cal in
|
||||
Task { @MainActor in self?.liveCalories = cal }
|
||||
}
|
||||
}
|
||||
|
||||
AnalyticsService.shared.workoutStarted(
|
||||
programId: program.id,
|
||||
programTitle: program.titleEn,
|
||||
bodyZone: program.bodyZone,
|
||||
level: program.level
|
||||
)
|
||||
|
||||
startTimer()
|
||||
}
|
||||
|
||||
private func pauseWorkout() {
|
||||
isPaused = true
|
||||
timer?.invalidate()
|
||||
liveSession.pause()
|
||||
softHaptics.impactOccurred()
|
||||
}
|
||||
|
||||
private func resumeWorkout() {
|
||||
isPaused = false
|
||||
liveSession.resume()
|
||||
startTimer()
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
timer?.invalidate()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in self?.tick() }
|
||||
}
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
guard !isPaused else { return }
|
||||
|
||||
// Countdown cue
|
||||
if timeRemaining <= 3 {
|
||||
audio.playCountdown(secondsLeft: timeRemaining)
|
||||
}
|
||||
|
||||
if timeRemaining > 1 {
|
||||
timeRemaining -= 1
|
||||
} else {
|
||||
timer?.invalidate()
|
||||
advancePhase()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phase Transitions ────────────────────────────────────────
|
||||
|
||||
private func advancePhase() {
|
||||
switch phase {
|
||||
case .prep:
|
||||
if !program.warmup.movements.isEmpty {
|
||||
warmupIndex = 0
|
||||
enterPhase(.warmup)
|
||||
} else {
|
||||
currentBlockIndex = 0
|
||||
currentRound = 1
|
||||
enterPhase(.work)
|
||||
}
|
||||
|
||||
case .warmup:
|
||||
warmupIndex += 1
|
||||
if warmupIndex < program.warmup.movements.count {
|
||||
enterPhase(.warmup) // next warmup movement
|
||||
} else {
|
||||
currentBlockIndex = 0
|
||||
currentRound = 1
|
||||
enterPhase(.work)
|
||||
}
|
||||
|
||||
case .work:
|
||||
enterPhase(.rest)
|
||||
|
||||
case .rest:
|
||||
let block = program.blocks[currentBlockIndex]
|
||||
if currentRound < block.rounds {
|
||||
currentRound += 1
|
||||
enterPhase(.work)
|
||||
} else {
|
||||
// End of block
|
||||
let nextBlockIndex = currentBlockIndex + 1
|
||||
if nextBlockIndex < program.blocks.count {
|
||||
currentBlockIndex = nextBlockIndex
|
||||
currentRound = 1
|
||||
enterPhase(.interBlockRest)
|
||||
} else {
|
||||
// All blocks done
|
||||
if !program.cooldown.movements.isEmpty {
|
||||
cooldownIndex = 0
|
||||
enterPhase(.cooldown)
|
||||
} else {
|
||||
enterPhase(.complete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .interBlockRest:
|
||||
enterPhase(.work)
|
||||
|
||||
case .cooldown:
|
||||
cooldownIndex += 1
|
||||
if cooldownIndex < program.cooldown.movements.count {
|
||||
enterPhase(.cooldown)
|
||||
} else {
|
||||
enterPhase(.complete)
|
||||
}
|
||||
|
||||
case .complete:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func enterPhase(_ newPhase: TimerPhase) {
|
||||
withAnimation(.spring(duration: 0.4)) {
|
||||
phase = newPhase
|
||||
}
|
||||
|
||||
haptics.impactOccurred(intensity: newPhase == .work ? 1.0 : 0.6)
|
||||
audio.playPhaseStart(newPhase)
|
||||
audio.announcePhase(newPhase)
|
||||
|
||||
switch newPhase {
|
||||
case .prep:
|
||||
timeRemaining = 5
|
||||
totalPhaseTime = 5
|
||||
currentExercise = nil
|
||||
|
||||
case .warmup:
|
||||
let movement = program.warmup.movements[warmupIndex]
|
||||
timeRemaining = movement.duration
|
||||
totalPhaseTime = movement.duration
|
||||
currentExercise = TabataExercise(name: movement.name, nameEn: movement.nameEn)
|
||||
|
||||
case .work:
|
||||
guard let block = currentBlock else { return }
|
||||
let exercise = currentRound % 2 == 1 ? block.exercise1 : block.exercise2
|
||||
timeRemaining = block.workTime
|
||||
totalPhaseTime = block.workTime
|
||||
totalRoundsInBlock = block.rounds
|
||||
currentExercise = exercise
|
||||
audio.announceExercise(exercise)
|
||||
|
||||
case .rest:
|
||||
guard let block = currentBlock else { return }
|
||||
timeRemaining = block.restTime
|
||||
totalPhaseTime = block.restTime
|
||||
// Preview next exercise
|
||||
let nextIsExercise1 = (currentRound + 1) % 2 == 1
|
||||
currentExercise = nextIsExercise1 ? block.exercise1 : block.exercise2
|
||||
|
||||
case .interBlockRest:
|
||||
timeRemaining = 60
|
||||
totalPhaseTime = 60
|
||||
currentExercise = nil
|
||||
|
||||
case .cooldown:
|
||||
let movement = program.cooldown.movements[cooldownIndex]
|
||||
timeRemaining = movement.duration
|
||||
totalPhaseTime = movement.duration
|
||||
currentExercise = TabataExercise(name: movement.name, nameEn: movement.nameEn)
|
||||
|
||||
case .complete:
|
||||
currentExercise = nil
|
||||
timeRemaining = 0
|
||||
Task { await finishWorkout() }
|
||||
}
|
||||
|
||||
if isRunning && !isPaused {
|
||||
startTimer()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Workout Completion ───────────────────────────────────────
|
||||
|
||||
private func finishWorkout() async {
|
||||
let now = Date()
|
||||
let duration = Int(now.timeIntervalSince(startedAt ?? now))
|
||||
|
||||
// Collect HealthKit data
|
||||
let (hkCalories, avgHR) = (try? await liveSession.end()) ?? (0, nil)
|
||||
let finalCalories = hkCalories > 0 ? hkCalories : estimateCalories()
|
||||
|
||||
// Build session
|
||||
let session = WorkoutSession(
|
||||
programId: program.id,
|
||||
programTitle: program.titleEn,
|
||||
bodyZone: program.bodyZone,
|
||||
level: program.level,
|
||||
startedAt: startedAt ?? now,
|
||||
completedAt: now,
|
||||
durationSeconds: duration,
|
||||
caloriesBurned: finalCalories,
|
||||
roundsCompleted: program.totalRounds,
|
||||
totalRounds: program.totalRounds
|
||||
)
|
||||
session.averageHeartRate = avgHR
|
||||
|
||||
modelContext?.insert(session)
|
||||
try? modelContext?.save()
|
||||
|
||||
completedSession = session
|
||||
isComplete = true
|
||||
|
||||
AnalyticsService.shared.workoutCompleted(
|
||||
programId: program.id,
|
||||
durationSeconds: duration,
|
||||
calories: finalCalories,
|
||||
completionRate: 1.0,
|
||||
healthKitSaved: false // updated after user confirms save in CompletionView
|
||||
)
|
||||
}
|
||||
|
||||
private func estimateCalories() -> Double {
|
||||
Double(program.estimatedCalories)
|
||||
}
|
||||
}
|
||||
46
tabatago-swift/TabataGo/ViewModels/PurchaseViewModel.swift
Normal file
46
tabatago-swift/TabataGo/ViewModels/PurchaseViewModel.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import Foundation
|
||||
import RevenueCat
|
||||
|
||||
@MainActor
|
||||
final class PurchaseViewModel: ObservableObject {
|
||||
|
||||
@Published var offerings: Offerings? = nil
|
||||
@Published var selectedPackage: Package? = nil
|
||||
@Published var isPurchasing = false
|
||||
@Published var showError = false
|
||||
@Published var errorMessage: String? = nil
|
||||
|
||||
private let service = PurchaseService.shared
|
||||
|
||||
func loadOfferings() async {
|
||||
await service.loadOfferings()
|
||||
offerings = service.offerings
|
||||
// Pre-select yearly if available
|
||||
selectedPackage = offerings?.current?.availablePackages.first {
|
||||
$0.packageType == .annual
|
||||
} ?? offerings?.current?.availablePackages.first
|
||||
}
|
||||
|
||||
func purchase() async {
|
||||
guard let package = selectedPackage else { return }
|
||||
isPurchasing = true
|
||||
do {
|
||||
try await service.purchase(package: package)
|
||||
AnalyticsService.shared.subscriptionStarted(plan: package.identifier)
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
isPurchasing = false
|
||||
}
|
||||
|
||||
func restorePurchases() async {
|
||||
do {
|
||||
try await service.restorePurchases()
|
||||
AnalyticsService.shared.subscriptionRestored()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user