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,105 @@
import Foundation
import PostHog
/// PostHog analytics mirrors the event taxonomy from the Expo app.
final class AnalyticsService: @unchecked Sendable {
static let shared = AnalyticsService()
private let apiKey: String =
Bundle.main.infoDictionary?["POSTHOG_API_KEY"] as? String ?? ""
private let host = "https://eu.posthog.com"
private init() {}
func initialize() {
guard !apiKey.isEmpty else { return }
let config = PostHogConfig(apiKey: apiKey, host: host)
config.captureApplicationLifecycleEvents = true
config.captureScreenViews = false // manual tracking
PostHogSDK.shared.setup(config)
}
// Screens
func screen(_ name: String, properties: [String: Any] = [:]) {
PostHogSDK.shared.screen(name, properties: properties)
}
// Onboarding
func onboardingCompleted(name: String, level: String, goal: String, frequency: Int) {
capture("onboarding_completed", properties: [
"fitness_level": level,
"goal": goal,
"weekly_frequency": frequency,
])
}
// Workouts
func workoutStarted(programId: String, programTitle: String, bodyZone: String, level: String) {
capture("workout_started", properties: [
"program_id": programId,
"program_title": programTitle,
"body_zone": bodyZone,
"level": level,
])
}
func workoutCompleted(
programId: String,
durationSeconds: Int,
calories: Double,
completionRate: Double,
healthKitSaved: Bool
) {
capture("workout_completed", properties: [
"program_id": programId,
"duration_seconds": durationSeconds,
"calories": calories,
"completion_rate": completionRate,
"healthkit_saved": healthKitSaved,
])
}
func workoutAbandoned(programId: String, atRound: Int, totalRounds: Int) {
capture("workout_abandoned", properties: [
"program_id": programId,
"at_round": atRound,
"total_rounds": totalRounds,
"completion_rate": Double(atRound) / Double(max(totalRounds, 1)),
])
}
// Paywall
func paywallViewed(source: String) {
capture("paywall_viewed", properties: ["source": source])
}
func subscriptionStarted(plan: String) {
capture("subscription_started", properties: ["plan": plan])
}
func subscriptionRestored() {
capture("subscription_restored")
}
// HealthKit
func healthKitPermissionGranted() {
capture("healthkit_permission_granted")
}
func healthKitPermissionDenied() {
capture("healthkit_permission_denied")
}
// Private
private func capture(_ event: String, properties: [String: Any] = [:]) {
PostHogSDK.shared.capture(event, properties: properties.isEmpty ? nil : properties)
}
}

View File

