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,18 @@
import Foundation
import Observation
/// Global app bootstrap state initialises all services once at launch.
@Observable
final class AppState {
var isBootstrapped = false
@MainActor
func bootstrap() async {
guard !isBootstrapped else { return }
guard !AppEnvironment.isPreview else { isBootstrapped = true; return }
await PurchaseService.shared.initialize()
AnalyticsService.shared.initialize()
isBootstrapped = true
}
}

View File

@@ -0,0 +1,21 @@
import SwiftUI
import SwiftData
/// Root entry point: decides between onboarding and main tab view.
struct RootView: View {
@Environment(AppState.self) private var appState
@Query private var profiles: [UserProfile]
private var profile: UserProfile? { profiles.first }
var body: some View {
Group {
if let profile, profile.onboardingCompleted {
MainTabView()
} else {
OnboardingView()
}
}
.animation(.easeInOut(duration: 0.3), value: profile?.onboardingCompleted)
}
}

View File

@@ -0,0 +1,19 @@
import SwiftUI
import SwiftData
@main
struct TabataGoApp: App {
@State private var appState = AppState()
var body: some Scene {
WindowGroup {
RootView()
.environment(appState)
.modelContainer(TabataGoSchema.container)
.task {
await appState.bootstrap()
}
}
}
}

View File

@@ -0,0 +1,32 @@
import SwiftData
import Foundation
/// Snapshot of HealthKit data refreshed when user opens Activity tab.
@Model
final class HealthSnapshot: @unchecked Sendable {
var fetchedAt: Date = Date()
// Activity rings
var activeCaloricBurn: Double = 0 // kcal today
var exerciseMinutes: Double = 0 // minutes today
var standHours: Int = 0 // hours today
// Resting metrics
var restingHeartRate: Double? = nil // bpm
var bodyMassKg: Double? = nil // kg
// Weekly summary
var weeklyActiveCalories: Double = 0
var weeklyExerciseMinutes: Double = 0
var weeklyWorkoutCount: Int = 0
// VO2 Max (if available)
var vo2Max: Double? = nil
var isStale: Bool {
Date().timeIntervalSince(fetchedAt) > 900 // 15 min TTL
}
init() {}
}

View File

@@ -0,0 +1,31 @@
import Foundation
/// A single music track for workout playback.
struct MusicTrack: Identifiable, Equatable, Sendable {
let id: String
let title: String
let artist: String
let duration: Int // seconds
let url: URL
let vibe: MusicVibe
}
/// Workout music mood maps to genre buckets in the download_items table.
enum MusicVibe: String, CaseIterable, Sendable {
case electronic
case hipHop = "hip-hop"
case pop
case rock
case chill
/// Database genres that map to this vibe (matches Expo VIBE_TO_GENRES).
var genres: [String] {
switch self {
case .electronic: return ["edm", "house", "drum-and-bass", "dubstep"]
case .hipHop: return ["hip-hop", "r-and-b"]
case .pop: return ["pop", "latin"]
case .rock: return ["rock", "metal", "country"]
case .chill: return ["ambient"]
}
}
}

View File

@@ -0,0 +1,111 @@
import SwiftData
import Foundation
/// Seed data for SwiftUI previews and unit tests.
enum PreviewData {
static func seed(into context: ModelContext) {
// User profile
let profile = UserProfile()
profile.name = "Alex"
profile.fitnessLevel = .intermediate
profile.goal = .cardio
profile.weeklyFrequency = 4
profile.onboardingCompleted = true
profile.subscription = .premiumMonthly
context.insert(profile)
// Workout history
let sessions: [(String, String, String, Int)] = [
("upper-body-beginner", "Upper Body Blast", "upper", 1440),
("full-body-intermediate", "Full Body HIIT", "full", 2040),
("lower-body-beginner", "Lower Body Burn", "lower", 1200),
("upper-body-intermediate", "Arms & Core", "upper", 1680),
("full-body-beginner", "Total Body", "full", 960),
]
for (i, (id, title, zone, duration)) in sessions.enumerated() {
let daysAgo = Double(i * 2)
let session = WorkoutSession(
programId: id,
programTitle: title,
bodyZone: zone,
level: "Intermediate",
startedAt: Date().addingTimeInterval(-(daysAgo * 86400 + Double(duration))),
completedAt: Date().addingTimeInterval(-daysAgo * 86400),
durationSeconds: duration,
caloriesBurned: Double(duration / 10),
roundsCompleted: 8,
totalRounds: 8
)
context.insert(session)
}
// Health snapshot
let snapshot = HealthSnapshot()
snapshot.activeCaloricBurn = 320
snapshot.exerciseMinutes = 28
snapshot.standHours = 9
snapshot.restingHeartRate = 58
snapshot.bodyMassKg = 72
snapshot.weeklyActiveCalories = 1850
snapshot.weeklyExerciseMinutes = 148
snapshot.weeklyWorkoutCount = 4
context.insert(snapshot)
try? context.save()
}
/// A single workout program for player previews.
static var sampleProgram: WorkoutProgram {
WorkoutProgram(
id: "upper-body-beginner",
title: "Upper Body Blast",
titleEn: "Upper Body Blast",
description: "Sculpt your upper body with this intense Tabata workout.",
descriptionEn: "Sculpt your upper body with this intense Tabata workout.",
bodyZone: "upper",
level: "Beginner",
musicVibe: "electronic",
accentColor: "#FF6B35",
isFree: true,
estimatedCalories: 180,
estimatedDuration: 24,
totalRounds: 32,
warmup: WarmupSection(
movements: [
TimedMovement(name: "Arm Circles", nameEn: "Arm Circles", duration: 30, videoUrl: nil),
TimedMovement(name: "High Knees", nameEn: "High Knees", duration: 30, videoUrl: nil),
],
totalDuration: 150
),
cooldown: CooldownSection(
movements: [
TimedMovement(name: "Shoulder Stretch", nameEn: "Shoulder Stretch", duration: 30, videoUrl: nil),
],
totalDuration: 90
),
blocks: [
TabataBlock(
id: "block-1",
position: 1,
exercise1: TabataExercise(name: "Push-ups", nameEn: "Push-ups", tip: "Keep core tight"),
exercise2: TabataExercise(name: "Tricep Dips", nameEn: "Tricep Dips"),
rounds: 8,
workTime: 20,
restTime: 10
),
TabataBlock(
id: "block-2",
position: 2,
exercise1: TabataExercise(name: "Pike Push-ups", nameEn: "Pike Push-ups"),
exercise2: TabataExercise(name: "Plank Shoulder Taps", nameEn: "Plank Shoulder Taps"),
rounds: 8,
workTime: 20,
restTime: 10
),
],
thumbnailUrl: nil
)
}
}

View File

@@ -0,0 +1,41 @@
import SwiftData
import Foundation
/// Central schema definition + ModelContainer factory.
enum TabataGoSchema {
static var schema: Schema {
Schema([
UserProfile.self,
WorkoutSession.self,
CachedProgramData.self,
HealthSnapshot.self,
])
}
static var container: ModelContainer {
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
allowsSave: true
)
do {
return try ModelContainer(for: schema, configurations: config)
} catch {
fatalError("Failed to create SwiftData container: \(error)")
}
}
/// In-memory container for SwiftUI Previews and unit tests.
@MainActor
static var previewContainer: ModelContainer {
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
guard let container = try? ModelContainer(for: schema, configurations: config) else {
// Last-resort: if even a bare in-memory container fails, the schema itself is
// broken and we want a clear crash with a useful message rather than a silent hang.
fatalError("[TabataGoSchema] Cannot create even a bare in-memory ModelContainer — check your @Model definitions for conflicts.")
}
PreviewData.seed(into: container.mainContext)
return container
}
}

View File

@@ -0,0 +1,123 @@
import SwiftData
import Foundation
// Enums
enum FitnessLevel: String, Codable, CaseIterable {
case beginner, intermediate, advanced
var label: String {
switch self {
case .beginner: "Beginner"
case .intermediate: "Intermediate"
case .advanced: "Advanced"
}
}
}
enum FitnessGoal: String, Codable, CaseIterable {
case weightLoss = "weight-loss"
case cardio, strength, wellness
var label: String {
switch self {
case .weightLoss: "Weight Loss"
case .cardio: "Cardio"
case .strength: "Strength"
case .wellness: "Wellness"
}
}
}
enum SubscriptionPlan: String, Codable {
case free
case premiumMonthly = "premium-monthly"
case premiumYearly = "premium-yearly"
var isPremium: Bool { self != .free }
}
enum SyncStatus: String, Codable {
case neverSynced = "never-synced"
case promptPending = "prompt-pending"
case synced
case failed
}
// Model
/// Singleton user profile created once at onboarding, updated in place.
@Model
final class UserProfile {
// Identity
var name: String = ""
var email: String = ""
var joinDate: Date = Date()
// Fitness preferences
var fitnessLevelRaw: String = FitnessLevel.beginner.rawValue
var goalRaw: String = FitnessGoal.cardio.rawValue
var weeklyFrequency: Int = 3
var barriers: [String] = []
// Subscription
var subscriptionRaw: String = SubscriptionPlan.free.rawValue
// Onboarding
var onboardingCompleted: Bool = false
// Cloud sync
var syncStatusRaw: String = SyncStatus.neverSynced.rawValue
var supabaseUserId: String? = nil
// App settings
var hapticsEnabled: Bool = true
var soundEffectsEnabled: Bool = true
var voiceCoachingEnabled: Bool = true
var musicEnabled: Bool = true
var musicVolume: Double = 0.5
var remindersEnabled: Bool = false
var reminderTimeHour: Int = 9
var reminderTimeMinute: Int = 0
var hasPromptedReview: Bool = false
// Saved workouts
var savedWorkoutIds: [String] = []
// Computed accessors
var fitnessLevel: FitnessLevel {
get { FitnessLevel(rawValue: fitnessLevelRaw) ?? .beginner }
set { fitnessLevelRaw = newValue.rawValue }
}
var goal: FitnessGoal {
get { FitnessGoal(rawValue: goalRaw) ?? .cardio }
set { goalRaw = newValue.rawValue }
}
var subscription: SubscriptionPlan {
get { SubscriptionPlan(rawValue: subscriptionRaw) ?? .free }
set { subscriptionRaw = newValue.rawValue }
}
var syncStatus: SyncStatus {
get { SyncStatus(rawValue: syncStatusRaw) ?? .neverSynced }
set { syncStatusRaw = newValue.rawValue }
}
var reminderTime: DateComponents {
get {
var c = DateComponents()
c.hour = reminderTimeHour
c.minute = reminderTimeMinute
return c
}
set {
reminderTimeHour = newValue.hour ?? 9
reminderTimeMinute = newValue.minute ?? 0
}
}
init() {}
}

View File

@@ -0,0 +1,89 @@
import SwiftData
import Foundation
// Value types (not persisted, used in memory)
struct TabataExercise: Codable, Hashable, Sendable {
var name: String
var nameEn: String
var tip: String?
var tipEn: String?
var modification: String?
var modificationEn: String?
var progression: String?
var progressionEn: String?
var videoUrl: String?
}
struct TabataBlock: Codable, Hashable, Sendable {
var id: String
var position: Int
var exercise1: TabataExercise
var exercise2: TabataExercise
var rounds: Int
var workTime: Int // seconds
var restTime: Int // seconds
}
struct TimedMovement: Codable, Hashable, Sendable {
var name: String
var nameEn: String
var duration: Int // seconds
var videoUrl: String?
}
struct WarmupSection: Codable, Hashable, Sendable {
var movements: [TimedMovement]
var totalDuration: Int
}
struct CooldownSection: Codable, Hashable, Sendable {
var movements: [TimedMovement]
var totalDuration: Int
}
/// In-memory representation of a full workout program (decoded from Supabase cache).
struct WorkoutProgram: Codable, Hashable, Identifiable, Sendable {
var id: String
var title: String
var titleEn: String
var description: String
var descriptionEn: String
var bodyZone: String
var level: String
var musicVibe: String
var accentColor: String
var isFree: Bool
var estimatedCalories: Int
var estimatedDuration: Int // minutes
var totalRounds: Int
var warmup: WarmupSection
var cooldown: CooldownSection
var blocks: [TabataBlock]
var thumbnailUrl: String?
}
// SwiftData cache model
/// Supabase workout programs cached locally. TTL = 1 hour.
@Model
final class CachedProgramData {
var cacheKey: String = ""
var cachedAt: Date = Date()
var jsonData: Data = Data()
var isExpired: Bool {
Date().timeIntervalSince(cachedAt) > 3600 // 1 hour TTL
}
init(cacheKey: String, programs: [WorkoutProgram]) throws {
self.cacheKey = cacheKey
self.cachedAt = Date()
self.jsonData = try JSONEncoder().encode(programs)
}
func decode() throws -> [WorkoutProgram] {
try JSONDecoder().decode([WorkoutProgram].self, from: jsonData)
}
}

