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:
105
tabatago-swift/TabataGo/Services/AnalyticsService.swift
Normal file
105
tabatago-swift/TabataGo/Services/AnalyticsService.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
147
tabatago-swift/TabataGo/Services/AudioService.swift
Normal file
147
tabatago-swift/TabataGo/Services/AudioService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
380
tabatago-swift/TabataGo/Services/HealthKitService.swift
Normal file
380
tabatago-swift/TabataGo/Services/HealthKitService.swift
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
165
tabatago-swift/TabataGo/Services/MusicService.swift
Normal file
165
tabatago-swift/TabataGo/Services/MusicService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
136
tabatago-swift/TabataGo/Services/PhoneConnectivityManager.swift
Normal file
136
tabatago-swift/TabataGo/Services/PhoneConnectivityManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
103
tabatago-swift/TabataGo/Services/PurchaseService.swift
Normal file
103
tabatago-swift/TabataGo/Services/PurchaseService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
247
tabatago-swift/TabataGo/Services/SupabaseService.swift
Normal file
247
tabatago-swift/TabataGo/Services/SupabaseService.swift
Normal 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?
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
Reference in New Issue
Block a user