@@ -0,0 +1,147 @@
import Foundation
import AVFoundation
/// Manages all workout audio: timer beeps, phase cues, voice coaching, music.
@MainActor
@Observable
final class AudioService {
static let shared = AudioService()
var isMusicEnabled = true
var musicVolume: Float = 0.5
var isVoiceCoachingEnabled = true
var isSoundEffectsEnabled = true
private var audioSession: AVAudioSession { AVAudioSession.sharedInstance() }
private var beepPlayer: AVAudioPlayer?
private var speechSynthesizer = AVSpeechSynthesizer()
private init() {
configureSession()
}
// Session Setup
private func configureSession() {
do {
try audioSession.setCategory(
.playback,
mode: .default,
options: [.mixWithOthers, .allowAirPlay, .allowBluetooth]
)
try audioSession.setActive(true)
} catch {
print("[Audio] Session configuration failed: \(error)")
}
}
// Phase Audio Cues
func playPhaseStart(_ phase: TimerPhase) {
guard isSoundEffectsEnabled else { return }
switch phase {
case .warmup: playTone(frequency: 523, duration: 0.15, count: 2)
case .work: playTone(frequency: 880, duration: 0.2, count: 3)
case .rest: playTone(frequency: 440, duration: 0.3, count: 1)
case .interBlockRest: playTone(frequency: 392, duration: 0.4, count: 2)
case .cooldown: playTone(frequency: 660, duration: 0.2, count: 2)
case .complete: playTone(frequency: 880, duration: 0.15, count: 5)
case .prep: break
}
}
func playCountdown(secondsLeft: Int) {
guard isSoundEffectsEnabled, secondsLeft <= 3, secondsLeft > 0 else { return }
playTone(frequency: secondsLeft == 1 ? 880 : 660, duration: 0.08, count: 1)
}
// Voice Coaching
func speak(_ text: String, locale: String = "en-US") {
guard isVoiceCoachingEnabled else { return }
speechSynthesizer.stopSpeaking(at: .immediate)
let utterance = AVSpeechUtterance(string: text)
utterance.voice = AVSpeechSynthesisVoice(language: locale)
utterance.rate = 0.52
utterance.pitchMultiplier = 1.0
utterance.volume = 0.9
speechSynthesizer.speak(utterance)
}
func announceExercise(_ exercise: TabataExercise) {
speak(exercise.nameEn)
}
func announcePhase(_ phase: TimerPhase) {
guard isVoiceCoachingEnabled else { return }
let text: String
switch phase {
case .prep: text = "Get ready"
case .warmup: text = "Warm up"
case .work: text = "Work"
case .rest: text = "Rest"
case .interBlockRest: text = "Take a break"
case .cooldown: text = "Cool down"
case .complete: text = "Workout complete. Great job!"
}
speak(text)
}
// Programmatic Tone Generation
private func playTone(frequency: Double, duration: Double, count: Int) {
guard let url = generateToneURL(frequency: frequency, duration: duration) else { return }
Task {
for i in 0..<count {
if i > 0 { try? await Task.sleep(for: .milliseconds(Int(duration * 1000) + 50)) }
try? beepPlayer = AVAudioPlayer(contentsOf: url)
beepPlayer?.volume = 0.8
beepPlayer?.play()
}
}
}
private func generateToneURL(frequency: Double, duration: Double) -> URL? {
let sampleRate = 44100.0
let numSamples = Int(sampleRate * duration)
var samples = [Int16](repeating: 0, count: numSamples)
let fadeFrames = Int(sampleRate * 0.005) // 5ms fade
for i in 0..<numSamples {
let angle = 2.0 * Double.pi * frequency * Double(i) / sampleRate
var amplitude = 0.5
if i < fadeFrames { amplitude *= Double(i) / Double(fadeFrames) }
if i > numSamples - fadeFrames { amplitude *= Double(numSamples - i) / Double(fadeFrames) }
samples[i] = Int16(amplitude * Double(Int16.max) * sin(angle))
}
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("tabata_tone_\(Int(frequency)).wav")
let headerSize = 44
var data = Data(count: headerSize + numSamples * 2)
data.withUnsafeMutableBytes { ptr in
let raw = ptr.baseAddress!
// RIFF header
"RIFF".utf8.enumerated().forEach { raw.storeBytes(of: $0.element, toByteOffset: $0.offset, as: UInt8.self) }
let chunkSize = UInt32(36 + numSamples * 2).littleEndian
withUnsafeBytes(of: chunkSize) { raw.advanced(by: 4).copyMemory(from: $0.baseAddress!, byteCount: 4) }
"WAVE".utf8.enumerated().forEach { raw.storeBytes(of: $0.element, toByteOffset: 8 + $0.offset, as: UInt8.self) }
"fmt ".utf8.enumerated().forEach { raw.storeBytes(of: $0.element, toByteOffset: 12 + $0.offset, as: UInt8.self) }
let fmtSize = UInt32(16).littleEndian; withUnsafeBytes(of: fmtSize) { raw.advanced(by: 16).copyMemory(from: $0.baseAddress!, byteCount: 4) }
let audioFmt = UInt16(1).littleEndian; withUnsafeBytes(of: audioFmt) { raw.advanced(by: 20).copyMemory(from: $0.baseAddress!, byteCount: 2) }
let channels = UInt16(1).littleEndian; withUnsafeBytes(of: channels) { raw.advanced(by: 22).copyMemory(from: $0.baseAddress!, byteCount: 2) }
let sr = UInt32(sampleRate).littleEndian; withUnsafeBytes(of: sr) { raw.advanced(by: 24).copyMemory(from: $0.baseAddress!, byteCount: 4) }
let byteRate = UInt32(sampleRate * 2).littleEndian; withUnsafeBytes(of: byteRate) { raw.advanced(by: 28).copyMemory(from: $0.baseAddress!, byteCount: 4) }
let blockAlign = UInt16(2).littleEndian; withUnsafeBytes(of: blockAlign) { raw.advanced(by: 32).copyMemory(from: $0.baseAddress!, byteCount: 2) }
let bitsPerSample = UInt16(16).littleEndian; withUnsafeBytes(of: bitsPerSample) { raw.advanced(by: 34).copyMemory(from: $0.baseAddress!, byteCount: 2) }
"data".utf8.enumerated().forEach { raw.storeBytes(of: $0.element, toByteOffset: 36 + $0.offset, as: UInt8.self) }
let dataSize = UInt32(numSamples * 2).littleEndian; withUnsafeBytes(of: dataSize) { raw.advanced(by: 40).copyMemory(from: $0.baseAddress!, byteCount: 4) }
samples.withUnsafeBytes { raw.advanced(by: 44).copyMemory(from: $0.baseAddress!, byteCount: numSamples * 2) }
}
try? data.write(to: url)
return url
}
}

View File