View File

@@ -0,0 +1,60 @@
import SwiftData
import Foundation
/// One completed Tabata workout session written to SwiftData + HealthKit.
@Model
final class WorkoutSession: @unchecked Sendable {
var id: UUID = UUID()
var programId: String = ""
var programTitle: String = ""
var bodyZone: String = ""
var level: String = ""
// Timing
var startedAt: Date = Date()
var completedAt: Date = Date()
var durationSeconds: Int = 0
// Effort
var caloriesBurned: Double = 0
var roundsCompleted: Int = 0
var totalRounds: Int = 0
/// Completion rate 0..1
var completionRate: Double {
guard totalRounds > 0 else { return 0 }
return Double(roundsCompleted) / Double(totalRounds)
}
// HealthKit link
var healthKitWorkoutId: UUID? = nil
// Heart rate (populated from HKWorkoutSession after completion)
var averageHeartRate: Double? = nil
var peakHeartRate: Double? = nil
init(
programId: String,
programTitle: String,
bodyZone: String,
level: String,
startedAt: Date,
completedAt: Date,
durationSeconds: Int,
caloriesBurned: Double,
roundsCompleted: Int,
totalRounds: Int
) {
self.programId = programId
self.programTitle = programTitle
self.bodyZone = bodyZone
self.level = level
self.startedAt = startedAt
self.completedAt = completedAt
self.durationSeconds = durationSeconds
self.caloriesBurned = caloriesBurned
self.roundsCompleted = roundsCompleted
self.totalRounds = totalRounds
}
}

View File

@@ -0,0 +1,38 @@
{
"colors": [
{
"color": {
"color-space": "srgb",
"components": {
"alpha": "1.000",
"blue": "0.208",
"green": "0.420",
"red": "1.000"
}
},
"idiom": "universal"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"color": {
"color-space": "srgb",
"components": {
"alpha": "1.000",
"blue": "0.208",
"green": "0.420",
"red": "1.000"
}
},
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -0,0 +1,13 @@
{
"images": [
{
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>TabataGo</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSHealthShareUsageDescription</key>
<string>TabataGo reads your health data to show fitness stats and personalize your workouts.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>TabataGo saves your Tabata workouts to Apple Health to track calories, heart rate, and contribute to your Activity Rings.</string>
<key>NSMotionUsageDescription</key>
<string>TabataGo uses motion data to improve calorie estimates during workouts.</string>
<key>POSTHOG_API_KEY</key>
<string>$(POSTHOG_API_KEY)</string>
<key>REVENUECAT_API_KEY</key>
<string>$(REVENUECAT_API_KEY)</string>
<key>SUPABASE_ANON_KEY</key>
<string>$(SUPABASE_ANON_KEY)</string>
<key>SUPABASE_URL</key>
<string>$(SUPABASE_URL)</string>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string></string>
<key>UIImageName</key>
<string></string>
</dict>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
</dict>
</plist>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array>
<string>health-records</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.tabatago.app</string>
</array>
</dict>
</plist>

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

View File

@@ -0,0 +1,163 @@
import SwiftUI
/// TabataGo design tokens adaptive light/dark theme.
enum Theme {
// Brand Colors (invariant work on both light & dark)
static let brand = Color(red: 1.0, green: 0.42, blue: 0.21) // #FF6B35 Flame orange
static let brandLight = Color(red: 1.0, green: 0.73, blue: 0.58)
static let rest = Color(red: 0.35, green: 0.78, blue: 0.98) // #5AC8FA Ice blue
static let success = Color(red: 0.19, green: 0.82, blue: 0.35) // #30D158 Energy green
static let prep = Color(red: 1.0, green: 0.58, blue: 0.0) // #FF9500 Orange-yellow
// Adaptive Surface Colors
// Dark: custom navy surfaces. Light: system defaults.
/// Main screen background
static let surfaceBackground = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0.04, green: 0.09, blue: 0.16, alpha: 1) // #0A1628
: .systemBackground
})
/// Card / grouped inset background
static let surfaceCard = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0.07, green: 0.11, blue: 0.18, alpha: 1) // #111D2E
: .secondarySystemBackground
})
/// Elevated card / hover state
static let surfaceElevated = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 0.10, green: 0.16, blue: 0.24, alpha: 1) // #192A3E
: .tertiarySystemBackground
})
/// Subtle overlay / separator tint
static let surfaceOverlay = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(white: 1.0, alpha: 0.05)
: UIColor(white: 0.0, alpha: 0.03)
})
/// Adaptive border color
static let border = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(white: 1.0, alpha: 0.10)
: UIColor(white: 0.0, alpha: 0.08)
})
// Phase Colors
static func phaseColor(_ phase: TimerPhase) -> Color {
switch phase {
case .prep: return prep
case .warmup: return prep
case .work: return brand
case .rest: return rest
case .interBlockRest: return rest.opacity(0.7)
case .cooldown: return Color(red: 0.40, green: 0.75, blue: 0.90)
case .complete: return success
}
}
static func phaseLabel(_ phase: TimerPhase) -> String {
switch phase {
case .prep: return "GET READY"
case .warmup: return "WARM UP"
case .work: return "WORK"
case .rest: return "REST"
case .interBlockRest: return "BREAK"
case .cooldown: return "COOL DOWN"
case .complete: return "DONE"
}
}
// Body Zone Gradients
static func zoneGradient(_ zone: String) -> LinearGradient {
switch zone.lowercased() {
case "upper-body", "upper":
return LinearGradient(colors: [.orange, .red.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing)
case "lower-body", "lower":
return LinearGradient(colors: [.blue, .purple.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing)
case "full-body", "full":
return LinearGradient(colors: [brand, .purple], startPoint: .topLeading, endPoint: .bottomTrailing)
default:
return LinearGradient(colors: [.gray, .secondary], startPoint: .topLeading, endPoint: .bottomTrailing)
}
}
static func zoneColor(_ zone: String) -> Color {
switch zone.lowercased() {
case "upper-body", "upper": return .orange
case "lower-body", "lower": return .blue
case "full-body", "full": return brand
default: return .gray
}
}
// Level Colors
static func levelColor(_ level: String) -> Color {
switch level.lowercased() {
case "beginner": return success
case "intermediate": return prep
case "advanced": return brand
default: return .gray
}
}
// Typography
static let timerFont = Font.system(size: 96, weight: .black, design: .rounded)
.monospacedDigit()
static let timerSmallFont = Font.system(size: 60, weight: .bold, design: .rounded)
.monospacedDigit()
static let roundFont = Font.system(size: 22, weight: .semibold, design: .rounded)
static let phaseFont = Font.system(size: 18, weight: .bold, design: .rounded)
}
// Glass Effect Modifier
struct GlassCard: ViewModifier {
var cornerRadius: CGFloat = 20
func body(content: Content) -> some View {
content
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
}
}
extension View {
func glassCard(cornerRadius: CGFloat = 20) -> some View {
modifier(GlassCard(cornerRadius: cornerRadius))
}
}
// Stat Badge
struct StatBadge: View {
let label: String
let value: String
var color: Color = .primary
var icon: String? = nil
var body: some View {
VStack(spacing: 4) {
if let icon {
Image(systemName: icon)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(color)
}
Text(value)
.font(.system(size: 22, weight: .bold, design: .rounded))
.foregroundStyle(color)
.monospacedDigit()
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.glassCard()
}
}

View File

@@ -0,0 +1,7 @@
import Foundation
/// Helpers for detecting the current runtime environment.
enum AppEnvironment {
/// True when running inside the Xcode preview sandbox.
static let isPreview = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
}

View File

@@ -0,0 +1,138 @@
import Foundation
/// Type-safe string keys for `Localizable.xcstrings`.
/// Usage: Text(L10n.action.start) or String(localized: L10n.player.phase.work)
enum L10n {
enum action {
static let back = LocalizedStringResource("action.back")
static let cancel = LocalizedStringResource("action.cancel")
static let `continue` = LocalizedStringResource("action.continue")
static let done = LocalizedStringResource("action.done")
static let save = LocalizedStringResource("action.save")
static let share = LocalizedStringResource("action.share")
static let start = LocalizedStringResource("action.start")
static let startWorkout = LocalizedStringResource("action.startWorkout")
static let startTraining = LocalizedStringResource("action.startTraining")
static let unlockPremium = LocalizedStringResource("action.unlockPremium")
static let restorePurchases = LocalizedStringResource("action.restorePurchases")
}
enum tab {
static let home = LocalizedStringResource("tab.home")
static let programs = LocalizedStringResource("tab.programs")
static let activity = LocalizedStringResource("tab.activity")
static let profile = LocalizedStringResource("tab.profile")
}
enum home {
static let featuredTitle = LocalizedStringResource("home.featuredTitle")
static let featuredSubtitle = LocalizedStringResource("home.featuredSubtitle")
static let browseTitle = LocalizedStringResource("home.browseTitle")
static let streak = LocalizedStringResource("home.streak")
static let thisWeek = LocalizedStringResource("home.thisWeek")
static let allTime = LocalizedStringResource("home.allTime")
}
enum zone {
static let upper = LocalizedStringResource("zone.upper")
static let lower = LocalizedStringResource("zone.lower")
static let full = LocalizedStringResource("zone.full")
static func label(for zone: String) -> LocalizedStringResource {
switch zone.lowercased() {
case "upper": return upper
case "lower": return lower
case "full": return full
default: return LocalizedStringResource(stringLiteral: zone.capitalized)
}
}
}
enum level {
static let beginner = LocalizedStringResource("level.beginner")
static let intermediate = LocalizedStringResource("level.intermediate")
static let advanced = LocalizedStringResource("level.advanced")
}
enum player {
enum phase {
static let getReady = LocalizedStringResource("player.phase.getReady")
static let warmUp = LocalizedStringResource("player.phase.warmUp")
static let work = LocalizedStringResource("player.phase.work")
static let rest = LocalizedStringResource("player.phase.rest")
static let `break` = LocalizedStringResource("player.phase.break")
static let coolDown = LocalizedStringResource("player.phase.coolDown")
static let done = LocalizedStringResource("player.phase.done")
static func label(for phase: TimerPhase) -> LocalizedStringResource {
switch phase {
case .prep: return getReady
case .warmup: return warmUp
case .work: return work
case .rest: return rest
case .interBlockRest: return `break`
case .cooldown: return coolDown
case .complete: return done
}
}
}
static let endWorkout = LocalizedStringResource("player.endWorkout")
static let endWorkoutMessage = LocalizedStringResource("player.endWorkoutMessage")
static let keepGoing = LocalizedStringResource("player.keepGoing")
}
enum complete {
static let title = LocalizedStringResource("complete.title")
static let saveToHealth = LocalizedStringResource("complete.saveToHealth")
static let savedToHealth = LocalizedStringResource("complete.savedToHealth")
static let backToHome = LocalizedStringResource("complete.backToHome")
static let duration = LocalizedStringResource("complete.duration")
static let calories = LocalizedStringResource("complete.calories")
static let rounds = LocalizedStringResource("complete.rounds")
static let avgHeartRate = LocalizedStringResource("complete.avgHeartRate")
static let shareWorkout = LocalizedStringResource("complete.shareWorkout")
}
enum activity {
static let currentStreak = LocalizedStringResource("activity.currentStreak")
static let bestStreak = LocalizedStringResource("activity.bestStreak")
static let history = LocalizedStringResource("activity.history")
static let workouts = LocalizedStringResource("activity.workouts")
static let minutes = LocalizedStringResource("activity.minutes")
static let noWorkouts = LocalizedStringResource("activity.noWorkouts")
static let noWorkoutsMessage = LocalizedStringResource("activity.noWorkoutsMessage")
}
enum onboarding {
static let whatIsYourName = LocalizedStringResource("onboarding.whatIsYourName")
static let fitnessLevel = LocalizedStringResource("onboarding.fitnessLevel")
static let mainGoal = LocalizedStringResource("onboarding.mainGoal")
static let howOften = LocalizedStringResource("onboarding.howOften")
static let allSet = LocalizedStringResource("onboarding.allSet")
}
enum goal {
static let weightLoss = LocalizedStringResource("goal.weightLoss")
static let cardio = LocalizedStringResource("goal.cardio")
static let strength = LocalizedStringResource("goal.strength")
static let wellness = LocalizedStringResource("goal.wellness")
}
enum settings {
static let title = LocalizedStringResource("settings.title")
static let audio = LocalizedStringResource("settings.audio")
static let soundEffects = LocalizedStringResource("settings.soundEffects")
static let voiceCoaching = LocalizedStringResource("settings.voiceCoaching")
static let music = LocalizedStringResource("settings.music")
static let haptics = LocalizedStringResource("settings.haptics")
static let hapticFeedback = LocalizedStringResource("settings.hapticFeedback")
static let reminders = LocalizedStringResource("settings.reminders")
static let dailyReminder = LocalizedStringResource("settings.dailyReminder")
static let resetProgress = LocalizedStringResource("settings.resetProgress")
}
enum paywall {
static let title = LocalizedStringResource("paywall.title")
static let subtitle = LocalizedStringResource("paywall.subtitle")
static let startPremium = LocalizedStringResource("paywall.startPremium")
static let premiumActive = LocalizedStringResource("paywall.premiumActive")
static let upgradePrompt = LocalizedStringResource("paywall.upgradePrompt")
static let cancelAnytime = LocalizedStringResource("paywall.cancelAnytime")
}
enum health {
static let appleHealth = LocalizedStringResource("health.appleHealth")
static let move = LocalizedStringResource("health.move")
static let exercise = LocalizedStringResource("health.exercise")
static let stand = LocalizedStringResource("health.stand")
static let restingHR = LocalizedStringResource("health.restingHR")
}
}

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

View File

@@ -0,0 +1,241 @@
import SwiftUI
import SwiftData
/// Workout completion screen summary, HealthKit save, share.
struct CompletionView: View {
let session: WorkoutSession?
let program: WorkoutProgram
var onDone: () -> Void = {}
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@State private var healthKitSaved = false
@State private var isSavingToHealth = false
@State private var showShareSheet = false
@State private var confettiTrigger = 0
var body: some View {
ZStack {
// Background
Theme.surfaceBackground
.ignoresSafeArea()
ScrollView {
VStack(spacing: 28) {
// Trophy Header
VStack(spacing: 12) {
Image(systemName: "trophy.fill")
.font(.system(size: 72))
.foregroundStyle(
LinearGradient(colors: [.yellow, .orange], startPoint: .top, endPoint: .bottom)
)
.symbolEffect(.bounce, value: confettiTrigger)
.padding(.top, 32)
Text("Workout Complete!")
.font(.system(size: 32, weight: .black, design: .rounded))
.foregroundStyle(.primary)
Text(program.titleEn)
.font(.subheadline)
.foregroundStyle(.secondary)
}
// Stats Grid
if let session {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
CompletionStat(
label: "Duration",
value: formatDuration(session.durationSeconds),
icon: "clock.fill",
color: Theme.rest
)
CompletionStat(
label: "Calories",
value: "\(Int(session.caloriesBurned)) kcal",
icon: "flame.fill",
color: Theme.brand
)
CompletionStat(
label: "Rounds",
value: "\(session.roundsCompleted) / \(session.totalRounds)",
icon: "repeat",
color: Theme.success
)
if let hr = session.averageHeartRate {
CompletionStat(
label: "Avg Heart Rate",
value: "\(Int(hr)) bpm",
icon: "heart.fill",
color: .red
)
} else {
CompletionStat(
label: "Completion",
value: "\(Int(session.completionRate * 100))%",
icon: "checkmark.circle.fill",
color: Theme.success
)
}
}
.padding(.horizontal)
}
// Apple Health Save
if !healthKitSaved, HealthKitService.shared.isAvailable {
Button {
Task { await saveToHealth() }
} label: {
HStack {
Image(systemName: "heart.text.square.fill")
.foregroundStyle(.red)
Text(isSavingToHealth ? "Saving..." : "Save to Apple Health")
.fontWeight(.semibold)
Spacer()
if isSavingToHealth {
ProgressView()
} else {
Image(systemName: "chevron.right")
.foregroundStyle(.secondary)
}
}
.padding()
.glassCard()
}
.buttonStyle(.plain)
.disabled(isSavingToHealth)
.padding(.horizontal)
} else if healthKitSaved {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.success)
Text("Saved to Apple Health")
.fontWeight(.semibold)
.foregroundStyle(.primary)
}
.padding()
.glassCard()
.padding(.horizontal)
}
// Actions
VStack(spacing: 12) {
Button {
showShareSheet = true
} label: {
Label("Share Workout", systemImage: "square.and.arrow.up")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
.buttonStyle(.plain)
Button {
onDone()
} label: {
Text("Back to Home")
.font(.headline.weight(.bold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding()
.background(Theme.brand)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}
.padding(.horizontal)
.padding(.bottom, 40)
}
}
}
.navigationBarHidden(true)
.onAppear {
confettiTrigger += 1
}
.sheet(isPresented: $showShareSheet) {
if let session {
ShareSheet(text: generateShareText(session: session, program: program))
}
}
}
private func saveToHealth() async {
guard let session else { return }
isSavingToHealth = true
// Extract Sendable values on @MainActor before crossing into HealthKitService actor.
let saveData = HealthKitService.WorkoutSaveData(
startedAt: session.startedAt,
completedAt: session.completedAt,
caloriesBurned: session.caloriesBurned,
averageHeartRate: session.averageHeartRate
)
do {
try await HealthKitService.shared.requestAuthorization()
let workout = try await HealthKitService.shared.saveWorkout(saveData)
session.healthKitWorkoutId = workout.uuid
try? context.save()
healthKitSaved = true
AnalyticsService.shared.workoutCompleted(
programId: program.id,
durationSeconds: session.durationSeconds,
calories: session.caloriesBurned,
completionRate: session.completionRate,
healthKitSaved: true
)
} catch {
print("[Completion] HealthKit save failed: \(error)")
}
isSavingToHealth = false
}
private func generateShareText(session: WorkoutSession, program: WorkoutProgram) -> String {
"Just crushed a \(session.durationSeconds / 60)-minute \(program.titleEn) Tabata workout with TabataGo! 🔥 \(Int(session.caloriesBurned)) kcal burned."
}
private func formatDuration(_ seconds: Int) -> String {
let m = seconds / 60
let s = seconds % 60
return s > 0 ? "\(m)m \(s)s" : "\(m)m"
}
}
struct CompletionStat: View {
let label: String
let value: String
let icon: String
let color: Color
var body: some View {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(color)
Text(value)
.font(.system(size: 22, weight: .bold, design: .rounded))
.monospacedDigit()
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.glassCard()
}
}
struct ShareSheet: UIViewControllerRepresentable {
let text: String
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: [text], applicationActivities: nil)
}
func updateUIViewController(_ vc: UIActivityViewController, context: Context) {}
}
#Preview {
CompletionView(session: nil, program: PreviewData.sampleProgram)
.modelContainer(TabataGoSchema.previewContainer)
}

