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:
32
tabatago-swift/TabataGo/Models/HealthSnapshot.swift
Normal file
32
tabatago-swift/TabataGo/Models/HealthSnapshot.swift
Normal 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() {}
|
||||
}
|
||||
31
tabatago-swift/TabataGo/Models/MusicTrack.swift
Normal file
31
tabatago-swift/TabataGo/Models/MusicTrack.swift
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
111
tabatago-swift/TabataGo/Models/PreviewData.swift
Normal file
111
tabatago-swift/TabataGo/Models/PreviewData.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
41
tabatago-swift/TabataGo/Models/TabataGoSchema.swift
Normal file
41
tabatago-swift/TabataGo/Models/TabataGoSchema.swift
Normal 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
|
||||
}
|
||||
}
|
||||
123
tabatago-swift/TabataGo/Models/UserProfile.swift
Normal file
123
tabatago-swift/TabataGo/Models/UserProfile.swift
Normal 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() {}
|
||||
}
|
||||
89
tabatago-swift/TabataGo/Models/WorkoutProgram.swift
Normal file
89
tabatago-swift/TabataGo/Models/WorkoutProgram.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
60
tabatago-swift/TabataGo/Models/WorkoutSession.swift
Normal file
60
tabatago-swift/TabataGo/Models/WorkoutSession.swift
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user