@@ -0,0 +1,380 @@
import Foundation
import HealthKit
// NSPredicate (returned by HKQuery.predicateForSamples) is not Sendable,
// but it is documented as thread-safe. Suppress the check globally here.
extension NSPredicate: @unchecked Sendable {}
// Shared HealthKit store
/// Full HealthKit integration: read rings/HR/weight, write workouts, live session.
actor HealthKitService {
static let shared = HealthKitService()
private let store = HKHealthStore()
// Permission types
private var writeTypes: Set<HKSampleType> {
[
HKWorkoutType.workoutType(),
HKQuantityType(.activeEnergyBurned),
HKQuantityType(.heartRate),
]
}
private var readTypes: Set<HKObjectType> {
[
HKWorkoutType.workoutType(),
HKQuantityType(.activeEnergyBurned),
HKQuantityType(.restingHeartRate),
HKQuantityType(.heartRate),
HKQuantityType(.bodyMass),
HKQuantityType(.vo2Max),
HKQuantityType(.appleExerciseTime),
HKQuantityType(.appleStandTime),
HKCategoryType(.appleStandHour),
HKActivitySummaryType.activitySummaryType(),
]
}
// Authorization
nonisolated var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() }
func requestAuthorization() async throws {
guard isAvailable else { return }
try await store.requestAuthorization(toShare: writeTypes, read: readTypes)
}
var isAuthorized: Bool {
get async {
guard isAvailable else { return false }
let status = store.authorizationStatus(for: HKWorkoutType.workoutType())
return status == .sharingAuthorized
}
}
// Save a completed workout
/// Plain Sendable snapshot of a WorkoutSession safe to cross actor boundaries.
struct WorkoutSaveData: Sendable {
let startedAt: Date
let completedAt: Date
let caloriesBurned: Double
let averageHeartRate: Double?
}
@discardableResult
func saveWorkout(_ data: WorkoutSaveData) async throws -> HKWorkout {
let config = HKWorkoutConfiguration()
config.activityType = .highIntensityIntervalTraining
config.locationType = .indoor
let builder = HKWorkoutBuilder(healthStore: store, configuration: config, device: .local())
try await builder.beginCollection(at: data.startedAt)
// Active energy samples
if data.caloriesBurned > 0 {
let energyType = HKQuantityType(.activeEnergyBurned)
let energy = HKQuantity(unit: .kilocalorie(), doubleValue: data.caloriesBurned)
let sample = HKQuantitySample(
type: energyType,
quantity: energy,
start: data.startedAt,
end: data.completedAt
)
try await builder.addSamples([sample])
}
// Heart rate samples (if captured during workout)
if let avgHR = data.averageHeartRate {
let hrType = HKQuantityType(.heartRate)
let hrUnit = HKUnit.count().unitDivided(by: .minute())
let hrQuantity = HKQuantity(unit: hrUnit, doubleValue: avgHR)
let hrSample = HKQuantitySample(
type: hrType,
quantity: hrQuantity,
start: data.startedAt,
end: data.completedAt
)
try await builder.addSamples([hrSample])
}
try await builder.endCollection(at: data.completedAt)
guard let workout = try await builder.finishWorkout() else {
throw HealthKitError.workoutSaveFailed
}
return workout
}
// Read: Health Snapshot
func fetchSnapshot() async throws -> HealthSnapshot {
let snapshot = HealthSnapshot()
snapshot.fetchedAt = Date()
async let activeCalories = fetchTodayQuantity(type: .activeEnergyBurned, unit: .kilocalorie())
async let exerciseMinutes = fetchTodayQuantity(type: .appleExerciseTime, unit: .minute())
async let restingHR = fetchMostRecent(type: .restingHeartRate, unit: HKUnit.count().unitDivided(by: .minute()))
async let bodyMass = fetchMostRecent(type: .bodyMass, unit: .gramUnit(with: .kilo))
async let vo2Max = fetchMostRecent(type: .vo2Max, unit: HKUnit(from: "ml/kg·min"))
async let standHours = fetchTodayStandHours()
async let weekly = fetchWeeklySummary()
snapshot.activeCaloricBurn = (try? await activeCalories) ?? 0
snapshot.exerciseMinutes = (try? await exerciseMinutes) ?? 0
snapshot.restingHeartRate = try? await restingHR
snapshot.bodyMassKg = try? await bodyMass
snapshot.vo2Max = try? await vo2Max
snapshot.standHours = (try? await standHours) ?? 0
let weeklySummary = try? await weekly
snapshot.weeklyActiveCalories = weeklySummary?.calories ?? 0
snapshot.weeklyExerciseMinutes = weeklySummary?.exerciseMinutes ?? 0
snapshot.weeklyWorkoutCount = weeklySummary?.workoutCount ?? 0
return snapshot
}
// Private helpers
private func fetchTodayQuantity(type identifier: HKQuantityTypeIdentifier, unit: HKUnit) async throws -> Double {
let quantityType = HKQuantityType(identifier)
let now = Date()
let startOfDay = Calendar.current.startOfDay(for: now)
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now)
return try await withCheckedThrowingContinuation { continuation in
let query = HKStatisticsQuery(
quantityType: quantityType,
quantitySamplePredicate: predicate,
options: .cumulativeSum
) { _, result, error in
if let error { continuation.resume(throwing: error); return }
let value = result?.sumQuantity()?.doubleValue(for: unit) ?? 0
continuation.resume(returning: value)
}
store.execute(query)
}
}
private func fetchMostRecent(type identifier: HKQuantityTypeIdentifier, unit: HKUnit) async throws -> Double? {
let quantityType = HKQuantityType(identifier)
let predicate = HKQuery.predicateForSamples(withStart: .distantPast, end: Date())
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
return try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: quantityType,
predicate: predicate,
limit: 1,
sortDescriptors: [sortDescriptor]
) { _, samples, error in
if let error { continuation.resume(throwing: error); return }
guard let sample = samples?.first as? HKQuantitySample else {
continuation.resume(returning: nil); return
}
continuation.resume(returning: sample.quantity.doubleValue(for: unit))
}
store.execute(query)
}
}
private func fetchTodayStandHours() async throws -> Int {
let standType = HKCategoryType(.appleStandHour)
let now = Date()
let startOfDay = Calendar.current.startOfDay(for: now)
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now)
return try await withCheckedThrowingContinuation { continuation in
let query = HKSampleQuery(
sampleType: standType,
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: nil
) { _, samples, error in
if let error { continuation.resume(throwing: error); return }
let stood = samples?.compactMap { $0 as? HKCategorySample }
.filter { $0.value == HKCategoryValueAppleStandHour.stood.rawValue }
.count ?? 0
continuation.resume(returning: stood)
}
store.execute(query)
}
}
struct WeeklySummary {
var calories: Double
var exerciseMinutes: Double
var workoutCount: Int
}
private func fetchWeeklySummary() async throws -> WeeklySummary {
let now = Date()
let weekAgo = Calendar.current.date(byAdding: .day, value: -7, to: now)!
// Capture Sendable Date values; create NSPredicate inside each closure
// to avoid sending non-Sendable NSPredicate across actor boundaries.
let start = weekAgo
let end = now
async let calories = withCheckedThrowingContinuation { (c: CheckedContinuation<Double, Error>) in
let query = HKStatisticsQuery(
quantityType: HKQuantityType(.activeEnergyBurned),
quantitySamplePredicate: HKQuery.predicateForSamples(withStart: start, end: end),
options: .cumulativeSum
) { _, result, error in
if let error { c.resume(throwing: error); return }
c.resume(returning: result?.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0)
}
store.execute(query)
}
async let exerciseMinutes = withCheckedThrowingContinuation { (c: CheckedContinuation<Double, Error>) in
let query = HKStatisticsQuery(
quantityType: HKQuantityType(.appleExerciseTime),
quantitySamplePredicate: HKQuery.predicateForSamples(withStart: start, end: end),
options: .cumulativeSum
) { _, result, error in
if let error { c.resume(throwing: error); return }
c.resume(returning: result?.sumQuantity()?.doubleValue(for: .minute()) ?? 0)
}
store.execute(query)
}
async let workoutCount = withCheckedThrowingContinuation { (c: CheckedContinuation<Int, Error>) in
let query = HKSampleQuery(
sampleType: HKWorkoutType.workoutType(),
predicate: HKQuery.predicateForSamples(withStart: start, end: end),
limit: HKObjectQueryNoLimit,
sortDescriptors: nil
) { _, samples, error in
if let error { c.resume(throwing: error); return }
c.resume(returning: samples?.count ?? 0)
}
store.execute(query)
}
return try await WeeklySummary(
calories: calories,
exerciseMinutes: exerciseMinutes,
workoutCount: workoutCount
)
}
}
// Live workout session
/// Manages a live HKWorkoutSession during an active Tabata workout.
/// Provides real-time heart rate and calorie updates.
@Observable
final class LiveWorkoutSession: NSObject, HKWorkoutSessionDelegate, HKLiveWorkoutBuilderDelegate, @unchecked Sendable {
private(set) var heartRate: Double = 0
private(set) var activeCalories: Double = 0
private(set) var isActive = false
private var workoutSession: HKWorkoutSession?
private var builder: HKLiveWorkoutBuilder?
private let store = HKHealthStore()
var onHeartRateUpdate: ((Double) -> Void)?
var onCaloriesUpdate: ((Double) -> Void)?
func start(startDate: Date) async throws {
let config = HKWorkoutConfiguration()
config.activityType = .highIntensityIntervalTraining
config.locationType = .indoor
workoutSession = try HKWorkoutSession(healthStore: store, configuration: config)
builder = workoutSession?.associatedWorkoutBuilder()
builder?.dataSource = HKLiveWorkoutDataSource(healthStore: store, workoutConfiguration: config)
workoutSession?.delegate = self
builder?.delegate = self
workoutSession?.startActivity(with: startDate)
try await builder?.beginCollection(at: startDate)
isActive = true
}
func pause() {
workoutSession?.pause()
}
func resume() {
workoutSession?.resume()
}
func end() async throws -> (calories: Double, avgHeartRate: Double?) {
guard let session = workoutSession, let builder = builder else {
return (activeCalories, heartRate > 0 ? heartRate : nil)
}
session.end()
try await builder.endCollection(at: Date())
_ = try await builder.finishWorkout()
isActive = false
return (activeCalories, heartRate > 0 ? heartRate : nil)
}
// HKWorkoutSessionDelegate
nonisolated func workoutSession(
_ workoutSession: HKWorkoutSession,
didChangeTo toState: HKWorkoutSessionState,
from fromState: HKWorkoutSessionState,
date: Date
) {}
nonisolated func workoutSession(
_ workoutSession: HKWorkoutSession,
didFailWithError error: Error
) {
print("[LiveWorkout] Session error: \(error)")
}
// HKLiveWorkoutBuilderDelegate
nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
nonisolated func workoutBuilder(
_ workoutBuilder: HKLiveWorkoutBuilder,
didCollectDataOf collectedTypes: Set<HKSampleType>
) {
for type in collectedTypes {
guard let quantityType = type as? HKQuantityType else { continue }
let stats = workoutBuilder.statistics(for: quantityType)
if quantityType == HKQuantityType(.heartRate) {
let hr = stats?.mostRecentQuantity()?.doubleValue(for: HKUnit.count().unitDivided(by: .minute())) ?? 0
Task { @MainActor in
self.heartRate = hr
self.onHeartRateUpdate?(hr)
}
} else if quantityType == HKQuantityType(.activeEnergyBurned) {
let cal = stats?.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0
Task { @MainActor in
self.activeCalories = cal
self.onCaloriesUpdate?(cal)
}
}
}
}
}
// Errors
enum HealthKitError: LocalizedError {
case notAvailable
case notAuthorized
case workoutSaveFailed
var errorDescription: String? {
switch self {
case .notAvailable: "HealthKit is not available on this device."
case .notAuthorized: "HealthKit access was not granted."
case .workoutSaveFailed: "Failed to save workout to Health."
}
}
}