View File

@@ -0,0 +1,641 @@
import SwiftUI
import SwiftData
/// Multi-step onboarding 6-screen conversion funnel with polished animations.
struct OnboardingView: View {
@State private var step: Step = .welcome
@State private var name = ""
@State private var fitnessLevel: FitnessLevel = .beginner
@State private var goal: FitnessGoal = .cardio
@State private var weeklyFrequency: Int = 3
@State private var selectedBarriers: Set<String> = []
@Environment(\.modelContext) private var context
enum Step: Int, CaseIterable {
case welcome, name, level, goal, frequency, ready
var progress: Double { Double(rawValue) / Double(Step.allCases.count - 1) }
}
private let barriers = ["Time", "Motivation", "Equipment", "Knowledge", "Injuries", "Energy"]
var body: some View {
ZStack {
Theme.surfaceBackground
.ignoresSafeArea()
VStack(spacing: 0) {
// Header: Progress + Back
if step != .welcome {
VStack(spacing: 12) {
// Back button
HStack {
Button {
withAnimation(.spring(duration: 0.45)) {
if let prev = Step(rawValue: step.rawValue - 1) {
step = prev
}
}
} label: {
Image(systemName: "chevron.left")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.secondary)
.frame(width: 44, height: 44)
}
Spacer()
}
.padding(.horizontal, 12)
// Segmented progress bar
HStack(spacing: 4) {
ForEach(Step.allCases, id: \.rawValue) { s in
Capsule()
.fill(s.rawValue <= step.rawValue ? Theme.brand : Theme.surfaceElevated)
.frame(height: 4)
}
}
.padding(.horizontal, 24)
.animation(.spring(duration: 0.5), value: step)
}
.padding(.top, 8)
}
// Step Content
Group {
switch step {
case .welcome: WelcomeStep()
case .name: NameStep(name: $name, onContinue: { advance() })
case .level: LevelStep(selection: $fitnessLevel)
case .goal: GoalStep(selection: $goal)
case .frequency: FrequencyStep(frequency: $weeklyFrequency, barriers: $selectedBarriers, allBarriers: barriers)
case .ready: ReadyStep(name: name)
}
}
.transition(.asymmetric(
insertion: .opacity.combined(with: .offset(y: 20)),
removal: .opacity.combined(with: .offset(y: -10))
))
.animation(.spring(duration: 0.45), value: step)
// Pinned bottom button
PrimaryButton(label: buttonLabel, action: buttonAction)
.disabled(step == .name && name.trimmingCharacters(in: .whitespaces).isEmpty)
.padding(.horizontal, 32)
.padding(.bottom, 48)
.padding(.top, 16)
}
}
}
private var buttonLabel: String {
switch step {
case .welcome: return "Get Started"
case .ready: return "Start My First Workout"
default: return "Continue"
}
}
private var buttonAction: () -> Void {
step == .ready ? completeOnboarding : advance
}
private func advance() {
guard let next = Step(rawValue: step.rawValue + 1) else { return }
withAnimation { step = next }
}
private func completeOnboarding() {
let profile = UserProfile()
profile.name = name.trimmingCharacters(in: .whitespaces)
profile.fitnessLevel = fitnessLevel
profile.goal = goal
profile.weeklyFrequency = weeklyFrequency
profile.barriers = Array(selectedBarriers)
profile.onboardingCompleted = true
profile.joinDate = Date()
context.insert(profile)
try? context.save()
AnalyticsService.shared.onboardingCompleted(
name: profile.name,
level: fitnessLevel.rawValue,
goal: goal.rawValue,
frequency: weeklyFrequency
)
}
}
// Step Views
private struct WelcomeStep: View {
@State private var showPills = false
@State private var pillStates = [false, false, false]
private let pills = [
("bolt.fill", "4-Min Workouts"),
("house.fill", "No Equipment"),
("mic.fill", "Voice-Guided"),
]
var body: some View {
VStack(spacing: 40) {
Spacer()
// Hero icon
Image(systemName: "bolt.fill")
.font(.system(size: 88))
.foregroundStyle(Theme.brand.gradient)
.symbolEffect(.pulse)
VStack(spacing: 14) {
Text("TabataGo")
.font(.system(size: 44, weight: .black, design: .rounded))
.foregroundStyle(.primary)
Text("High-intensity Tabata workouts,\ndesigned for real results.")
.font(.title3)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
// Feature pills
HStack(spacing: 10) {
ForEach(Array(pills.enumerated()), id: \.offset) { i, pill in
HStack(spacing: 6) {
Image(systemName: pill.0)
.font(.caption.weight(.semibold))
.foregroundStyle(Theme.brand)
Text(pill.1)
.font(.caption.weight(.medium))
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Theme.brand.opacity(0.1))
.clipShape(Capsule())
.overlay { Capsule().stroke(Theme.brand.opacity(0.2), lineWidth: 1) }
.opacity(pillStates[i] ? 1 : 0)
.offset(y: pillStates[i] ? 0 : 10)
}
}
Spacer()
}
.onAppear {
for i in 0..<3 {
withAnimation(.spring(duration: 0.5).delay(0.4 + Double(i) * 0.12)) {
pillStates[i] = true
}
}
}
}
}
private struct NameStep: View {
@Binding var name: String
let onContinue: () -> Void
@FocusState private var focused: Bool
var body: some View {
VStack(spacing: 32) {
Spacer()
OnboardingHeader(title: "What's your name?", subtitle: "We'll personalise your experience.")
VStack(spacing: 16) {
TextField("Enter your name", text: $name)
.font(.title2)
.multilineTextAlignment(.center)
.padding()
.background(Theme.surfaceCard)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(focused ? Theme.brand.opacity(0.6) : Theme.border, lineWidth: focused ? 2 : 1)
}
.padding(.horizontal, 32)
.focused($focused)
.submitLabel(.continue)
.onSubmit { if !name.isEmpty { onContinue() } }
// Live greeting
if !name.trimmingCharacters(in: .whitespaces).isEmpty {
Text("Hey, \(name.trimmingCharacters(in: .whitespaces))! 👋")
.font(.title3.weight(.semibold))
.foregroundStyle(Theme.brand)
.transition(.opacity.combined(with: .offset(y: 8)))
.animation(.spring(duration: 0.4), value: name)
}
}
Spacer()
}
.onAppear { focused = true }
}
}
private struct LevelStep: View {
@Binding var selection: FitnessLevel
@State private var appeared = false
var body: some View {
VStack(spacing: 32) {
Spacer()
OnboardingHeader(title: "What's your fitness level?", subtitle: "We'll recommend the right workouts.")
VStack(spacing: 12) {
ForEach(Array(FitnessLevel.allCases.enumerated()), id: \.element) { i, level in
SelectionCard(
label: level.label,
subtitle: levelDescription(level),
icon: levelIcon(level),
isSelected: selection == level,
color: Theme.levelColor(level.rawValue)
) {
selection = level
}
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 14)
.animation(.spring(duration: 0.45).delay(Double(i) * 0.08), value: appeared)
}
}
.padding(.horizontal, 24)
Spacer()
}
.onAppear { appeared = true }
}
private func levelDescription(_ level: FitnessLevel) -> String {
switch level {
case .beginner: return "New to HIIT or returning after a break"
case .intermediate: return "Regular exerciser, ready for more intensity"
case .advanced: return "Experienced athlete seeking maximum challenge"
}
}
private func levelIcon(_ level: FitnessLevel) -> String {
switch level {
case .beginner: return "figure.walk"
case .intermediate: return "figure.run"
case .advanced: return "figure.highintensity.intervaltraining"
}
}
}
private struct GoalStep: View {
@Binding var selection: FitnessGoal
@State private var appeared = false
var body: some View {
VStack(spacing: 32) {
Spacer()
OnboardingHeader(title: "What's your main goal?", subtitle: "This helps us curate your program.")
VStack(spacing: 12) {
ForEach(Array(FitnessGoal.allCases.enumerated()), id: \.element) { i, goal in
SelectionCard(
label: goal.label,
subtitle: goalDescription(goal),
icon: goalIcon(goal),
isSelected: selection == goal,
color: Theme.brand
) {
selection = goal
}
.opacity(appeared ? 1 : 0)
.offset(y: appeared ? 0 : 14)
.animation(.spring(duration: 0.45).delay(Double(i) * 0.08), value: appeared)
}
}
.padding(.horizontal, 24)
Spacer()
}
.onAppear { appeared = true }
}
private func goalDescription(_ goal: FitnessGoal) -> String {
switch goal {
case .weightLoss: return "Burn calories and reduce body fat"
case .cardio: return "Improve cardiovascular endurance"
case .strength: return "Build muscle and increase power"
case .wellness: return "Improve overall health and energy"
}
}
private func goalIcon(_ goal: FitnessGoal) -> String {
switch goal {
case .weightLoss: return "scalemass"
case .cardio: return "heart.fill"
case .strength: return "dumbbell.fill"
case .wellness: return "leaf.fill"
}
}
}
private struct FrequencyStep: View {
@Binding var frequency: Int
@Binding var barriers: Set<String>
let allBarriers: [String]
var body: some View {
ScrollView {
VStack(spacing: 28) {
Spacer(minLength: 20)
OnboardingHeader(title: "How often can you train?", subtitle: "Be realistic — consistency beats intensity.")
// Frequency picker
HStack(spacing: 12) {
ForEach([2, 3, 5], id: \.self) { n in
Button {
withAnimation(.spring(duration: 0.25)) { frequency = n }
} label: {
VStack(spacing: 6) {
Text("\(n)x")
.font(.system(size: 28, weight: .black, design: .rounded))
Text("per week")
.font(.caption)
}
.foregroundStyle(frequency == n ? .white : .primary)
.frame(maxWidth: .infinity)
.padding(.vertical, 22)
.background {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(frequency == n ? Theme.brand : Theme.surfaceCard)
}
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(frequency == n ? Theme.brand : Theme.border, lineWidth: frequency == n ? 0 : 1)
}
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 24)
// Barriers
VStack(alignment: .leading, spacing: 14) {
Text("Any challenges?")
.font(.headline)
.padding(.horizontal, 24)
Text("Optional — helps us personalise tips")
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.horizontal, 24)
WrappingHStack(items: allBarriers, spacing: 10, lineSpacing: 10) { barrier in
Button {
withAnimation(.spring(duration: 0.25)) {
if barriers.contains(barrier) { barriers.remove(barrier) }
else { barriers.insert(barrier) }
}
} label: {
Text(barrier)
.font(.subheadline.weight(barriers.contains(barrier) ? .semibold : .regular))
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background {
Capsule().fill(barriers.contains(barrier) ? Theme.brand.opacity(0.15) : Theme.surfaceCard)
}
.overlay {
Capsule().stroke(barriers.contains(barrier) ? Theme.brand : Theme.border, lineWidth: barriers.contains(barrier) ? 1.5 : 1)
}
}
.buttonStyle(.plain)
}
.padding(.horizontal, 24)
}
}
.padding(.bottom, 12)
}
.scrollDismissesKeyboard(.immediately)
}
}
private struct ReadyStep: View {
let name: String
@State private var showContent = false
@State private var iconStates = [false, false, false]
private let celebrationIcons = ["flame.fill", "bolt.fill", "star.fill"]
private let celebrationColors: [Color] = [Theme.brand, Theme.prep, Theme.success]
var body: some View {
VStack(spacing: 36) {
Spacer()
// Celebration icons
HStack(spacing: 20) {
ForEach(Array(celebrationIcons.enumerated()), id: \.offset) { i, icon in
Image(systemName: icon)
.font(.system(size: 28, weight: .bold))
.foregroundStyle(celebrationColors[i])
.scaleEffect(iconStates[i] ? 1 : 0)
.animation(.spring(duration: 0.5, bounce: 0.5).delay(Double(i) * 0.15), value: iconStates[i])
}
}
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 80))
.foregroundStyle(Theme.success)
.symbolEffect(.bounce)
VStack(spacing: 14) {
if name.trimmingCharacters(in: .whitespaces).isEmpty {
Text("You're all set!")
.font(.system(size: 34, weight: .black, design: .rounded))
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
} else {
let trimmedName = name.trimmingCharacters(in: .whitespaces)
Text("You're all set, \(Text(trimmedName).foregroundStyle(Theme.brand))!")
.font(.system(size: 34, weight: .black, design: .rounded))
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
}
Text("Your personalised Tabata plan is ready.")
.font(.title3)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 24)
Spacer()
}
.onAppear {
for i in 0..<3 {
iconStates[i] = true
}
}
}
}
// Reusable components
struct OnboardingHeader: View {
let title: String
let subtitle: String
var body: some View {
VStack(spacing: 10) {
Text(title)
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(.horizontal, 32)
}
}
struct SelectionCard: View {
let label: String
let subtitle: String
let icon: String
let isSelected: Bool
let color: Color
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 16) {
// Icon circle
Image(systemName: icon)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(isSelected ? color : .secondary)
.frame(width: 44, height: 44)
.background(
Circle()
.fill(isSelected ? color.opacity(0.12) : Theme.surfaceOverlay)
)
VStack(alignment: .leading, spacing: 3) {
Text(label)
.font(.headline)
.foregroundStyle(.primary)
Text(subtitle)
.font(.caption)
.foregroundStyle(isSelected ? Color.primary.opacity(0.7) : Color.secondary)
.lineLimit(2)
}
Spacer()
if isSelected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 22))
.foregroundStyle(color)
.transition(.scale.combined(with: .opacity))
}
}
.padding(16)
.background {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(isSelected ? color.opacity(0.08) : Theme.surfaceCard)
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(isSelected ? color : Theme.border, lineWidth: isSelected ? 2 : 1)
}
}
}
.buttonStyle(ScaleButtonStyle())
.animation(.spring(duration: 0.25), value: isSelected)
}
}
struct PrimaryButton: View {
let label: String
let action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.headline.weight(.bold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.background(Theme.brand.gradient)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
.buttonStyle(ScaleButtonStyle())
}
}
/// Button style that adds a subtle press scale effect.
struct ScaleButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.97 : 1.0)
.animation(.spring(duration: 0.2), value: configuration.isPressed)
}
}
// Wrapping HStack (proper flow layout)
struct WrappingHStack<Item: Hashable, Content: View>: View {
let items: [Item]
let spacing: CGFloat
let lineSpacing: CGFloat
let content: (Item) -> Content
init(items: [Item], spacing: CGFloat = 8, lineSpacing: CGFloat = 8, @ViewBuilder content: @escaping (Item) -> Content) {
self.items = items
self.spacing = spacing
self.lineSpacing = lineSpacing
self.content = content
}
var body: some View {
_WrappingLayout(spacing: spacing, lineSpacing: lineSpacing) {
ForEach(Array(items.enumerated()), id: \.offset) { _, item in
content(item)
}
}
}
}
struct _WrappingLayout: Layout {
let spacing: CGFloat
let lineSpacing: CGFloat
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let result = layout(subviews: subviews, proposal: proposal)
return result.size
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
let result = layout(subviews: subviews, proposal: proposal)
for (index, offset) in result.offsets.enumerated() {
subviews[index].place(at: CGPoint(x: bounds.minX + offset.x, y: bounds.minY + offset.y), proposal: .unspecified)
}
}
private func layout(subviews: Subviews, proposal: ProposedViewSize) -> (offsets: [CGPoint], size: CGSize) {
let maxWidth = proposal.width ?? .infinity
var offsets: [CGPoint] = []
var currentX: CGFloat = 0
var currentY: CGFloat = 0
var lineHeight: CGFloat = 0
var maxX: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if currentX + size.width > maxWidth, currentX > 0 {
currentX = 0
currentY += lineHeight + lineSpacing
lineHeight = 0
}
offsets.append(CGPoint(x: currentX, y: currentY))
lineHeight = max(lineHeight, size.height)
currentX += size.width + spacing
maxX = max(maxX, currentX - spacing)
}
return (offsets, CGSize(width: maxX, height: currentY + lineHeight))
}
}
#Preview {
OnboardingView()
.modelContainer(TabataGoSchema.previewContainer)
}

