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:
Millian Lamiaux
2026-04-21 21:55:00 +02:00
parent 8c90b73d90
commit 89cca25e22
285 changed files with 11212 additions and 44392 deletions

View 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)")
}
}
}

View 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
}
}
}

View 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()
}
}
}

View 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)
}
}

View 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
}
}
}