View File

@@ -0,0 +1,165 @@
import Foundation
import Supabase
/// Fetches music tracks from Supabase `download_items` table, with mock fallback.
actor MusicService {
static let shared = MusicService()
private var cache: [MusicVibe: [MusicTrack]] = [:]
// Public API
/// Load tracks for a given vibe. Returns cached results on subsequent calls.
func loadTracks(for vibe: MusicVibe) async -> [MusicTrack] {
if let cached = cache[vibe] { return cached }
// Try Supabase first
if let tracks = await fetchFromSupabase(vibe: vibe), !tracks.isEmpty {
cache[vibe] = tracks
return tracks
}
// Fallback to mock tracks
let mocks = Self.mockTracks(for: vibe)
cache[vibe] = mocks
return mocks
}
/// Return `count` distinct random tracks for a vibe.
func randomTracks(for vibe: MusicVibe, count: Int = 3) async -> [MusicTrack] {
let pool = await loadTracks(for: vibe)
guard !pool.isEmpty else { return [] }
if pool.count <= count {
return pool.shuffled()
}
return Array(pool.shuffled().prefix(count))
}
/// Next track after `currentId`, cycling through the list.
func nextTrack(in tracks: [MusicTrack], after currentId: String) -> MusicTrack? {
guard tracks.count > 1 else { return tracks.first }
let idx = tracks.firstIndex(where: { $0.id == currentId }) ?? 0
return tracks[(idx + 1) % tracks.count]
}
func clearCache(for vibe: MusicVibe? = nil) {
if let vibe { cache.removeValue(forKey: vibe) } else { cache.removeAll() }
}
// Supabase Fetch
private func fetchFromSupabase(vibe: MusicVibe) async -> [MusicTrack]? {
// Re-use the existing SupabaseService's client configuration.
// We build our own client here because SupabaseService is an actor
// and doesn't expose the raw client.
guard !AppEnvironment.isPreview else { return nil }
let urlRaw = Bundle.main.infoDictionary?["SUPABASE_URL"] as? String ?? ""
let key = Bundle.main.infoDictionary?["SUPABASE_ANON_KEY"] as? String ?? ""
guard !urlRaw.isEmpty, urlRaw != "https://localhost", !key.isEmpty,
let url = URL(string: urlRaw) else {
return nil
}
let client = SupabaseClient(supabaseURL: url, supabaseKey: key)
do {
let rows: [DownloadItemRow] = try await client
.from("download_items")
.select("id, video_id, title, duration_seconds, public_url, storage_path, genre")
.eq("status", value: "completed")
.in("genre", values: vibe.genres)
.limit(50)
.execute()
.value
let tracks: [MusicTrack] = rows.compactMap { row in
guard let trackURL = row.resolvedURL(supabaseBase: urlRaw) else { return nil }
let (artist, title) = Self.parseTitle(row.title ?? "Unknown Track")
return MusicTrack(
id: row.id ?? UUID().uuidString,
title: title,
artist: artist,
duration: row.duration_seconds ?? 180,
url: trackURL,
vibe: vibe
)
}
#if DEBUG
print("[MusicService] Loaded \(tracks.count) tracks for \(vibe.rawValue)")
#endif
return tracks.isEmpty ? nil : tracks
} catch {
#if DEBUG
print("[MusicService] Supabase error: \(error)")
#endif
return nil
}
}
// Helpers
/// Splits "Artist - Title" strings.
private static func parseTitle(_ raw: String) -> (artist: String, title: String) {
if raw.contains(" - ") {
let parts = raw.components(separatedBy: " - ").map { $0.trimmingCharacters(in: .whitespaces) }
return (parts[0], parts.dropFirst().joined(separator: " - "))
}
return ("YouTube Music", raw)
}
// Mock Tracks
private static let testBase = "https://www2.cs.uic.edu/~i101/SoundFiles"
private static func mockTracks(for vibe: MusicVibe) -> [MusicTrack] {
let names: [(String, String)] = {
switch vibe {
case .electronic: return [("Energy Pulse", "Neon Dreams"), ("Cyber Sprint", "Digital Flux"), ("High Voltage", "Circuit Breakers")]
case .hipHop: return [("Street Heat", "Urban Flow"), ("Rhythm Power", "Beat Masters"), ("Flow State", "MC Dynamic")]
case .pop: return [("Summer Energy", "The Popstars"), ("Upbeat Vibes", "Chart Toppers"), ("Feel Good", "Radio Hits")]
case .rock: return [("Power Chord", "The Amplifiers"), ("High Gain", "Distortion"), ("Adrenaline", "Thunderstruck")]
case .chill: return [("Smooth Flow", "Lo-Fi Beats"), ("Zen Mode", "Calm Collective"), ("Deep Breath", "Mindful Tones")]
}
}()
let files = ["StarWars60.wav", "tapioca.wav", "preamble10.wav"]
return names.enumerated().map { i, pair in
let (title, artist) = pair
return MusicTrack(
id: "\(vibe.rawValue)-\(i)",
title: title,
artist: artist,
duration: 200,
url: URL(string: "\(testBase)/\(files[i % files.count])")!,
vibe: vibe
)
}
}
}
// Supabase Row
private struct DownloadItemRow: Decodable {
let id: String?
let video_id: String?
let title: String?
let duration_seconds: Int?
let public_url: String?
let storage_path: String?
let genre: String?
func resolvedURL(supabaseBase: String) -> URL? {
if let pub = public_url, let url = URL(string: pub) { return url }
if let path = storage_path {
return URL(string: "\(supabaseBase)/storage/v1/object/public/workout-audio/\(path)")
}
return nil
}
}