View File

@@ -0,0 +1,210 @@
import SwiftUI
import RevenueCat
/// RevenueCat paywall shows available packages with Liquid Glass cards.
struct PaywallView: View {
@StateObject private var vm = PurchaseViewModel()
@Environment(\.dismiss) private var dismiss
var body: some View {
ZStack {
Theme.surfaceBackground
.ignoresSafeArea()
ScrollView {
VStack(spacing: 28) {
// Close
HStack {
Spacer()
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.secondary)
.padding(8)
.background(.ultraThinMaterial)
.clipShape(Circle())
}
}
.padding(.horizontal)
.padding(.top, 16)
// Crown
VStack(spacing: 12) {
Image(systemName: "crown.fill")
.font(.system(size: 56))
.foregroundStyle(
LinearGradient(colors: [.yellow, .orange], startPoint: .top, endPoint: .bottom)
)
.symbolEffect(.bounce, value: vm.isPurchasing)
Text("TabataGo Premium")
.font(.system(size: 32, weight: .black, design: .rounded))
.foregroundStyle(.primary)
Text("Unlock every workout, every week.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
// Features
VStack(spacing: 12) {
FeatureRow(icon: "bolt.fill", color: Theme.brand, title: "Unlimited Workouts", subtitle: "Access all body zones & difficulty levels")
FeatureRow(icon: "heart.fill", color: .red, title: "HealthKit Sync", subtitle: "Every workout saved to Apple Health")
FeatureRow(icon: "icloud.fill", color: Theme.rest, title: "Progress Sync", subtitle: "Your history backed up to the cloud")
FeatureRow(icon: "waveform", color: Theme.success, title: "Voice Coaching", subtitle: "Audio guidance through every phase")
}
.padding(.horizontal)
// Packages
if let offerings = vm.offerings, let current = offerings.current {
VStack(spacing: 12) {
ForEach(current.availablePackages, id: \.identifier) { package in
PackageCard(
package: package,
isSelected: vm.selectedPackage?.identifier == package.identifier
) {
vm.selectedPackage = package
}
}
}
.padding(.horizontal)
} else if vm.isPurchasing {
ProgressView().padding()
}
// CTA
VStack(spacing: 12) {
Button {
Task { await vm.purchase() }
} label: {
HStack {
if vm.isPurchasing { ProgressView().tint(.white) }
Text(vm.isPurchasing ? "Processing..." : "Start Premium")
.font(.headline.weight(.bold))
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.background(
vm.selectedPackage == nil
? AnyShapeStyle(Color.gray.opacity(0.4))
: AnyShapeStyle(Theme.brand.gradient)
)
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
}
.disabled(vm.selectedPackage == nil || vm.isPurchasing)
Button {
Task { await vm.restorePurchases() }
} label: {
Text("Restore Purchases")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Text("Cancel anytime. Prices in your local currency.")
.font(.caption)
.foregroundStyle(.tertiary)
.multilineTextAlignment(.center)
}
.padding(.horizontal)
.padding(.bottom, 40)
}
}
}
.task { await vm.loadOfferings() }
.onAppear { AnalyticsService.shared.paywallViewed(source: "paywall_sheet") }
.alert("Error", isPresented: $vm.showError) {
Button("OK") {}
} message: {
Text(vm.errorMessage ?? "Something went wrong.")
}
}
}
struct FeatureRow: View {
let icon: String
let color: Color
let title: String
let subtitle: String
var body: some View {
HStack(spacing: 14) {
Image(systemName: icon)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(color)
.frame(width: 36, height: 36)
.background(color.opacity(0.12))
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
}
}
struct PackageCard: View {
let package: Package
let isSelected: Bool
let action: () -> Void
private var isYearly: Bool {
package.packageType == .annual
}
var body: some View {
Button(action: action) {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(package.storeProduct.localizedTitle)
.font(.headline.weight(.semibold))
if isYearly {
Text("BEST VALUE")
.font(.caption2.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Theme.success)
.clipShape(Capsule())
}
}
if isYearly {
Text("\(package.storeProduct.localizedPriceString) / year — save 40%")
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Text("\(package.storeProduct.localizedPriceString) / month")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.system(size: 22))
.foregroundStyle(isSelected ? Theme.brand : .secondary)
}
.padding(16)
.background {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(isSelected ? Theme.brand.opacity(0.08) : Theme.surfaceCard)
.overlay {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(isSelected ? Theme.brand : .clear, lineWidth: 2)
}
}
}
.buttonStyle(.plain)
.animation(.spring(duration: 0.25), value: isSelected)
}
}
#Preview {
PaywallView()
.modelContainer(TabataGoSchema.previewContainer)
}

View File

@@ -0,0 +1,57 @@
import SwiftUI
/// Floating pill showing the current music track with a skip button.
/// Mirrors the Expo NowPlaying component.
struct NowPlayingView: View {
let track: MusicTrack?
let isReady: Bool
let onSkip: () -> Void
@State private var isVisible = false
var body: some View {
if let track, isReady {
HStack(spacing: 8) {
// Music note icon
Image(systemName: "music.note")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Theme.success)
.frame(width: 28, height: 28)
.background(Theme.success.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
// Track info
VStack(alignment: .leading, spacing: 1) {
Text(track.title)
.font(.caption.weight(.semibold))
.foregroundStyle(.white)
.lineLimit(1)
Text(track.artist)
.font(.caption2)
.foregroundStyle(.white.opacity(0.55))
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
// Skip button
Button(action: onSkip) {
Image(systemName: "forward.fill")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.white.opacity(0.6))
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
}
.padding(.vertical, 6)
.padding(.horizontal, 12)
.background(.ultraThinMaterial)
.clipShape(Capsule())
.overlay(Capsule().stroke(.white.opacity(0.1), lineWidth: 1))
.opacity(isVisible ? 1 : 0)
.offset(y: isVisible ? 0 : 20)
.onAppear { withAnimation(.spring(duration: 0.4, bounce: 0.3)) { isVisible = true } }
.onDisappear { isVisible = false }
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
}
}

View File

@@ -0,0 +1,338 @@
import SwiftUI
/// Full-screen Tabata workout player with Liquid Glass timer.
struct PlayerView: View {
let program: WorkoutProgram
@StateObject private var vm: PlayerViewModel
@StateObject private var musicVM: MusicPlayerViewModel
@Environment(\.dismiss) private var dismiss
@Environment(\.modelContext) private var context
init(program: WorkoutProgram) {
self.program = program
_vm = StateObject(wrappedValue: PlayerViewModel(program: program))
let vibe = MusicVibe(rawValue: program.musicVibe) ?? .electronic
_musicVM = StateObject(wrappedValue: MusicPlayerViewModel(vibe: vibe))
}
var body: some View {
NavigationStack {
ZStack {
// Animated Phase Background
PhaseBackground(phase: vm.phase)
.ignoresSafeArea()
// Content
VStack(spacing: 0) {
PlayerTopBar(
title: program.titleEn,
block: vm.currentBlockIndex + 1,
totalBlocks: program.blocks.count,
onClose: { vm.showExitConfirmation = true }
)
Spacer()
// Exercise Label
if let exercise = vm.currentExercise {
Text(exercise.nameEn)
.font(.system(size: 32, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.multilineTextAlignment(.center)
.padding(.horizontal, 32)
.transition(.opacity.combined(with: .scale(scale: 0.9)))
}
// Phase Badge
Text(Theme.phaseLabel(vm.phase))
.font(Theme.phaseFont)
.foregroundStyle(.white.opacity(0.85))
.padding(.top, 8)
Spacer()
// Timer Ring
TimerRing(
timeRemaining: vm.timeRemaining,
total: vm.totalPhaseTime,
phase: vm.phase
)
Spacer()
// Round Counter
RoundCounter(
current: vm.currentRound,
total: vm.totalRoundsInBlock,
phase: vm.phase
)
// Live Stats (HealthKit)
if vm.heartRate > 0 || vm.liveCalories > 0 {
LiveStatsBar(heartRate: vm.heartRate, calories: vm.liveCalories)
.padding(.top, 12)
}
// Now Playing (Music)
NowPlayingView(
track: musicVM.currentTrack,
isReady: musicVM.isReady,
onSkip: { musicVM.skipTrack() }
)
.padding(.horizontal, 32)
.padding(.top, 8)
Spacer()
// Controls
PlayerControls(
isRunning: vm.isRunning,
isPaused: vm.isPaused,
onStartPause: { vm.togglePlayPause() },
onSkip: { vm.skipPhase() }
)
.padding(.bottom, 40)
}
}
.navigationBarHidden(true)
.statusBarHidden(true)
.preferredColorScheme(.dark)
.onAppear {
vm.setup(modelContext: context)
UIApplication.shared.isIdleTimerDisabled = true
Task { await musicVM.load() }
}
.onDisappear {
UIApplication.shared.isIdleTimerDisabled = false
musicVM.stop()
}
.onChange(of: vm.isRunning) { _, running in
let musicPhase = vm.phase != .prep && vm.phase != .warmup && vm.phase != .complete
musicVM.setPlaying(running && !vm.isPaused && musicPhase)
}
.onChange(of: vm.isPaused) { _, paused in
let musicPhase = vm.phase != .prep && vm.phase != .warmup && vm.phase != .complete
musicVM.setPlaying(vm.isRunning && !paused && musicPhase)
}
.onChange(of: vm.phase) { _, phase in
let musicPhase = phase != .prep && phase != .warmup && phase != .complete
musicVM.setPlaying(vm.isRunning && !vm.isPaused && musicPhase)
}
.navigationDestination(isPresented: $vm.isComplete) {
CompletionView(session: vm.completedSession, program: program, onDone: { dismiss() })
.navigationBarBackButtonHidden()
}
.alert("End Workout?", isPresented: $vm.showExitConfirmation) {
Button("End Workout", role: .destructive) {
vm.abandonWorkout()
dismiss()
}
Button("Keep Going", role: .cancel) {}
} message: {
Text("Your progress will not be saved.")
}
} // NavigationStack
}
}
// Sub-components
struct PhaseBackground: View {
let phase: TimerPhase
@State private var animating = false
var body: some View {
ZStack {
Color.black
RadialGradient(
colors: [Theme.phaseColor(phase).opacity(0.45), .clear],
center: .center,
startRadius: 0,
endRadius: 400
)
.scaleEffect(animating ? 1.15 : 1.0)
.animation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true), value: animating)
}
.onChange(of: phase) { _, _ in animating = false; animating = true }
.onAppear { animating = true }
}
}
struct PlayerTopBar: View {
let title: String
let block: Int
let totalBlocks: Int
let onClose: () -> Void
var body: some View {
HStack {
Button(action: onClose) {
Image(systemName: "xmark")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
.padding(10)
.background(.ultraThinMaterial)
.clipShape(Circle())
}
Spacer()
VStack(spacing: 2) {
Text(title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
.lineLimit(1)
Text("Block \(block) of \(totalBlocks)")
.font(.caption)
.foregroundStyle(.white.opacity(0.7))
}
Spacer()
// Placeholder for symmetry
Color.clear.frame(width: 37, height: 37)
}
.padding(.horizontal, 20)
.padding(.top, 16)
}
}
struct TimerRing: View {
let timeRemaining: Int
let total: Int
let phase: TimerPhase
private var progress: Double {
guard total > 0 else { return 1 }
return Double(timeRemaining) / Double(total)
}
var body: some View {
ZStack {
// Background ring
Circle()
.stroke(.white.opacity(0.1), lineWidth: 16)
.frame(width: 240, height: 240)
// Progress ring
Circle()
.trim(from: 0, to: progress)
.stroke(
Theme.phaseColor(phase),
style: StrokeStyle(lineWidth: 16, lineCap: .round)
)
.frame(width: 240, height: 240)
.rotationEffect(.degrees(-90))
.animation(.linear(duration: 1), value: progress)
// Glass disc
Circle()
.fill(.ultraThinMaterial)
.frame(width: 200, height: 200)
// Timer digits
Text("\(timeRemaining)")
.font(Theme.timerFont)
.foregroundStyle(.white)
.monospacedDigit()
.contentTransition(.numericText(countsDown: true))
.animation(.spring(duration: 0.3), value: timeRemaining)
}
}
}
struct RoundCounter: View {
let current: Int
let total: Int
let phase: TimerPhase
var body: some View {
HStack(spacing: 8) {
ForEach(1...max(total, 1), id: \.self) { i in
Capsule()
.fill(i < current ? Theme.phaseColor(phase) :
i == current ? .white :
.white.opacity(0.25))
.frame(width: i == current ? 24 : 8, height: 8)
.animation(.spring(duration: 0.3), value: current)
}
}
.padding(.vertical, 8)
}
}
struct LiveStatsBar: View {
let heartRate: Double
let calories: Double
var body: some View {
HStack(spacing: 24) {
if heartRate > 0 {
HStack(spacing: 6) {
Image(systemName: "heart.fill")
.foregroundStyle(.red)
Text("\(Int(heartRate)) bpm")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
.monospacedDigit()
}
}
if calories > 0 {
HStack(spacing: 6) {
Image(systemName: "flame.fill")
.foregroundStyle(Theme.brand)
Text("\(Int(calories)) kcal")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
.monospacedDigit()
}
}
}
.padding(.horizontal, 24)
.padding(.vertical, 10)
.background(.ultraThinMaterial)
.clipShape(Capsule())
}
}
struct PlayerControls: View {
let isRunning: Bool
let isPaused: Bool
let onStartPause: () -> Void
let onSkip: () -> Void
var body: some View {
HStack(spacing: 40) {
// Skip button
Button(action: onSkip) {
Image(systemName: "forward.end.fill")
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(.white.opacity(0.8))
.padding(16)
.background(.ultraThinMaterial)
.clipShape(Circle())
}
// Play / Pause
Button(action: onStartPause) {
Image(systemName: isRunning && !isPaused ? "pause.fill" : "play.fill")
.font(.system(size: 32, weight: .bold))
.foregroundStyle(.white)
.frame(width: 72, height: 72)
.background(Theme.phaseColor(.work))
.clipShape(Circle())
.shadow(color: Theme.brand.opacity(0.4), radius: 16, y: 6)
}
.scaleEffect(isRunning ? 1.0 : 1.05)
.animation(.spring(duration: 0.3), value: isRunning)
// Spacer for symmetry
Color.clear.frame(width: 54, height: 54)
}
}
}
#Preview {
PlayerView(program: PreviewData.sampleProgram)
.modelContainer(TabataGoSchema.previewContainer)
}

View File

@@ -0,0 +1,68 @@
import SwiftUI
/// Programs filtered by body zone (upper / lower / full).
struct BodyZoneView: View {
let zone: String
@StateObject private var vm = HomeViewModel()
@State private var selectedProgram: WorkoutProgram? = nil
private var zoneTitle: String {
switch zone {
case "upper-body": return "Upper Body"
case "lower-body": return "Lower Body"
case "full-body": return "Full Body"
default: return zone.replacingOccurrences(of: "-", with: " ").capitalized
}
}
private var programs: [WorkoutProgram] {
vm.allPrograms.filter { $0.bodyZone == zone }
}
var body: some View {
List {
if vm.isLoading {
ProgressView().frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
} else if let error = vm.error {
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.font(.title2)
.foregroundStyle(.secondary)
Text("Failed to load programs")
.font(.subheadline.weight(.semibold))
Text(error)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("Retry") { Task { await vm.refresh() } }
.buttonStyle(.bordered)
.padding(.top, 4)
}
.frame(maxWidth: .infinity, minHeight: 120)
.listRowBackground(Color.clear)
} else if programs.isEmpty {
ContentUnavailableView(
"No Programs Yet",
systemImage: "dumbbell",
description: Text("Programs for \(zoneTitle) are coming soon.")
)
.listRowBackground(Color.clear)
} else {
ForEach(programs) { program in
ProgramRow(program: program)
.listRowBackground(Color.clear)
.listRowInsets(.init(top: 4, leading: 16, bottom: 4, trailing: 16))
.onTapGesture { selectedProgram = program }
}
}
}
.listStyle(.plain)
.navigationTitle(zoneTitle)
.navigationBarTitleDisplayMode(.large)
.task { await vm.loadPrograms() }
.sheet(item: $selectedProgram) { program in
ProgramDetailView(program: program)
}
}
}

View File

@@ -0,0 +1,221 @@
import SwiftUI
import SwiftData
/// Program detail exercise list, warmup/cooldown, start button.
struct ProgramDetailView: View {
let program: WorkoutProgram
@Query private var profiles: [UserProfile]
@Environment(\.dismiss) private var dismiss
@State private var showingPlayer = false
@State private var showingPaywall = false
@State private var selectedBlock: TabataBlock? = nil
@StateObject private var purchaseVM = PurchaseViewModel()
private var profile: UserProfile? { profiles.first }
private var canAccess: Bool {
program.isFree || (profile?.subscription.isPremium == true)
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 0) {
// Hero Banner
ZStack(alignment: .bottomLeading) {
Rectangle()
.fill(Theme.zoneGradient(program.bodyZone))
.frame(height: 240)
VStack(alignment: .leading, spacing: 8) {
LevelBadge(level: program.level)
Text(program.titleEn)
.font(.system(size: 28, weight: .black, design: .rounded))
.foregroundStyle(.white)
HStack(spacing: 16) {
Label("\(program.estimatedDuration) min", systemImage: "clock.fill")
Label("\(program.estimatedCalories) kcal", systemImage: "flame.fill")
Label("\(program.totalRounds) rounds", systemImage: "repeat")
}
.font(.subheadline.weight(.medium))
.foregroundStyle(.white.opacity(0.85))
}
.padding(20)
}
VStack(alignment: .leading, spacing: 20) {
// Description
if !program.descriptionEn.isEmpty {
Text(program.descriptionEn)
.font(.body)
.foregroundStyle(.secondary)
.padding(.horizontal)
}
// Warmup
if !program.warmup.movements.isEmpty {
ExerciseSection(title: "Warm Up", icon: "figure.cooldown", color: Theme.prep) {
ForEach(program.warmup.movements, id: \.name) { move in
ExerciseRow(name: move.nameEn, duration: "\(move.duration)s", color: Theme.prep)
}
}
}
// Tabata Blocks
ForEach(Array(program.blocks.enumerated()), id: \.offset) { i, block in
ExerciseSection(
title: "Block \(i + 1)",
icon: "bolt.fill",
color: Theme.brand,
subtitle: "\(block.rounds) rounds · \(block.workTime)s work / \(block.restTime)s rest"
) {
ExerciseRow(
name: block.exercise1.nameEn,
duration: "\(block.workTime)s",
tip: block.exercise1.tipEn,
color: Theme.brand
)
Divider().padding(.leading, 36)
ExerciseRow(
name: block.exercise2.nameEn,
duration: "\(block.workTime)s",
tip: block.exercise2.tipEn,
color: Theme.brand
)
}
}
// Cooldown
if !program.cooldown.movements.isEmpty {
ExerciseSection(title: "Cool Down", icon: "snowflake", color: Theme.rest) {
ForEach(program.cooldown.movements, id: \.name) { move in
ExerciseRow(name: move.nameEn, duration: "\(move.duration)s", color: Theme.rest)
}
}
}
Spacer(minLength: 120)
}
.padding(.top, 20)
}
}
.ignoresSafeArea(edges: .top)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button { dismiss() } label: {
Image(systemName: "chevron.down")
.font(.system(size: 17, weight: .semibold))
}
}
}
.safeAreaInset(edge: .bottom) {
// Start Button
VStack(spacing: 0) {
Divider()
Button {
if canAccess { showingPlayer = true }
else { showingPaywall = true }
} label: {
HStack(spacing: 10) {
if !canAccess {
Image(systemName: "lock.fill")
}
Text(canAccess ? "Start Workout" : "Unlock Premium")
.font(.headline.weight(.bold))
}
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.background(canAccess ? AnyShapeStyle(Theme.brand.gradient) : AnyShapeStyle(LinearGradient(colors: [.gray.opacity(0.6), .gray.opacity(0.4)], startPoint: .leading, endPoint: .trailing)))
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
.background(.ultraThinMaterial)
}
.fullScreenCover(isPresented: $showingPlayer) {
PlayerView(program: program)
}
.sheet(isPresented: $showingPaywall) {
PaywallView()
}
}
}
}
// Components
struct ExerciseSection<Content: View>: View {
let title: String
let icon: String
let color: Color
var subtitle: String? = nil
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 10) {
Image(systemName: icon)
.foregroundStyle(color)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.headline.weight(.semibold))
if let subtitle {
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(.horizontal)
.padding(.bottom, 10)
VStack(spacing: 0) {
content()
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.padding(.horizontal)
}
}
}
struct ExerciseRow: View {
let name: String
let duration: String
var tip: String? = nil
let color: Color
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Circle()
.fill(color.opacity(0.25))
.frame(width: 8, height: 8)
.padding(.leading, 12)
Text(name)
.font(.subheadline.weight(.medium))
Spacer()
Text(duration)
.font(.subheadline)
.foregroundStyle(.secondary)
.monospacedDigit()
.padding(.trailing, 12)
}
if let tip {
Text(tip)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 32)
}
}
.padding(.vertical, 12)
}
}
#Preview {
ProgramDetailView(program: PreviewData.sampleProgram)
.modelContainer(TabataGoSchema.previewContainer)
}