View File

@@ -0,0 +1,136 @@
import Foundation
import WatchConnectivity
/// Phone-side WatchConnectivity manager.
/// Sends workout payloads to Watch, receives HR/calorie updates back.
@MainActor
@Observable
final class PhoneConnectivityManager: NSObject, WCSessionDelegate {
static let shared = PhoneConnectivityManager()
private(set) var isWatchReachable = false
private(set) var isWatchAppInstalled = false
var onHeartRateUpdate: ((Double) -> Void)?
var onCaloriesUpdate: ((Double) -> Void)?
var onSessionCompleted: ((WatchSessionResult) -> Void)?
private var session: WCSession?
private override init() {
super.init()
guard WCSession.isSupported() else { return }
session = WCSession.default
session?.delegate = self
session?.activate()
}
// Send workout to Watch
func sendWorkout(_ program: WorkoutProgram) {
guard let session, session.isReachable else { return }
let blocks = program.blocks.map { block in
WatchTabataBlock(
position: block.position,
exercise1Name: block.exercise1.nameEn,
exercise2Name: block.exercise2.nameEn,
rounds: block.rounds,
workTime: block.workTime,
restTime: block.restTime
)
}
let payload = WatchWorkoutPayload(
programId: program.id,
programTitle: program.titleEn,
bodyZone: program.bodyZone,
level: program.level,
totalRounds: program.totalRounds,
blocks: blocks,
warmupDuration: program.warmup.totalDuration,
cooldownDuration: program.cooldown.totalDuration
)
guard let data = try? JSONEncoder().encode(payload) else { return }
session.sendMessage(
[WCMessageKey.type: WCMessageType.startWorkout.rawValue,
WCMessageKey.workoutPayload: data],
replyHandler: nil
)
}
func sendTimerTick(_ tick: TimerTickPayload) {
guard let session, session.isReachable else { return }
guard let data = try? JSONEncoder().encode(tick) else { return }
session.sendMessage(
[WCMessageKey.type: WCMessageType.timerTick.rawValue,
"tick": data],
replyHandler: nil
)
}
func sendPause() {
sendSimple(type: .pauseWorkout)
}
func sendResume() {
sendSimple(type: .resumeWorkout)
}
func sendEndWorkout() {
sendSimple(type: .endWorkout)
}
// WCSessionDelegate
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
let reachable = session.isReachable
let installed = session.isWatchAppInstalled
Task { @MainActor in
self.isWatchReachable = reachable
self.isWatchAppInstalled = installed
}
}
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
let reachable = session.isReachable
Task { @MainActor in
self.isWatchReachable = reachable
}
}
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
guard let typeRaw = message[WCMessageKey.type] as? String,
let type = WCMessageType(rawValue: typeRaw) else { return }
switch type {
case .heartRateUpdate:
let hr = message[WCMessageKey.heartRate] as? Double ?? 0
Task { @MainActor in self.onHeartRateUpdate?(hr) }
case .sessionCompleted:
guard let data = message[WCMessageKey.sessionResult] as? Data,
let result = try? JSONDecoder().decode(WatchSessionResult.self, from: data) else { return }
Task { @MainActor in self.onSessionCompleted?(result) }
default:
break
}
}
// Required for iOS
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
nonisolated func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
// Private helpers
private func sendSimple(type: WCMessageType) {
guard let session, session.isReachable else { return }
session.sendMessage([WCMessageKey.type: type.rawValue], replyHandler: nil)
}
}