View File

@@ -0,0 +1,79 @@
import SwiftUI
struct PrivacyPolicyView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Group {
PolicySection(title: "Data We Collect") {
Text("TabataGo collects minimal data to provide you with a great workout experience. We collect your name, fitness preferences, and workout history locally on your device.")
}
PolicySection(title: "Apple Health") {
Text("When you grant permission, TabataGo saves your Tabata workouts to Apple Health, including calories burned, heart rate, and workout duration. This data stays on your device and is governed by Apple's privacy policies.")
}
PolicySection(title: "Analytics") {
Text("We use PostHog to collect anonymised usage analytics to improve the app. No personally identifiable information is sent. You can opt out in your device privacy settings.")
}
PolicySection(title: "Purchases") {
Text("Subscription purchases are handled by Apple's App Store and RevenueCat. We do not store your payment information.")
}
PolicySection(title: "Data Storage") {
Text("Your workout history, profile, and settings are stored locally using SwiftData. If you enable cloud sync, data is securely stored in Supabase with industry-standard encryption.")
}
PolicySection(title: "Contact") {
Text("For privacy concerns, contact us at privacy@tabatago.app")
}
}
.padding(.horizontal)
}
.padding(.vertical)
}
.navigationTitle("Privacy Policy")
.navigationBarTitleDisplayMode(.large)
}
}
struct TermsOfServiceView: View {
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Group {
PolicySection(title: "Use of the App") {
Text("TabataGo is designed for fitness purposes. By using the app, you agree to use it responsibly and consult a healthcare professional before starting any new exercise program.")
}
PolicySection(title: "Subscription") {
Text("Premium subscriptions are billed monthly or yearly through the App Store. Subscriptions automatically renew unless cancelled at least 24 hours before the renewal date.")
}
PolicySection(title: "Health Disclaimer") {
Text("TabataGo is not a medical device. The app does not provide medical advice. Always consult a doctor before beginning a new exercise program, especially if you have pre-existing health conditions.")
}
PolicySection(title: "Limitation of Liability") {
Text("TabataGo is provided 'as is'. We are not liable for any injuries or health issues arising from the use of our workout programs.")
}
PolicySection(title: "Changes to Terms") {
Text("We may update these terms at any time. Continued use of the app after changes constitutes acceptance of the new terms.")
}
}
.padding(.horizontal)
}
.padding(.vertical)
}
.navigationTitle("Terms of Service")
.navigationBarTitleDisplayMode(.large)
}
}
struct PolicySection<Content: View>: View {
let title: String
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline.weight(.semibold))
content()
.font(.body)
.foregroundStyle(.secondary)
}
}
}

View File

@@ -0,0 +1,170 @@
import SwiftUI
import SwiftData
/// Settings screen haptics, audio, voice coaching, reminders, account.
struct SettingsView: View {
@Query private var profiles: [UserProfile]
@Environment(\.modelContext) private var context
@State private var showingResetAlert = false
private var profile: UserProfile? { profiles.first }
var body: some View {
Form {
// Audio
Section("Audio") {
if let profile {
Toggle("Sound Effects", isOn: Binding(
get: { profile.soundEffectsEnabled },
set: { profile.soundEffectsEnabled = $0; save() }
))
Toggle("Voice Coaching", isOn: Binding(
get: { profile.voiceCoachingEnabled },
set: { profile.voiceCoachingEnabled = $0; save() }
))
Toggle("Music", isOn: Binding(
get: { profile.musicEnabled },
set: { profile.musicEnabled = $0; save() }
))
if profile.musicEnabled {
HStack {
Image(systemName: "speaker.fill")
.foregroundStyle(.secondary)
Slider(value: Binding(
get: { profile.musicVolume },
set: { profile.musicVolume = $0; save() }
))
Image(systemName: "speaker.wave.3.fill")
.foregroundStyle(.secondary)
}
}
}
}
// Haptics
Section("Haptics") {
if let profile {
Toggle("Haptic Feedback", isOn: Binding(
get: { profile.hapticsEnabled },
set: { profile.hapticsEnabled = $0; save() }
))
}
}
// Reminders
Section("Reminders") {
if let profile {
Toggle("Daily Reminder", isOn: Binding(
get: { profile.remindersEnabled },
set: { profile.remindersEnabled = $0; save() }
))
if profile.remindersEnabled {
DatePicker(
"Reminder Time",
selection: Binding(
get: {
var c = DateComponents()
c.hour = profile.reminderTimeHour
c.minute = profile.reminderTimeMinute
return Calendar.current.date(from: c) ?? Date()
},
set: { date in
let c = Calendar.current.dateComponents([.hour, .minute], from: date)
profile.reminderTimeHour = c.hour ?? 9
profile.reminderTimeMinute = c.minute ?? 0
save()
}
),
displayedComponents: .hourAndMinute
)
}
}
}
// HealthKit
Section("Apple Health") {
Button {
Task { try? await HealthKitService.shared.requestAuthorization() }
} label: {
HStack {
Label("Manage Health Permissions", systemImage: "heart.text.square")
Spacer()
Image(systemName: "arrow.up.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
.buttonStyle(.plain)
}
// Account
Section("Account") {
if let profile {
HStack {
Text("Name")
Spacer()
Text(profile.name.isEmpty ? "Not set" : profile.name)
.foregroundStyle(.secondary)
}
HStack {
Text("Joined")
Spacer()
Text(profile.joinDate.formatted(date: .abbreviated, time: .omitted))
.foregroundStyle(.secondary)
}
}
Button(role: .destructive) {
showingResetAlert = true
} label: {
Label("Reset All Progress", systemImage: "trash")
.foregroundStyle(.red)
}
}
// About
Section("About") {
HStack {
Text("Version")
Spacer()
Text(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0")
.foregroundStyle(.secondary)
}
NavigationLink("Privacy Policy") { PrivacyPolicyView() }
NavigationLink("Terms of Service") { TermsOfServiceView() }
}
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.large)
.alert("Reset All Progress?", isPresented: $showingResetAlert) {
Button("Reset", role: .destructive) { resetProgress() }
Button("Cancel", role: .cancel) {}
} message: {
Text("This will permanently delete your workout history and streak. This cannot be undone.")
}
}
private func save() {
try? context.save()
// Sync settings to AudioService
if let profile {
AudioService.shared.isSoundEffectsEnabled = profile.soundEffectsEnabled
AudioService.shared.isVoiceCoachingEnabled = profile.voiceCoachingEnabled
AudioService.shared.isMusicEnabled = profile.musicEnabled
AudioService.shared.musicVolume = Float(profile.musicVolume)
}
}
private func resetProgress() {
let descriptor = FetchDescriptor<WorkoutSession>()
if let sessions = try? context.fetch(descriptor) {
sessions.forEach { context.delete($0) }
}
profile?.onboardingCompleted = false
try? context.save()
}
}

View File

@@ -0,0 +1,317 @@
import SwiftUI
import SwiftData
/// Activity tab streak, workout history, HealthKit rings summary.
struct ActivityTab: View {
@Query(sort: \WorkoutSession.completedAt, order: .reverse)
private var sessions: [WorkoutSession]
@Query private var snapshots: [HealthSnapshot]
@StateObject private var healthVM = HealthViewModel()
private var streak: (current: Int, longest: Int) { computeStreak(from: sessions) }
private var snapshot: HealthSnapshot? { snapshots.first }
private var weeklyCount: Int { countThisWeek(sessions) }
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 20) {
// Streak Banner
StreakBanner(current: streak.current, longest: streak.longest)
.padding(.horizontal)
// HealthKit Rings
if let snap = snapshot {
HealthRingsCard(snapshot: snap)
.padding(.horizontal)
}
// Weekly Summary
VStack(alignment: .leading, spacing: 12) {
Text("This Week")
.font(.title3.weight(.bold))
.padding(.horizontal)
HStack(spacing: 12) {
StatBadge(label: "Workouts", value: "\(weeklyCount)", color: Theme.brand, icon: "flame.fill")
StatBadge(label: "Minutes", value: "\(weeklyMinutes)", color: Theme.rest, icon: "clock.fill")
StatBadge(label: "Calories", value: "\(Int(weeklyCalories))", color: Theme.success, icon: "bolt.fill")
}
.padding(.horizontal)
}
// Workout History
if !sessions.isEmpty {
VStack(alignment: .leading, spacing: 12) {
Text("History")
.font(.title3.weight(.bold))
.padding(.horizontal)
LazyVStack(spacing: 10) {
ForEach(sessions.prefix(30)) { session in
SessionHistoryRow(session: session)
.padding(.horizontal)
}
}
}
} else {
EmptyActivityView()
.padding(.horizontal)
}
Spacer(minLength: 40)
}
.padding(.top, 8)
}
.navigationTitle("Activity")
.navigationBarTitleDisplayMode(.large)
.task { await healthVM.refresh() }
.refreshable { await healthVM.refresh() }
}
}
private var weeklyMinutes: Int {
countThisWeek(sessions, value: { $0.durationSeconds / 60 })
}
private var weeklyCalories: Double {
sessions.filter { isThisWeek($0.completedAt) }
.reduce(0) { $0 + $1.caloriesBurned }
}
}
// Sub-components
struct StreakBanner: View {
let current: Int
let longest: Int
var body: some View {
HStack(spacing: 0) {
// Current streak
VStack(spacing: 4) {
HStack(alignment: .lastTextBaseline, spacing: 4) {
Text("\(current)")
.font(.system(size: 52, weight: .black, design: .rounded))
.monospacedDigit()
.foregroundStyle(Theme.brand)
Text("days")
.font(.headline)
.foregroundStyle(.secondary)
}
Text("Current Streak")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
Divider().frame(height: 50)
// Longest streak
VStack(spacing: 4) {
HStack(alignment: .lastTextBaseline, spacing: 4) {
Text("\(longest)")
.font(.system(size: 52, weight: .black, design: .rounded))
.monospacedDigit()
.foregroundStyle(Theme.success)
Text("days")
.font(.headline)
.foregroundStyle(.secondary)
}
Text("Best Streak")
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
.padding(.vertical, 16)
.glassCard()
}
}
struct HealthRingsCard: View {
let snapshot: HealthSnapshot
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Image(systemName: "heart.fill")
.foregroundStyle(.red)
Text("Apple Health")
.font(.headline.weight(.semibold))
Spacer()
Text("Today")
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
HealthRingStat(
label: "Move",
value: "\(Int(snapshot.activeCaloricBurn))",
unit: "kcal",
color: .red
)
HealthRingStat(
label: "Exercise",
value: "\(Int(snapshot.exerciseMinutes))",
unit: "min",
color: .green
)
HealthRingStat(
label: "Stand",
value: "\(snapshot.standHours)",
unit: "hrs",
color: .cyan
)
}
if let hr = snapshot.restingHeartRate {
Divider()
HStack {
Label("\(Int(hr)) bpm", systemImage: "waveform.path.ecg")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
Text("Resting HR")
.font(.caption)
.foregroundStyle(.tertiary)
}
}
}
.padding(16)
.glassCard()
}
}
struct HealthRingStat: View {
let label: String
let value: String
let unit: String
let color: Color
var body: some View {
VStack(spacing: 4) {
Text(value)
.font(.system(size: 24, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(color)
Text(unit)
.font(.caption2.weight(.semibold))
.foregroundStyle(color.opacity(0.7))
Text(label)
.font(.caption2)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}
struct SessionHistoryRow: View {
let session: WorkoutSession
var body: some View {
HStack(spacing: 14) {
// Zone indicator
Circle()
.fill(Theme.zoneColor(session.bodyZone))
.frame(width: 10, height: 10)
VStack(alignment: .leading, spacing: 3) {
Text(session.programTitle)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
Text(session.completedAt.formatted(date: .abbreviated, time: .omitted))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
VStack(alignment: .trailing, spacing: 3) {
Text("\(session.durationSeconds / 60)m")
.font(.subheadline.weight(.semibold))
.monospacedDigit()
if session.caloriesBurned > 0 {
Text("\(Int(session.caloriesBurned)) kcal")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding(14)
.glassCard()
}
}
struct EmptyActivityView: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "figure.run.circle")
.font(.system(size: 56))
.foregroundStyle(Theme.brand.opacity(0.6))
Text("No workouts yet")
.font(.title3.weight(.semibold))
Text("Complete your first Tabata to see your activity here.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(32)
.frame(maxWidth: .infinity)
.glassCard()
}
}
// Helpers
private func computeStreak(from sessions: [WorkoutSession]) -> (current: Int, longest: Int) {
guard !sessions.isEmpty else { return (0, 0) }
let calendar = Calendar.current
let uniqueDays = Set(sessions.map { calendar.startOfDay(for: $0.completedAt) })
.sorted(by: >)
let today = calendar.startOfDay(for: Date())
let yesterday = calendar.date(byAdding: .day, value: -1, to: today)!
guard uniqueDays[0] == today || uniqueDays[0] == yesterday else {
return (0, longestStreak(from: uniqueDays.sorted(by: >)))
}
var current = 1
for i in 1..<uniqueDays.count {
let expected = calendar.date(byAdding: .day, value: -i, to: today)!
if uniqueDays[i] == expected { current += 1 } else { break }
}
return (current, longestStreak(from: uniqueDays.sorted(by: >)))
}
private func longestStreak(from sortedDays: [Date]) -> Int {
guard !sortedDays.isEmpty else { return 0 }
let calendar = Calendar.current
var longest = 1, run = 1
for i in 1..<sortedDays.count {
let diff = calendar.dateComponents([.day], from: sortedDays[i], to: sortedDays[i-1]).day ?? 0
if diff == 1 { run += 1; longest = max(longest, run) } else { run = 1 }
}
return longest
}
private func countThisWeek(_ sessions: [WorkoutSession]) -> Int {
sessions.filter { isThisWeek($0.completedAt) }.count
}
private func countThisWeek(_ sessions: [WorkoutSession], value: (WorkoutSession) -> Int) -> Int {
sessions.filter { isThisWeek($0.completedAt) }.reduce(0) { $0 + value($1) }
}
private func isThisWeek(_ date: Date) -> Bool {
Calendar.current.isDate(date, equalTo: Date(), toGranularity: .weekOfYear)
}
#Preview {
ActivityTab()
.modelContainer(TabataGoSchema.previewContainer)
}

View File

@@ -0,0 +1,329 @@
import SwiftUI
import SwiftData
/// Home tab featured programs, quick start, welcome back header.
struct HomeTab: View {
@Query private var profiles: [UserProfile]
@Query(sort: \WorkoutSession.completedAt, order: .reverse) private var sessions: [WorkoutSession]
@StateObject private var vm: HomeViewModel
@State private var selectedProgram: WorkoutProgram? = nil
@State private var showingPlayer = false
/// Production init ViewModel fetches programs from Supabase.
init() {
_vm = StateObject(wrappedValue: HomeViewModel())
}
/// Preview/test init injects a pre-populated ViewModel, no network calls.
init(previewVM: HomeViewModel) {
_vm = StateObject(wrappedValue: previewVM)
}
private var profile: UserProfile? { profiles.first }
// Stats derived from SwiftData session history
private var currentStreak: Int {
let calendar = Calendar.current
var streak = 0
var checkDate = calendar.startOfDay(for: Date())
let workoutDays = Set(sessions.map { calendar.startOfDay(for: $0.completedAt) })
while workoutDays.contains(checkDate) {
streak += 1
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate)!
}
return streak
}
private var weeklyCount: Int {
let start = Calendar.current.date(byAdding: .day, value: -7, to: Date())!
return sessions.filter { $0.completedAt >= start }.count
}
private var totalCount: Int { sessions.count }
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
// Quick Stats Row
HStack(spacing: 12) {
StatBadge(label: "Streak", value: "\(currentStreak)d", color: Theme.brand, icon: "flame.fill")
StatBadge(label: "This Week", value: "\(weeklyCount)", color: Theme.success, icon: "checkmark.circle.fill")
StatBadge(label: "All Time", value: "\(totalCount)", color: Theme.rest, icon: "trophy.fill")
}
.padding(.horizontal)
// Featured Workouts
if !vm.featuredPrograms.isEmpty {
VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "Featured", subtitle: "Handpicked for you")
.padding(.horizontal)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
ForEach(vm.featuredPrograms) { program in
FeaturedProgramCard(program: program)
.onTapGesture { selectedProgram = program }
}
}
.padding(.horizontal)
}
}
}
// Body Zone Grid
VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "Browse by Zone", subtitle: "Target specific muscle groups")
.padding(.horizontal)
VStack(spacing: 12) {
ForEach(vm.availableZones, id: \.self) { zone in
NavigationLink(destination: BodyZoneView(zone: zone)) {
ZoneCard(zone: zone)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
}
// All Programs
if !vm.allPrograms.isEmpty {
VStack(alignment: .leading, spacing: 12) {
SectionHeader(title: "All Workouts")
.padding(.horizontal)
LazyVStack(spacing: 12) {
ForEach(vm.allPrograms) { program in
ProgramRow(program: program)
.onTapGesture { selectedProgram = program }
.padding(.horizontal)
}
}
}
}
// Loading / Error State
if vm.isLoading {
ProgressView()
.frame(maxWidth: .infinity, minHeight: 120)
} else if let error = vm.error {
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle")
.font(.title2)
.foregroundStyle(.secondary)
Text("Failed to load programs")
.font(.subheadline.weight(.semibold))
Text(error)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("Retry") { Task { await vm.refresh() } }
.buttonStyle(.bordered)
.padding(.top, 4)
}
.frame(maxWidth: .infinity, minHeight: 120)
.padding(.horizontal)
}
Spacer(minLength: 32)
}
.padding(.top, 8)
}
.navigationTitle(profile?.name.isEmpty == false ? "Hey, \(profile!.name.split(separator: " ").first ?? "there") 👋" : "TabataGo")
.navigationBarTitleDisplayMode(.large)
.refreshable { await vm.refresh() }
.sheet(item: $selectedProgram) { program in
ProgramDetailView(program: program)
}
.task { await vm.loadPrograms() }
}
}
}
// Sub-components
struct SectionHeader: View {
let title: String
var subtitle: String? = nil
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.title2.weight(.bold))
if let subtitle {
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
}
}
struct FeaturedProgramCard: View {
let program: WorkoutProgram
var body: some View {
VStack(alignment: .leading, spacing: 8) {
// Header gradient area
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Theme.zoneGradient(program.bodyZone))
.frame(width: 220, height: 110)
.overlay(alignment: .bottomLeading) {
VStack(alignment: .leading, spacing: 4) {
Text(program.titleEn)
.font(.headline.weight(.bold))
.foregroundStyle(.white)
.lineLimit(2)
HStack(spacing: 6) {
Label("\(program.estimatedDuration)m", systemImage: "clock")
Label("\(program.estimatedCalories) kcal", systemImage: "flame")
}
.font(.caption.weight(.medium))
.foregroundStyle(.white.opacity(0.85))
}
.padding(12)
}
HStack {
LevelBadge(level: program.level)
Spacer()
if program.isFree {
Text("FREE")
.font(.caption2.weight(.bold))
.foregroundStyle(Theme.success)
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Theme.success.opacity(0.15))
.clipShape(Capsule())
}
}
}
.frame(width: 220)
}
}
struct ZoneCard: View {
let zone: String
var body: some View {
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Theme.zoneGradient(zone))
HStack {
VStack(alignment: .leading, spacing: 6) {
Text(zoneLabel)
.font(.title3.weight(.bold))
.foregroundStyle(.white)
Text(zoneDescription)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.8))
.lineLimit(2)
Spacer(minLength: 0)
HStack(spacing: 4) {
Text("Explore")
.font(.caption.weight(.semibold))
Image(systemName: "arrow.right")
.font(.caption.weight(.semibold))
}
.foregroundStyle(.white.opacity(0.7))
}
Spacer()
Image(systemName: zoneIcon)
.font(.system(size: 44, weight: .semibold))
.foregroundStyle(.white.opacity(0.25))
}
.padding(20)
}
.frame(height: 140)
}
private var zoneLabel: String {
switch zone {
case "upper-body": return "Upper Body"
case "lower-body": return "Lower Body"
case "full-body": return "Full Body"
default: return zone.replacingOccurrences(of: "-", with: " ").capitalized
}
}
private var zoneDescription: String {
switch zone {
case "upper-body": return "Arms, chest, shoulders & back"
case "lower-body": return "Legs, glutes & core stability"
case "full-body": return "Total body burn, head to toe"
default: return "Targeted workouts"
}
}
private var zoneIcon: String {
switch zone {
case "upper-body": return "figure.arms.open"
case "lower-body": return "figure.walk"
case "full-body": return "figure.highintensity.intervaltraining"
default: return "figure.run"
}
}
}
struct LevelBadge: View {
let level: String
var body: some View {
Text(level)
.font(.caption2.weight(.bold))
.foregroundStyle(Theme.levelColor(level))
.padding(.horizontal, 8)
.padding(.vertical, 3)
.background(Theme.levelColor(level).opacity(0.15))
.clipShape(Capsule())
}
}
struct ProgramRow: View {
let program: WorkoutProgram
var body: some View {
HStack(spacing: 14) {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Theme.zoneGradient(program.bodyZone))
.frame(width: 56, height: 56)
.overlay {
Image(systemName: "bolt.fill")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(.white)
}
VStack(alignment: .leading, spacing: 4) {
Text(program.titleEn)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
HStack(spacing: 8) {
LevelBadge(level: program.level)
Label("\(program.estimatedDuration)m", systemImage: "clock")
.font(.caption)
.foregroundStyle(.secondary)
Label("\(program.estimatedCalories) kcal", systemImage: "flame")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
.padding(14)
.glassCard()
}
}
#Preview {
HomeTab(previewVM: HomeViewModel(previewPrograms: [PreviewData.sampleProgram]))
.modelContainer(TabataGoSchema.previewContainer)
.environment(AppState())
}

View File

@@ -0,0 +1,52 @@
import SwiftUI
/// Root tab bar Liquid Glass tab bar (iOS 26).
struct MainTabView: View {
@State private var selectedTab: AppTab = .home
enum AppTab: String, CaseIterable {
case home, programs, activity, profile
var icon: String {
switch self {
case .home: return "house.fill"
case .programs: return "rectangle.grid.2x2.fill"
case .activity: return "chart.bar.fill"
case .profile: return "person.fill"
}
}
var label: String {
switch self {
case .home: return "Home"
case .programs: return "Programs"
case .activity: return "Activity"
case .profile: return "Profile"
}
}
}
var body: some View {
TabView(selection: $selectedTab) {
Tab(AppTab.home.label, systemImage: AppTab.home.icon, value: AppTab.home) {
HomeTab()
}
Tab(AppTab.programs.label, systemImage: AppTab.programs.icon, value: AppTab.programs) {
ProgramsTab()
}
Tab(AppTab.activity.label, systemImage: AppTab.activity.icon, value: AppTab.activity) {
ActivityTab()
}
Tab(AppTab.profile.label, systemImage: AppTab.profile.icon, value: AppTab.profile) {
ProfileTab()
}
}
.tabViewStyle(.sidebarAdaptable)
}
}
#Preview {
MainTabView()
.modelContainer(TabataGoSchema.previewContainer)
.environment(AppState())
}