View File

@@ -0,0 +1,103 @@
import Foundation
import RevenueCat
/// Wraps RevenueCat manages entitlements and purchase flows.
@MainActor
@Observable
final class PurchaseService {
static let shared = PurchaseService()
private(set) var isInitialized = false
private(set) var currentPlan: SubscriptionPlan = .free
private(set) var offerings: Offerings? = nil
private(set) var isPurchasing = false
private(set) var error: String? = nil
var isPremium: Bool { currentPlan.isPremium }
private static let apiKey: String =
Bundle.main.infoDictionary?["REVENUECAT_API_KEY"] as? String ?? ""
private static let entitlementId = "1000 Corp Pro"
private init() {}
@MainActor
func initialize() async {
guard !isInitialized, !AppEnvironment.isPreview else { return }
let key = Self.apiKey
guard !key.isEmpty else {
#if DEBUG
print("[PurchaseService] No API key configured — skipping RevenueCat init")
#endif
isInitialized = true
return
}
#if DEBUG
Purchases.logLevel = .debug
#endif
Purchases.configure(withAPIKey: key)
isInitialized = true
await refreshEntitlement()
await loadOfferings()
}
/// True when RevenueCat SDK has been configured with a valid-looking key.
private var isSDKConfigured: Bool {
isInitialized && !Self.apiKey.isEmpty
}
@MainActor
func refreshEntitlement() async {
guard isSDKConfigured else { return }
do {
let info = try await Purchases.shared.customerInfo()
updatePlan(from: info)
} catch {
self.error = error.localizedDescription
}
}
@MainActor
func loadOfferings() async {
guard isSDKConfigured else { return }
do {
offerings = try await Purchases.shared.offerings()
} catch {
self.error = error.localizedDescription
}
}
@MainActor
func purchase(package: Package) async throws {
guard isSDKConfigured else { return }
isPurchasing = true
defer { isPurchasing = false }
let result = try await Purchases.shared.purchase(package: package)
updatePlan(from: result.customerInfo)
}
@MainActor
func restorePurchases() async throws {
guard isSDKConfigured else { return }
let info = try await Purchases.shared.restorePurchases()
updatePlan(from: info)
}
private func updatePlan(from info: CustomerInfo) {
let isActive = info.entitlements[Self.entitlementId]?.isActive == true
if isActive {
// Determine monthly vs yearly from active subscriptions
if info.activeSubscriptions.contains(where: { $0.contains("yearly") || $0.contains("annual") }) {
currentPlan = .premiumYearly
} else {
currentPlan = .premiumMonthly
}
} else {
currentPlan = .free
}
}
}