View File

@@ -0,0 +1,130 @@
import SwiftUI
import SwiftData
/// Profile tab user info, settings, subscription, saved workouts.
struct ProfileTab: View {
@Query private var profiles: [UserProfile]
@State private var showingSettings = false
@State private var showingPaywall = false
@Environment(\.modelContext) private var context
@StateObject private var purchaseVM = PurchaseViewModel()
private var profile: UserProfile? { profiles.first }
var body: some View {
NavigationStack {
List {
// Profile Header
Section {
HStack(spacing: 16) {
Circle()
.fill(Theme.brand.gradient)
.frame(width: 64, height: 64)
.overlay {
Text(String(profile?.name.prefix(1).uppercased() ?? "?"))
.font(.title2.weight(.bold))
.foregroundStyle(.white)
}
VStack(alignment: .leading, spacing: 4) {
Text(profile?.name ?? "Athlete")
.font(.title3.weight(.bold))
Text(profile?.goal.label ?? "")
.font(.subheadline)
.foregroundStyle(.secondary)
Text("Joined \(profile?.joinDate.formatted(date: .abbreviated, time: .omitted) ?? "")")
.font(.caption)
.foregroundStyle(.tertiary)
}
Spacer()
}
.padding(.vertical, 8)
}
// Subscription
Section("Subscription") {
if profile?.subscription.isPremium == true {
HStack {
Label("Premium Active", systemImage: "crown.fill")
.foregroundStyle(Theme.brand)
Spacer()
Text(profile?.subscription == .premiumYearly ? "Yearly" : "Monthly")
.font(.subheadline)
.foregroundStyle(.secondary)
}
} else {
Button {
showingPaywall = true
} label: {
HStack {
Label("Upgrade to Premium", systemImage: "crown")
.foregroundStyle(Theme.brand)
Spacer()
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
}
.buttonStyle(.plain)
}
}
// Fitness Profile
Section("Fitness Profile") {
ProfileRow(label: "Level", value: profile?.fitnessLevel.label ?? "", icon: "chart.bar")
ProfileRow(label: "Goal", value: profile?.goal.label ?? "", icon: "target")
ProfileRow(label: "Weekly Goal", value: "\(profile?.weeklyFrequency ?? 3)x / week", icon: "calendar")
}
// Settings
Section {
NavigationLink(destination: SettingsView()) {
Label("Settings", systemImage: "gearshape")
}
NavigationLink(destination: PrivacyPolicyView()) {
Label("Privacy Policy", systemImage: "hand.raised")
}
NavigationLink(destination: TermsOfServiceView()) {
Label("Terms of Service", systemImage: "doc.text")
}
}
// App Info
Section {
HStack {
Text("Version")
Spacer()
Text(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0")
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Profile")
.navigationBarTitleDisplayMode(.large)
.sheet(isPresented: $showingPaywall) {
PaywallView()
}
}
}
}
struct ProfileRow: View {
let label: String
let value: String
let icon: String
var body: some View {
HStack {
Label(label, systemImage: icon)
Spacer()
Text(value)
.foregroundStyle(.secondary)
}
}
}
#Preview {
ProfileTab()
.modelContainer(TabataGoSchema.previewContainer)
.environment(AppState())
}

View File

@@ -0,0 +1,134 @@
import SwiftUI
import SwiftData
/// Programs tab browse all workouts, filter by zone/level.
struct ProgramsTab: View {
@StateObject private var vm = HomeViewModel()
@State private var selectedZone: String? = nil
@State private var selectedLevel: String? = nil
@State private var selectedProgram: WorkoutProgram? = nil
@State private var searchText = ""
private var zones = ["upper", "lower", "full"]
private var levels = ["Beginner", "Intermediate", "Advanced"]
private var filtered: [WorkoutProgram] {
vm.allPrograms.filter { program in
let zoneMatch = selectedZone == nil || program.bodyZone == selectedZone
let levelMatch = selectedLevel == nil || program.level == selectedLevel
let searchMatch = searchText.isEmpty ||
program.titleEn.localizedCaseInsensitiveContains(searchText) ||
program.bodyZone.localizedCaseInsensitiveContains(searchText)
return zoneMatch && levelMatch && searchMatch
}
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 16) {
// Zone Filter
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
FilterChip(label: "All", isSelected: selectedZone == nil) {
selectedZone = nil
}
ForEach(zones, id: \.self) { zone in
FilterChip(
label: zone.capitalized == "Full" ? "Full Body" : zone.capitalized,
isSelected: selectedZone == zone,
color: Theme.zoneColor(zone)
) {
selectedZone = selectedZone == zone ? nil : zone
}
}
}
.padding(.horizontal)
}
// Level Filter
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
FilterChip(label: "All Levels", isSelected: selectedLevel == nil) {
selectedLevel = nil
}
ForEach(levels, id: \.self) { level in
FilterChip(
label: level,
isSelected: selectedLevel == level,
color: Theme.levelColor(level)
) {
selectedLevel = selectedLevel == level ? nil : level
}
}
}
.padding(.horizontal)
}
// Program Grid
if vm.isLoading {
ProgressView().frame(minHeight: 120)
} else if filtered.isEmpty {
ContentUnavailableView(
"No Programs Found",
systemImage: "magnifyingglass",
description: Text("Try changing your filters.")
)
.padding(.top, 40)
} else {
LazyVStack(spacing: 12) {
ForEach(filtered) { program in
ProgramRow(program: program)
.onTapGesture { selectedProgram = program }
.padding(.horizontal)
}
}
}
Spacer(minLength: 40)
}
.padding(.top, 8)
}
.navigationTitle("Programs")
.navigationBarTitleDisplayMode(.large)
.searchable(text: $searchText, prompt: "Search workouts...")
.task { await vm.loadPrograms() }
.refreshable { await vm.refresh() }
.sheet(item: $selectedProgram) { program in
ProgramDetailView(program: program)
}
}
}
}
struct FilterChip: View {
let label: String
let isSelected: Bool
var color: Color = .primary
let action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.subheadline.weight(isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? .white : .primary)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background {
if isSelected {
Capsule().fill(color == .primary ? Theme.brand : color)
} else {
Capsule().fill(.ultraThinMaterial)
}
}
}
.buttonStyle(.plain)
.animation(.spring(duration: 0.25), value: isSelected)
}
}
#Preview {
ProgramsTab()
.modelContainer(TabataGoSchema.previewContainer)
.environment(AppState())
}