View File

@@ -0,0 +1,247 @@
import Foundation
import Supabase
// Service
/// Fetches workout programs from Supabase, with offline cache fallback.
actor SupabaseService {
static let shared = SupabaseService()
private let client: SupabaseClient
/// True only when both URL and key are real values, and we are not in a preview/test sandbox.
private let isConfigured: Bool
private init() {
// Bail out entirely in Xcode Canvas / unit-test sandboxes no network
// calls, no SupabaseClient init with placeholder credentials.
guard !AppEnvironment.isPreview else {
client = SupabaseClient(
supabaseURL: URL(string: "https://localhost")!,
supabaseKey: ""
)
isConfigured = false
return
}
let urlRaw = Bundle.main.infoDictionary?["SUPABASE_URL"] as? String ?? ""
let key = Bundle.main.infoDictionary?["SUPABASE_ANON_KEY"] as? String ?? ""
let url = URL(string: urlRaw) ?? URL(string: "https://localhost")!
// Consider configured only when both values look non-empty and non-placeholder.
let validURL = !urlRaw.isEmpty && urlRaw != "https://localhost"
let validKey = !key.isEmpty
isConfigured = validURL && validKey
client = SupabaseClient(supabaseURL: url, supabaseKey: key)
#if DEBUG
print("[SupabaseService] URL=\(urlRaw) configured=\(isConfigured)")
#endif
}
// Program Fetching
/// Fetch all workout programs. Returns nil in preview/test environments or when unconfigured.
func fetchAllPrograms() async throws -> [WorkoutProgram]? {
guard isConfigured else { return nil }
let programRows: [WorkoutProgramRow] = try await client
.from("workout_programs")
.select("*")
.order("sort_order")
.execute()
.value
let tabataRows: [ProgramTabataRow] = try await client
.from("program_tabatas")
.select("*")
.order("position")
.execute()
.value
let warmupRows: [TimedExerciseRow] = try await client
.from("workout_warmup_exercises")
.select("*")
.order("position")
.execute()
.value
let stretchRows: [TimedExerciseRow] = try await client
.from("workout_stretch_exercises")
.select("*")
.order("position")
.execute()
.value
return assemble(
programs: programRows,
tabatas: tabataRows,
warmups: warmupRows,
stretches: stretchRows
)
}
/// Sync a completed workout session to Supabase (premium users only).
func syncSession(_ session: WorkoutSession) async throws {
guard isConfigured else { return }
let payload = SessionPayload(
workout_id: session.programId,
completed_at: ISO8601DateFormatter().string(from: session.completedAt),
duration_seconds: session.durationSeconds,
calories_burned: session.caloriesBurned,
average_heart_rate: session.averageHeartRate
)
try await client.from("workout_sessions").insert(payload).execute()
}
// Assembly
private func assemble(
programs: [WorkoutProgramRow],
tabatas: [ProgramTabataRow],
warmups: [TimedExerciseRow],
stretches: [TimedExerciseRow]
) -> [WorkoutProgram] {
programs.map { prog in
let progTabatas = tabatas
.filter { $0.program_id == prog.id }
.sorted { $0.position < $1.position }
let progWarmups = warmups
.filter { $0.program_id == prog.id }
.sorted { $0.position < $1.position }
let progStretches = stretches
.filter { $0.program_id == prog.id }
.sorted { $0.position < $1.position }
let blocks = progTabatas.map { row in
TabataBlock(
id: row.id,
position: row.position,
exercise1: TabataExercise(
name: row.exercise_1_name,
nameEn: row.exercise_1_name_en ?? row.exercise_1_name,
tip: row.exercise_1_tip,
tipEn: row.exercise_1_tip_en,
modification: row.exercise_1_modification,
modificationEn: row.exercise_1_modification_en,
progression: row.exercise_1_progression,
progressionEn: row.exercise_1_progression_en,
videoUrl: row.exercise_1_video_url
),
exercise2: TabataExercise(
name: row.exercise_2_name,
nameEn: row.exercise_2_name_en ?? row.exercise_2_name,
tip: row.exercise_2_tip,
tipEn: row.exercise_2_tip_en,
modification: row.exercise_2_modification,
modificationEn: row.exercise_2_modification_en,
progression: row.exercise_2_progression,
progressionEn: row.exercise_2_progression_en,
videoUrl: row.exercise_2_video_url
),
rounds: row.rounds,
workTime: row.work_time,
restTime: row.rest_time
)
}
let warmupMovements = progWarmups.map {
TimedMovement(name: $0.name, nameEn: $0.name_en ?? $0.name, duration: $0.duration, videoUrl: $0.video_url)
}
let stretchMovements = progStretches.map {
TimedMovement(name: $0.name, nameEn: $0.name_en ?? $0.name, duration: $0.duration, videoUrl: $0.video_url)
}
let totalRounds = blocks.reduce(0) { $0 + $1.rounds }
let workSeconds = blocks.reduce(0) { $0 + $1.rounds * ($1.workTime + $1.restTime) }
let warmupTotal = warmupMovements.reduce(0) { $0 + $1.duration }
let stretchTotal = stretchMovements.reduce(0) { $0 + $1.duration }
return WorkoutProgram(
id: prog.id,
title: prog.title,
titleEn: prog.title_en ?? prog.title,
description: prog.description ?? "",
descriptionEn: prog.description_en ?? prog.description ?? "",
bodyZone: prog.body_zone,
level: prog.level,
musicVibe: prog.music_vibe ?? "electronic",
accentColor: prog.accent_color ?? "#FF6B35",
isFree: prog.is_free,
estimatedCalories: prog.estimated_calories ?? 0,
estimatedDuration: (warmupTotal + workSeconds + stretchTotal) / 60,
totalRounds: totalRounds,
warmup: WarmupSection(movements: warmupMovements, totalDuration: warmupTotal),
cooldown: CooldownSection(movements: stretchMovements, totalDuration: stretchTotal),
blocks: blocks,
thumbnailUrl: nil
)
}
}
}
// Row types (Supabase decode)
struct WorkoutProgramRow: Decodable {
let id: String
let title: String
let title_en: String?
let description: String?
let description_en: String?
let body_zone: String
let level: String
let music_vibe: String?
let accent_color: String?
let is_free: Bool
let estimated_calories: Int?
let sort_order: Int?
}
struct ProgramTabataRow: Decodable {
let id: String
let program_id: String
let position: Int
let exercise_1_name: String
let exercise_1_name_en: String?
let exercise_1_tip: String?
let exercise_1_tip_en: String?
let exercise_1_modification: String?
let exercise_1_modification_en: String?
let exercise_1_progression: String?
let exercise_1_progression_en: String?
let exercise_1_video_url: String?
let exercise_2_name: String
let exercise_2_name_en: String?
let exercise_2_tip: String?
let exercise_2_tip_en: String?
let exercise_2_modification: String?
let exercise_2_modification_en: String?
let exercise_2_progression: String?
let exercise_2_progression_en: String?
let exercise_2_video_url: String?
let rounds: Int
let work_time: Int
let rest_time: Int
}
struct TimedExerciseRow: Decodable {
let id: String
let program_id: String
let name: String
let name_en: String?
let duration: Int
let position: Int
let video_url: String?
}
struct SessionPayload: Encodable {
let workout_id: String
let completed_at: String
let duration_seconds: Int
let calories_burned: Double
let average_heart_rate: Double?
}

View File

@@ -0,0 +1,72 @@
import Foundation
// Shared message keys (used by both iOS and watchOS targets)
enum WCMessageKey {
static let type = "type"
static let workoutPayload = "workout"
static let sessionResult = "sessionResult"
static let heartRate = "heartRate"
static let calories = "calories"
static let phase = "phase"
static let timeRemaining = "timeRemaining"
static let round = "round"
static let totalRounds = "totalRounds"
static let exerciseName = "exerciseName"
static let programId = "programId"
static let programTitle = "programTitle"
}
enum WCMessageType: String {
case startWorkout = "startWorkout"
case pauseWorkout = "pauseWorkout"
case resumeWorkout = "resumeWorkout"
case endWorkout = "endWorkout"
case timerTick = "timerTick" // phone watch: sync state
case sessionCompleted = "sessionCompleted" // watch phone: HR/cal data
case heartRateUpdate = "heartRateUpdate" // watch phone: live HR
}
// Codable payload for starting a workout on Watch
struct WatchWorkoutPayload: Codable {
var programId: String
var programTitle: String
var bodyZone: String
var level: String
var totalRounds: Int
var blocks: [WatchTabataBlock]
var warmupDuration: Int // seconds
var cooldownDuration: Int // seconds
}
struct WatchTabataBlock: Codable {
var position: Int
var exercise1Name: String
var exercise2Name: String
var rounds: Int
var workTime: Int
var restTime: Int
}
// Timer tick payload (phone keeps Watch in sync)
struct TimerTickPayload: Codable {
var phase: String
var timeRemaining: Int
var currentRound: Int
var totalRoundsInBlock: Int
var exerciseName: String?
}
// Session result (Watch phone after workout ends)
struct WatchSessionResult: Codable {
var programId: String
var startedAt: Date
var completedAt: Date
var durationSeconds: Int
var activeCalories: Double
var averageHeartRate: Double?
var peakHeartRate: Double?
}