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:
4
tabatago-swift/Config/Secrets.xcconfig
Normal file
4
tabatago-swift/Config/Secrets.xcconfig
Normal file
@@ -0,0 +1,4 @@
|
||||
SUPABASE_URL = https:/$()/supabase.1000co.fr
|
||||
SUPABASE_ANON_KEY = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzcyMjMzMjAwLCJleHAiOjE5Mjk5OTk2MDB9.SlYN046eGvUSObW0tFQHcMRUqFvtMqBLfFRlZliSx_w
|
||||
REVENUECAT_API_KEY = test_oIJbIHWISJaUZdgxRMHlwizBHvM
|
||||
POSTHOG_API_KEY =
|
||||
8
tabatago-swift/Config/Secrets.xcconfig.example
Normal file
8
tabatago-swift/Config/Secrets.xcconfig.example
Normal file
@@ -0,0 +1,8 @@
|
||||
// Secrets.xcconfig.example
|
||||
// Copy this file to Secrets.xcconfig and fill in your values.
|
||||
// Secrets.xcconfig is gitignored — never commit the real file.
|
||||
|
||||
SUPABASE_URL = https://your-project.supabase.co
|
||||
SUPABASE_ANON_KEY = your-anon-key-here
|
||||
REVENUECAT_API_KEY = your-revenuecat-key-here
|
||||
POSTHOG_API_KEY = your-posthog-key-here
|
||||
1159
tabatago-swift/TabataGo.xcodeproj/project.pbxproj
Normal file
1159
tabatago-swift/TabataGo.xcodeproj/project.pbxproj
Normal file
File diff suppressed because it is too large
Load Diff
7
tabatago-swift/TabataGo.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
tabatago-swift/TabataGo.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"originHash" : "42d1f35b4500c2779457daf99841f2333a14d9a2965835305d89fd95beda836e",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "plcrashreporter",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/microsoft/plcrashreporter.git",
|
||||
"state" : {
|
||||
"revision" : "0254f941c646b1ed17b243654723d0f071e990d0",
|
||||
"version" : "1.12.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "posthog-ios",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/PostHog/posthog-ios",
|
||||
"state" : {
|
||||
"revision" : "c3efdae383a5e7a5a88c34fd774e9d7dc915b9d4",
|
||||
"version" : "3.55.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "purchases-ios",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/RevenueCat/purchases-ios",
|
||||
"state" : {
|
||||
"revision" : "bd63241b2258ea519020eb32a349db44fb44b119",
|
||||
"version" : "5.68.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "supabase-swift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/supabase/supabase-swift",
|
||||
"state" : {
|
||||
"revision" : "17261e93c60aa721e3c17312bfeb2ae6de3d6f8a",
|
||||
"version" : "2.43.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-asn1",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-asn1.git",
|
||||
"state" : {
|
||||
"revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab",
|
||||
"version" : "1.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-clocks",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-clocks",
|
||||
"state" : {
|
||||
"revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
|
||||
"version" : "1.0.6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-concurrency-extras",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
|
||||
"state" : {
|
||||
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
|
||||
"version" : "1.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-crypto",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-crypto.git",
|
||||
"state" : {
|
||||
"revision" : "476538ccb827f2dd18efc5de754cc87d77127a47",
|
||||
"version" : "4.4.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-http-types",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-http-types.git",
|
||||
"state" : {
|
||||
"revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca",
|
||||
"version" : "1.5.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "xctest-dynamic-overlay",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
|
||||
"state" : {
|
||||
"revision" : "dfd70507def84cb5fb821278448a262c6ff2bbad",
|
||||
"version" : "1.9.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,24 @@
|
||||
<?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>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>TabataGo.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>2</integer>
|
||||
</dict>
|
||||
<key>TabataGoWatch.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
<key>TabataGoWatchWidget.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>4</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
18
tabatago-swift/TabataGo/App/AppState.swift
Normal file
18
tabatago-swift/TabataGo/App/AppState.swift
Normal 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
|
||||
}
|
||||
}
|
||||
21
tabatago-swift/TabataGo/App/RootView.swift
Normal file
21
tabatago-swift/TabataGo/App/RootView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
19
tabatago-swift/TabataGo/App/TabataGoApp.swift
Normal file
19
tabatago-swift/TabataGo/App/TabataGoApp.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
49
tabatago-swift/TabataGo/Resources/Info.plist
Normal file
49
tabatago-swift/TabataGo/Resources/Info.plist
Normal 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>
|
||||
2736
tabatago-swift/TabataGo/Resources/Localizable.xcstrings
Normal file
2736
tabatago-swift/TabataGo/Resources/Localizable.xcstrings
Normal file
File diff suppressed because it is too large
Load Diff
16
tabatago-swift/TabataGo/Resources/TabataGo.entitlements
Normal file
16
tabatago-swift/TabataGo/Resources/TabataGo.entitlements
Normal 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>
|
||||
105
tabatago-swift/TabataGo/Services/AnalyticsService.swift
Normal file
105
tabatago-swift/TabataGo/Services/AnalyticsService.swift
Normal file
@@ -0,0 +1,105 @@
|
||||
import Foundation
|
||||
import PostHog
|
||||
|
||||
/// PostHog analytics — mirrors the event taxonomy from the Expo app.
|
||||
final class AnalyticsService: @unchecked Sendable {
|
||||
|
||||
static let shared = AnalyticsService()
|
||||
|
||||
private let apiKey: String =
|
||||
Bundle.main.infoDictionary?["POSTHOG_API_KEY"] as? String ?? ""
|
||||
|
||||
private let host = "https://eu.posthog.com"
|
||||
|
||||
private init() {}
|
||||
|
||||
func initialize() {
|
||||
guard !apiKey.isEmpty else { return }
|
||||
let config = PostHogConfig(apiKey: apiKey, host: host)
|
||||
config.captureApplicationLifecycleEvents = true
|
||||
config.captureScreenViews = false // manual tracking
|
||||
PostHogSDK.shared.setup(config)
|
||||
}
|
||||
|
||||
// ─── Screens ────────────────────────────────────────────────
|
||||
|
||||
func screen(_ name: String, properties: [String: Any] = [:]) {
|
||||
PostHogSDK.shared.screen(name, properties: properties)
|
||||
}
|
||||
|
||||
// ─── Onboarding ─────────────────────────────────────────────
|
||||
|
||||
func onboardingCompleted(name: String, level: String, goal: String, frequency: Int) {
|
||||
capture("onboarding_completed", properties: [
|
||||
"fitness_level": level,
|
||||
"goal": goal,
|
||||
"weekly_frequency": frequency,
|
||||
])
|
||||
}
|
||||
|
||||
// ─── Workouts ────────────────────────────────────────────────
|
||||
|
||||
func workoutStarted(programId: String, programTitle: String, bodyZone: String, level: String) {
|
||||
capture("workout_started", properties: [
|
||||
"program_id": programId,
|
||||
"program_title": programTitle,
|
||||
"body_zone": bodyZone,
|
||||
"level": level,
|
||||
])
|
||||
}
|
||||
|
||||
func workoutCompleted(
|
||||
programId: String,
|
||||
durationSeconds: Int,
|
||||
calories: Double,
|
||||
completionRate: Double,
|
||||
healthKitSaved: Bool
|
||||
) {
|
||||
capture("workout_completed", properties: [
|
||||
"program_id": programId,
|
||||
"duration_seconds": durationSeconds,
|
||||
"calories": calories,
|
||||
"completion_rate": completionRate,
|
||||
"healthkit_saved": healthKitSaved,
|
||||
])
|
||||
}
|
||||
|
||||
func workoutAbandoned(programId: String, atRound: Int, totalRounds: Int) {
|
||||
capture("workout_abandoned", properties: [
|
||||
"program_id": programId,
|
||||
"at_round": atRound,
|
||||
"total_rounds": totalRounds,
|
||||
"completion_rate": Double(atRound) / Double(max(totalRounds, 1)),
|
||||
])
|
||||
}
|
||||
|
||||
// ─── Paywall ─────────────────────────────────────────────────
|
||||
|
||||
func paywallViewed(source: String) {
|
||||
capture("paywall_viewed", properties: ["source": source])
|
||||
}
|
||||
|
||||
func subscriptionStarted(plan: String) {
|
||||
capture("subscription_started", properties: ["plan": plan])
|
||||
}
|
||||
|
||||
func subscriptionRestored() {
|
||||
capture("subscription_restored")
|
||||
}
|
||||
|
||||
// ─── HealthKit ───────────────────────────────────────────────
|
||||
|
||||
func healthKitPermissionGranted() {
|
||||
capture("healthkit_permission_granted")
|
||||
}
|
||||
|
||||
func healthKitPermissionDenied() {
|
||||
capture("healthkit_permission_denied")
|
||||
}
|
||||
|
||||
// ─── Private ─────────────────────────────────────────────────
|
||||
|
||||
private func capture(_ event: String, properties: [String: Any] = [:]) {
|
||||
PostHogSDK.shared.capture(event, properties: properties.isEmpty ? nil : properties)
|
||||
}
|
||||
}
|
||||
147
tabatago-swift/TabataGo/Services/AudioService.swift
Normal file
147
tabatago-swift/TabataGo/Services/AudioService.swift
Normal file
@@ -0,0 +1,147 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
/// Manages all workout audio: timer beeps, phase cues, voice coaching, music.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class AudioService {
|
||||
|
||||
static let shared = AudioService()
|
||||
|
||||
var isMusicEnabled = true
|
||||
var musicVolume: Float = 0.5
|
||||
var isVoiceCoachingEnabled = true
|
||||
var isSoundEffectsEnabled = true
|
||||
|
||||
private var audioSession: AVAudioSession { AVAudioSession.sharedInstance() }
|
||||
private var beepPlayer: AVAudioPlayer?
|
||||
private var speechSynthesizer = AVSpeechSynthesizer()
|
||||
|
||||
private init() {
|
||||
configureSession()
|
||||
}
|
||||
|
||||
// ─── Session Setup ────────────────────────────────────────────
|
||||
|
||||
private func configureSession() {
|
||||
do {
|
||||
try audioSession.setCategory(
|
||||
.playback,
|
||||
mode: .default,
|
||||
options: [.mixWithOthers, .allowAirPlay, .allowBluetooth]
|
||||
)
|
||||
try audioSession.setActive(true)
|
||||
} catch {
|
||||
print("[Audio] Session configuration failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phase Audio Cues ─────────────────────────────────────────
|
||||
|
||||
func playPhaseStart(_ phase: TimerPhase) {
|
||||
guard isSoundEffectsEnabled else { return }
|
||||
switch phase {
|
||||
case .warmup: playTone(frequency: 523, duration: 0.15, count: 2)
|
||||
case .work: playTone(frequency: 880, duration: 0.2, count: 3)
|
||||
case .rest: playTone(frequency: 440, duration: 0.3, count: 1)
|
||||
case .interBlockRest: playTone(frequency: 392, duration: 0.4, count: 2)
|
||||
case .cooldown: playTone(frequency: 660, duration: 0.2, count: 2)
|
||||
case .complete: playTone(frequency: 880, duration: 0.15, count: 5)
|
||||
case .prep: break
|
||||
}
|
||||
}
|
||||
|
||||
func playCountdown(secondsLeft: Int) {
|
||||
guard isSoundEffectsEnabled, secondsLeft <= 3, secondsLeft > 0 else { return }
|
||||
playTone(frequency: secondsLeft == 1 ? 880 : 660, duration: 0.08, count: 1)
|
||||
}
|
||||
|
||||
// ─── Voice Coaching ───────────────────────────────────────────
|
||||
|
||||
func speak(_ text: String, locale: String = "en-US") {
|
||||
guard isVoiceCoachingEnabled else { return }
|
||||
speechSynthesizer.stopSpeaking(at: .immediate)
|
||||
let utterance = AVSpeechUtterance(string: text)
|
||||
utterance.voice = AVSpeechSynthesisVoice(language: locale)
|
||||
utterance.rate = 0.52
|
||||
utterance.pitchMultiplier = 1.0
|
||||
utterance.volume = 0.9
|
||||
speechSynthesizer.speak(utterance)
|
||||
}
|
||||
|
||||
func announceExercise(_ exercise: TabataExercise) {
|
||||
speak(exercise.nameEn)
|
||||
}
|
||||
|
||||
func announcePhase(_ phase: TimerPhase) {
|
||||
guard isVoiceCoachingEnabled else { return }
|
||||
let text: String
|
||||
switch phase {
|
||||
case .prep: text = "Get ready"
|
||||
case .warmup: text = "Warm up"
|
||||
case .work: text = "Work"
|
||||
case .rest: text = "Rest"
|
||||
case .interBlockRest: text = "Take a break"
|
||||
case .cooldown: text = "Cool down"
|
||||
case .complete: text = "Workout complete. Great job!"
|
||||
}
|
||||
speak(text)
|
||||
}
|
||||
|
||||
// ─── Programmatic Tone Generation ────────────────────────────
|
||||
|
||||
private func playTone(frequency: Double, duration: Double, count: Int) {
|
||||
guard let url = generateToneURL(frequency: frequency, duration: duration) else { return }
|
||||
Task {
|
||||
for i in 0..<count {
|
||||
if i > 0 { try? await Task.sleep(for: .milliseconds(Int(duration * 1000) + 50)) }
|
||||
try? beepPlayer = AVAudioPlayer(contentsOf: url)
|
||||
beepPlayer?.volume = 0.8
|
||||
beepPlayer?.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func generateToneURL(frequency: Double, duration: Double) -> URL? {
|
||||
let sampleRate = 44100.0
|
||||
let numSamples = Int(sampleRate * duration)
|
||||
var samples = [Int16](repeating: 0, count: numSamples)
|
||||
let fadeFrames = Int(sampleRate * 0.005) // 5ms fade
|
||||
|
||||
for i in 0..<numSamples {
|
||||
let angle = 2.0 * Double.pi * frequency * Double(i) / sampleRate
|
||||
var amplitude = 0.5
|
||||
if i < fadeFrames { amplitude *= Double(i) / Double(fadeFrames) }
|
||||
if i > numSamples - fadeFrames { amplitude *= Double(numSamples - i) / Double(fadeFrames) }
|
||||
samples[i] = Int16(amplitude * Double(Int16.max) * sin(angle))
|
||||
}
|
||||
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("tabata_tone_\(Int(frequency)).wav")
|
||||
|
||||
let headerSize = 44
|
||||
var data = Data(count: headerSize + numSamples * 2)
|
||||
data.withUnsafeMutableBytes { ptr in
|
||||
let raw = ptr.baseAddress!
|
||||
// RIFF header
|
||||
"RIFF".utf8.enumerated().forEach { raw.storeBytes(of: $0.element, toByteOffset: $0.offset, as: UInt8.self) }
|
||||
let chunkSize = UInt32(36 + numSamples * 2).littleEndian
|
||||
withUnsafeBytes(of: chunkSize) { raw.advanced(by: 4).copyMemory(from: $0.baseAddress!, byteCount: 4) }
|
||||
"WAVE".utf8.enumerated().forEach { raw.storeBytes(of: $0.element, toByteOffset: 8 + $0.offset, as: UInt8.self) }
|
||||
"fmt ".utf8.enumerated().forEach { raw.storeBytes(of: $0.element, toByteOffset: 12 + $0.offset, as: UInt8.self) }
|
||||
let fmtSize = UInt32(16).littleEndian; withUnsafeBytes(of: fmtSize) { raw.advanced(by: 16).copyMemory(from: $0.baseAddress!, byteCount: 4) }
|
||||
let audioFmt = UInt16(1).littleEndian; withUnsafeBytes(of: audioFmt) { raw.advanced(by: 20).copyMemory(from: $0.baseAddress!, byteCount: 2) }
|
||||
let channels = UInt16(1).littleEndian; withUnsafeBytes(of: channels) { raw.advanced(by: 22).copyMemory(from: $0.baseAddress!, byteCount: 2) }
|
||||
let sr = UInt32(sampleRate).littleEndian; withUnsafeBytes(of: sr) { raw.advanced(by: 24).copyMemory(from: $0.baseAddress!, byteCount: 4) }
|
||||
let byteRate = UInt32(sampleRate * 2).littleEndian; withUnsafeBytes(of: byteRate) { raw.advanced(by: 28).copyMemory(from: $0.baseAddress!, byteCount: 4) }
|
||||
let blockAlign = UInt16(2).littleEndian; withUnsafeBytes(of: blockAlign) { raw.advanced(by: 32).copyMemory(from: $0.baseAddress!, byteCount: 2) }
|
||||
let bitsPerSample = UInt16(16).littleEndian; withUnsafeBytes(of: bitsPerSample) { raw.advanced(by: 34).copyMemory(from: $0.baseAddress!, byteCount: 2) }
|
||||
"data".utf8.enumerated().forEach { raw.storeBytes(of: $0.element, toByteOffset: 36 + $0.offset, as: UInt8.self) }
|
||||
let dataSize = UInt32(numSamples * 2).littleEndian; withUnsafeBytes(of: dataSize) { raw.advanced(by: 40).copyMemory(from: $0.baseAddress!, byteCount: 4) }
|
||||
samples.withUnsafeBytes { raw.advanced(by: 44).copyMemory(from: $0.baseAddress!, byteCount: numSamples * 2) }
|
||||
}
|
||||
|
||||
try? data.write(to: url)
|
||||
return url
|
||||
}
|
||||
}
|
||||
380
tabatago-swift/TabataGo/Services/HealthKitService.swift
Normal file
380
tabatago-swift/TabataGo/Services/HealthKitService.swift
Normal file
@@ -0,0 +1,380 @@
|
||||
import Foundation
|
||||
import HealthKit
|
||||
|
||||
// NSPredicate (returned by HKQuery.predicateForSamples) is not Sendable,
|
||||
// but it is documented as thread-safe. Suppress the check globally here.
|
||||
extension NSPredicate: @unchecked Sendable {}
|
||||
|
||||
// ─── Shared HealthKit store ───────────────────────────────────────
|
||||
|
||||
/// Full HealthKit integration: read rings/HR/weight, write workouts, live session.
|
||||
actor HealthKitService {
|
||||
|
||||
static let shared = HealthKitService()
|
||||
private let store = HKHealthStore()
|
||||
|
||||
// ─── Permission types ────────────────────────────────────────
|
||||
|
||||
private var writeTypes: Set<HKSampleType> {
|
||||
[
|
||||
HKWorkoutType.workoutType(),
|
||||
HKQuantityType(.activeEnergyBurned),
|
||||
HKQuantityType(.heartRate),
|
||||
]
|
||||
}
|
||||
|
||||
private var readTypes: Set<HKObjectType> {
|
||||
[
|
||||
HKWorkoutType.workoutType(),
|
||||
HKQuantityType(.activeEnergyBurned),
|
||||
HKQuantityType(.restingHeartRate),
|
||||
HKQuantityType(.heartRate),
|
||||
HKQuantityType(.bodyMass),
|
||||
HKQuantityType(.vo2Max),
|
||||
HKQuantityType(.appleExerciseTime),
|
||||
HKQuantityType(.appleStandTime),
|
||||
HKCategoryType(.appleStandHour),
|
||||
HKActivitySummaryType.activitySummaryType(),
|
||||
]
|
||||
}
|
||||
|
||||
// ─── Authorization ────────────────────────────────────────────
|
||||
|
||||
nonisolated var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() }
|
||||
|
||||
func requestAuthorization() async throws {
|
||||
guard isAvailable else { return }
|
||||
try await store.requestAuthorization(toShare: writeTypes, read: readTypes)
|
||||
}
|
||||
|
||||
var isAuthorized: Bool {
|
||||
get async {
|
||||
guard isAvailable else { return false }
|
||||
let status = store.authorizationStatus(for: HKWorkoutType.workoutType())
|
||||
return status == .sharingAuthorized
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Save a completed workout ─────────────────────────────────
|
||||
|
||||
/// Plain Sendable snapshot of a WorkoutSession — safe to cross actor boundaries.
|
||||
struct WorkoutSaveData: Sendable {
|
||||
let startedAt: Date
|
||||
let completedAt: Date
|
||||
let caloriesBurned: Double
|
||||
let averageHeartRate: Double?
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func saveWorkout(_ data: WorkoutSaveData) async throws -> HKWorkout {
|
||||
let config = HKWorkoutConfiguration()
|
||||
config.activityType = .highIntensityIntervalTraining
|
||||
config.locationType = .indoor
|
||||
|
||||
let builder = HKWorkoutBuilder(healthStore: store, configuration: config, device: .local())
|
||||
try await builder.beginCollection(at: data.startedAt)
|
||||
|
||||
// Active energy samples
|
||||
if data.caloriesBurned > 0 {
|
||||
let energyType = HKQuantityType(.activeEnergyBurned)
|
||||
let energy = HKQuantity(unit: .kilocalorie(), doubleValue: data.caloriesBurned)
|
||||
let sample = HKQuantitySample(
|
||||
type: energyType,
|
||||
quantity: energy,
|
||||
start: data.startedAt,
|
||||
end: data.completedAt
|
||||
)
|
||||
try await builder.addSamples([sample])
|
||||
}
|
||||
|
||||
// Heart rate samples (if captured during workout)
|
||||
if let avgHR = data.averageHeartRate {
|
||||
let hrType = HKQuantityType(.heartRate)
|
||||
let hrUnit = HKUnit.count().unitDivided(by: .minute())
|
||||
let hrQuantity = HKQuantity(unit: hrUnit, doubleValue: avgHR)
|
||||
let hrSample = HKQuantitySample(
|
||||
type: hrType,
|
||||
quantity: hrQuantity,
|
||||
start: data.startedAt,
|
||||
end: data.completedAt
|
||||
)
|
||||
try await builder.addSamples([hrSample])
|
||||
}
|
||||
|
||||
try await builder.endCollection(at: data.completedAt)
|
||||
guard let workout = try await builder.finishWorkout() else {
|
||||
throw HealthKitError.workoutSaveFailed
|
||||
}
|
||||
return workout
|
||||
}
|
||||
|
||||
// ─── Read: Health Snapshot ─────────────────────────────────────
|
||||
|
||||
func fetchSnapshot() async throws -> HealthSnapshot {
|
||||
let snapshot = HealthSnapshot()
|
||||
snapshot.fetchedAt = Date()
|
||||
|
||||
async let activeCalories = fetchTodayQuantity(type: .activeEnergyBurned, unit: .kilocalorie())
|
||||
async let exerciseMinutes = fetchTodayQuantity(type: .appleExerciseTime, unit: .minute())
|
||||
async let restingHR = fetchMostRecent(type: .restingHeartRate, unit: HKUnit.count().unitDivided(by: .minute()))
|
||||
async let bodyMass = fetchMostRecent(type: .bodyMass, unit: .gramUnit(with: .kilo))
|
||||
async let vo2Max = fetchMostRecent(type: .vo2Max, unit: HKUnit(from: "ml/kg·min"))
|
||||
async let standHours = fetchTodayStandHours()
|
||||
async let weekly = fetchWeeklySummary()
|
||||
|
||||
snapshot.activeCaloricBurn = (try? await activeCalories) ?? 0
|
||||
snapshot.exerciseMinutes = (try? await exerciseMinutes) ?? 0
|
||||
snapshot.restingHeartRate = try? await restingHR
|
||||
snapshot.bodyMassKg = try? await bodyMass
|
||||
snapshot.vo2Max = try? await vo2Max
|
||||
snapshot.standHours = (try? await standHours) ?? 0
|
||||
|
||||
let weeklySummary = try? await weekly
|
||||
snapshot.weeklyActiveCalories = weeklySummary?.calories ?? 0
|
||||
snapshot.weeklyExerciseMinutes = weeklySummary?.exerciseMinutes ?? 0
|
||||
snapshot.weeklyWorkoutCount = weeklySummary?.workoutCount ?? 0
|
||||
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// ─── Private helpers ──────────────────────────────────────────
|
||||
|
||||
private func fetchTodayQuantity(type identifier: HKQuantityTypeIdentifier, unit: HKUnit) async throws -> Double {
|
||||
let quantityType = HKQuantityType(identifier)
|
||||
let now = Date()
|
||||
let startOfDay = Calendar.current.startOfDay(for: now)
|
||||
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now)
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let query = HKStatisticsQuery(
|
||||
quantityType: quantityType,
|
||||
quantitySamplePredicate: predicate,
|
||||
options: .cumulativeSum
|
||||
) { _, result, error in
|
||||
if let error { continuation.resume(throwing: error); return }
|
||||
let value = result?.sumQuantity()?.doubleValue(for: unit) ?? 0
|
||||
continuation.resume(returning: value)
|
||||
}
|
||||
store.execute(query)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchMostRecent(type identifier: HKQuantityTypeIdentifier, unit: HKUnit) async throws -> Double? {
|
||||
let quantityType = HKQuantityType(identifier)
|
||||
let predicate = HKQuery.predicateForSamples(withStart: .distantPast, end: Date())
|
||||
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let query = HKSampleQuery(
|
||||
sampleType: quantityType,
|
||||
predicate: predicate,
|
||||
limit: 1,
|
||||
sortDescriptors: [sortDescriptor]
|
||||
) { _, samples, error in
|
||||
if let error { continuation.resume(throwing: error); return }
|
||||
guard let sample = samples?.first as? HKQuantitySample else {
|
||||
continuation.resume(returning: nil); return
|
||||
}
|
||||
continuation.resume(returning: sample.quantity.doubleValue(for: unit))
|
||||
}
|
||||
store.execute(query)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchTodayStandHours() async throws -> Int {
|
||||
let standType = HKCategoryType(.appleStandHour)
|
||||
let now = Date()
|
||||
let startOfDay = Calendar.current.startOfDay(for: now)
|
||||
let predicate = HKQuery.predicateForSamples(withStart: startOfDay, end: now)
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let query = HKSampleQuery(
|
||||
sampleType: standType,
|
||||
predicate: predicate,
|
||||
limit: HKObjectQueryNoLimit,
|
||||
sortDescriptors: nil
|
||||
) { _, samples, error in
|
||||
if let error { continuation.resume(throwing: error); return }
|
||||
let stood = samples?.compactMap { $0 as? HKCategorySample }
|
||||
.filter { $0.value == HKCategoryValueAppleStandHour.stood.rawValue }
|
||||
.count ?? 0
|
||||
continuation.resume(returning: stood)
|
||||
}
|
||||
store.execute(query)
|
||||
}
|
||||
}
|
||||
|
||||
struct WeeklySummary {
|
||||
var calories: Double
|
||||
var exerciseMinutes: Double
|
||||
var workoutCount: Int
|
||||
}
|
||||
|
||||
private func fetchWeeklySummary() async throws -> WeeklySummary {
|
||||
let now = Date()
|
||||
let weekAgo = Calendar.current.date(byAdding: .day, value: -7, to: now)!
|
||||
// Capture Sendable Date values; create NSPredicate inside each closure
|
||||
// to avoid sending non-Sendable NSPredicate across actor boundaries.
|
||||
let start = weekAgo
|
||||
let end = now
|
||||
|
||||
async let calories = withCheckedThrowingContinuation { (c: CheckedContinuation<Double, Error>) in
|
||||
let query = HKStatisticsQuery(
|
||||
quantityType: HKQuantityType(.activeEnergyBurned),
|
||||
quantitySamplePredicate: HKQuery.predicateForSamples(withStart: start, end: end),
|
||||
options: .cumulativeSum
|
||||
) { _, result, error in
|
||||
if let error { c.resume(throwing: error); return }
|
||||
c.resume(returning: result?.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0)
|
||||
}
|
||||
store.execute(query)
|
||||
}
|
||||
|
||||
async let exerciseMinutes = withCheckedThrowingContinuation { (c: CheckedContinuation<Double, Error>) in
|
||||
let query = HKStatisticsQuery(
|
||||
quantityType: HKQuantityType(.appleExerciseTime),
|
||||
quantitySamplePredicate: HKQuery.predicateForSamples(withStart: start, end: end),
|
||||
options: .cumulativeSum
|
||||
) { _, result, error in
|
||||
if let error { c.resume(throwing: error); return }
|
||||
c.resume(returning: result?.sumQuantity()?.doubleValue(for: .minute()) ?? 0)
|
||||
}
|
||||
store.execute(query)
|
||||
}
|
||||
|
||||
async let workoutCount = withCheckedThrowingContinuation { (c: CheckedContinuation<Int, Error>) in
|
||||
let query = HKSampleQuery(
|
||||
sampleType: HKWorkoutType.workoutType(),
|
||||
predicate: HKQuery.predicateForSamples(withStart: start, end: end),
|
||||
limit: HKObjectQueryNoLimit,
|
||||
sortDescriptors: nil
|
||||
) { _, samples, error in
|
||||
if let error { c.resume(throwing: error); return }
|
||||
c.resume(returning: samples?.count ?? 0)
|
||||
}
|
||||
store.execute(query)
|
||||
}
|
||||
|
||||
return try await WeeklySummary(
|
||||
calories: calories,
|
||||
exerciseMinutes: exerciseMinutes,
|
||||
workoutCount: workoutCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Live workout session ─────────────────────────────────────────
|
||||
|
||||
/// Manages a live HKWorkoutSession during an active Tabata workout.
|
||||
/// Provides real-time heart rate and calorie updates.
|
||||
@Observable
|
||||
final class LiveWorkoutSession: NSObject, HKWorkoutSessionDelegate, HKLiveWorkoutBuilderDelegate, @unchecked Sendable {
|
||||
|
||||
private(set) var heartRate: Double = 0
|
||||
private(set) var activeCalories: Double = 0
|
||||
private(set) var isActive = false
|
||||
|
||||
private var workoutSession: HKWorkoutSession?
|
||||
private var builder: HKLiveWorkoutBuilder?
|
||||
private let store = HKHealthStore()
|
||||
|
||||
var onHeartRateUpdate: ((Double) -> Void)?
|
||||
var onCaloriesUpdate: ((Double) -> Void)?
|
||||
|
||||
func start(startDate: Date) async throws {
|
||||
let config = HKWorkoutConfiguration()
|
||||
config.activityType = .highIntensityIntervalTraining
|
||||
config.locationType = .indoor
|
||||
|
||||
workoutSession = try HKWorkoutSession(healthStore: store, configuration: config)
|
||||
builder = workoutSession?.associatedWorkoutBuilder()
|
||||
builder?.dataSource = HKLiveWorkoutDataSource(healthStore: store, workoutConfiguration: config)
|
||||
|
||||
workoutSession?.delegate = self
|
||||
builder?.delegate = self
|
||||
|
||||
workoutSession?.startActivity(with: startDate)
|
||||
try await builder?.beginCollection(at: startDate)
|
||||
isActive = true
|
||||
}
|
||||
|
||||
func pause() {
|
||||
workoutSession?.pause()
|
||||
}
|
||||
|
||||
func resume() {
|
||||
workoutSession?.resume()
|
||||
}
|
||||
|
||||
func end() async throws -> (calories: Double, avgHeartRate: Double?) {
|
||||
guard let session = workoutSession, let builder = builder else {
|
||||
return (activeCalories, heartRate > 0 ? heartRate : nil)
|
||||
}
|
||||
session.end()
|
||||
try await builder.endCollection(at: Date())
|
||||
_ = try await builder.finishWorkout()
|
||||
isActive = false
|
||||
return (activeCalories, heartRate > 0 ? heartRate : nil)
|
||||
}
|
||||
|
||||
// ─── HKWorkoutSessionDelegate ─────────────────────────────────
|
||||
|
||||
nonisolated func workoutSession(
|
||||
_ workoutSession: HKWorkoutSession,
|
||||
didChangeTo toState: HKWorkoutSessionState,
|
||||
from fromState: HKWorkoutSessionState,
|
||||
date: Date
|
||||
) {}
|
||||
|
||||
nonisolated func workoutSession(
|
||||
_ workoutSession: HKWorkoutSession,
|
||||
didFailWithError error: Error
|
||||
) {
|
||||
print("[LiveWorkout] Session error: \(error)")
|
||||
}
|
||||
|
||||
// ─── HKLiveWorkoutBuilderDelegate ────────────────────────────
|
||||
|
||||
nonisolated func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
|
||||
|
||||
nonisolated func workoutBuilder(
|
||||
_ workoutBuilder: HKLiveWorkoutBuilder,
|
||||
didCollectDataOf collectedTypes: Set<HKSampleType>
|
||||
) {
|
||||
for type in collectedTypes {
|
||||
guard let quantityType = type as? HKQuantityType else { continue }
|
||||
let stats = workoutBuilder.statistics(for: quantityType)
|
||||
|
||||
if quantityType == HKQuantityType(.heartRate) {
|
||||
let hr = stats?.mostRecentQuantity()?.doubleValue(for: HKUnit.count().unitDivided(by: .minute())) ?? 0
|
||||
Task { @MainActor in
|
||||
self.heartRate = hr
|
||||
self.onHeartRateUpdate?(hr)
|
||||
}
|
||||
} else if quantityType == HKQuantityType(.activeEnergyBurned) {
|
||||
let cal = stats?.sumQuantity()?.doubleValue(for: .kilocalorie()) ?? 0
|
||||
Task { @MainActor in
|
||||
self.activeCalories = cal
|
||||
self.onCaloriesUpdate?(cal)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ─── Errors ───────────────────────────────────────────────────────
|
||||
|
||||
enum HealthKitError: LocalizedError {
|
||||
case notAvailable
|
||||
case notAuthorized
|
||||
case workoutSaveFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notAvailable: "HealthKit is not available on this device."
|
||||
case .notAuthorized: "HealthKit access was not granted."
|
||||
case .workoutSaveFailed: "Failed to save workout to Health."
|
||||
}
|
||||
}
|
||||
}
|
||||
165
tabatago-swift/TabataGo/Services/MusicService.swift
Normal file
165
tabatago-swift/TabataGo/Services/MusicService.swift
Normal file
@@ -0,0 +1,165 @@
|
||||
import Foundation
|
||||
import Supabase
|
||||
|
||||
/// Fetches music tracks from Supabase `download_items` table, with mock fallback.
|
||||
actor MusicService {
|
||||
|
||||
static let shared = MusicService()
|
||||
|
||||
private var cache: [MusicVibe: [MusicTrack]] = [:]
|
||||
|
||||
// ─── Public API ──────────────────────────────────────────────
|
||||
|
||||
/// Load tracks for a given vibe. Returns cached results on subsequent calls.
|
||||
func loadTracks(for vibe: MusicVibe) async -> [MusicTrack] {
|
||||
if let cached = cache[vibe] { return cached }
|
||||
|
||||
// Try Supabase first
|
||||
if let tracks = await fetchFromSupabase(vibe: vibe), !tracks.isEmpty {
|
||||
cache[vibe] = tracks
|
||||
return tracks
|
||||
}
|
||||
|
||||
// Fallback to mock tracks
|
||||
let mocks = Self.mockTracks(for: vibe)
|
||||
cache[vibe] = mocks
|
||||
return mocks
|
||||
}
|
||||
|
||||
/// Return `count` distinct random tracks for a vibe.
|
||||
func randomTracks(for vibe: MusicVibe, count: Int = 3) async -> [MusicTrack] {
|
||||
let pool = await loadTracks(for: vibe)
|
||||
guard !pool.isEmpty else { return [] }
|
||||
if pool.count <= count {
|
||||
return pool.shuffled()
|
||||
}
|
||||
return Array(pool.shuffled().prefix(count))
|
||||
}
|
||||
|
||||
/// Next track after `currentId`, cycling through the list.
|
||||
func nextTrack(in tracks: [MusicTrack], after currentId: String) -> MusicTrack? {
|
||||
guard tracks.count > 1 else { return tracks.first }
|
||||
let idx = tracks.firstIndex(where: { $0.id == currentId }) ?? 0
|
||||
return tracks[(idx + 1) % tracks.count]
|
||||
}
|
||||
|
||||
func clearCache(for vibe: MusicVibe? = nil) {
|
||||
if let vibe { cache.removeValue(forKey: vibe) } else { cache.removeAll() }
|
||||
}
|
||||
|
||||
// ─── Supabase Fetch ──────────────────────────────────────────
|
||||
|
||||
private func fetchFromSupabase(vibe: MusicVibe) async -> [MusicTrack]? {
|
||||
// Re-use the existing SupabaseService's client configuration.
|
||||
// We build our own client here because SupabaseService is an actor
|
||||
// and doesn't expose the raw client.
|
||||
guard !AppEnvironment.isPreview else { return nil }
|
||||
|
||||
let urlRaw = Bundle.main.infoDictionary?["SUPABASE_URL"] as? String ?? ""
|
||||
let key = Bundle.main.infoDictionary?["SUPABASE_ANON_KEY"] as? String ?? ""
|
||||
guard !urlRaw.isEmpty, urlRaw != "https://localhost", !key.isEmpty,
|
||||
let url = URL(string: urlRaw) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let client = SupabaseClient(supabaseURL: url, supabaseKey: key)
|
||||
|
||||
do {
|
||||
let rows: [DownloadItemRow] = try await client
|
||||
.from("download_items")
|
||||
.select("id, video_id, title, duration_seconds, public_url, storage_path, genre")
|
||||
.eq("status", value: "completed")
|
||||
.in("genre", values: vibe.genres)
|
||||
.limit(50)
|
||||
.execute()
|
||||
.value
|
||||
|
||||
let tracks: [MusicTrack] = rows.compactMap { row in
|
||||
guard let trackURL = row.resolvedURL(supabaseBase: urlRaw) else { return nil }
|
||||
|
||||
let (artist, title) = Self.parseTitle(row.title ?? "Unknown Track")
|
||||
|
||||
return MusicTrack(
|
||||
id: row.id ?? UUID().uuidString,
|
||||
title: title,
|
||||
artist: artist,
|
||||
duration: row.duration_seconds ?? 180,
|
||||
url: trackURL,
|
||||
vibe: vibe
|
||||
)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
print("[MusicService] Loaded \(tracks.count) tracks for \(vibe.rawValue)")
|
||||
#endif
|
||||
|
||||
return tracks.isEmpty ? nil : tracks
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("[MusicService] Supabase error: \(error)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
/// Splits "Artist - Title" strings.
|
||||
private static func parseTitle(_ raw: String) -> (artist: String, title: String) {
|
||||
if raw.contains(" - ") {
|
||||
let parts = raw.components(separatedBy: " - ").map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
return (parts[0], parts.dropFirst().joined(separator: " - "))
|
||||
}
|
||||
return ("YouTube Music", raw)
|
||||
}
|
||||
|
||||
// ─── Mock Tracks ─────────────────────────────────────────────
|
||||
|
||||
private static let testBase = "https://www2.cs.uic.edu/~i101/SoundFiles"
|
||||
|
||||
private static func mockTracks(for vibe: MusicVibe) -> [MusicTrack] {
|
||||
let names: [(String, String)] = {
|
||||
switch vibe {
|
||||
case .electronic: return [("Energy Pulse", "Neon Dreams"), ("Cyber Sprint", "Digital Flux"), ("High Voltage", "Circuit Breakers")]
|
||||
case .hipHop: return [("Street Heat", "Urban Flow"), ("Rhythm Power", "Beat Masters"), ("Flow State", "MC Dynamic")]
|
||||
case .pop: return [("Summer Energy", "The Popstars"), ("Upbeat Vibes", "Chart Toppers"), ("Feel Good", "Radio Hits")]
|
||||
case .rock: return [("Power Chord", "The Amplifiers"), ("High Gain", "Distortion"), ("Adrenaline", "Thunderstruck")]
|
||||
case .chill: return [("Smooth Flow", "Lo-Fi Beats"), ("Zen Mode", "Calm Collective"), ("Deep Breath", "Mindful Tones")]
|
||||
}
|
||||
}()
|
||||
|
||||
let files = ["StarWars60.wav", "tapioca.wav", "preamble10.wav"]
|
||||
|
||||
return names.enumerated().map { i, pair in
|
||||
let (title, artist) = pair
|
||||
return MusicTrack(
|
||||
id: "\(vibe.rawValue)-\(i)",
|
||||
title: title,
|
||||
artist: artist,
|
||||
duration: 200,
|
||||
url: URL(string: "\(testBase)/\(files[i % files.count])")!,
|
||||
vibe: vibe
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Supabase Row ────────────────────────────────────────────────
|
||||
|
||||
private struct DownloadItemRow: Decodable {
|
||||
let id: String?
|
||||
let video_id: String?
|
||||
let title: String?
|
||||
let duration_seconds: Int?
|
||||
let public_url: String?
|
||||
let storage_path: String?
|
||||
let genre: String?
|
||||
|
||||
func resolvedURL(supabaseBase: String) -> URL? {
|
||||
if let pub = public_url, let url = URL(string: pub) { return url }
|
||||
if let path = storage_path {
|
||||
return URL(string: "\(supabaseBase)/storage/v1/object/public/workout-audio/\(path)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
136
tabatago-swift/TabataGo/Services/PhoneConnectivityManager.swift
Normal file
136
tabatago-swift/TabataGo/Services/PhoneConnectivityManager.swift
Normal file
@@ -0,0 +1,136 @@
|
||||
import Foundation
|
||||
import WatchConnectivity
|
||||
|
||||
/// Phone-side WatchConnectivity manager.
|
||||
/// Sends workout payloads to Watch, receives HR/calorie updates back.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PhoneConnectivityManager: NSObject, WCSessionDelegate {
|
||||
|
||||
static let shared = PhoneConnectivityManager()
|
||||
|
||||
private(set) var isWatchReachable = false
|
||||
private(set) var isWatchAppInstalled = false
|
||||
|
||||
var onHeartRateUpdate: ((Double) -> Void)?
|
||||
var onCaloriesUpdate: ((Double) -> Void)?
|
||||
var onSessionCompleted: ((WatchSessionResult) -> Void)?
|
||||
|
||||
private var session: WCSession?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
guard WCSession.isSupported() else { return }
|
||||
session = WCSession.default
|
||||
session?.delegate = self
|
||||
session?.activate()
|
||||
}
|
||||
|
||||
// ─── Send workout to Watch ─────────────────────────────────────
|
||||
|
||||
func sendWorkout(_ program: WorkoutProgram) {
|
||||
guard let session, session.isReachable else { return }
|
||||
|
||||
let blocks = program.blocks.map { block in
|
||||
WatchTabataBlock(
|
||||
position: block.position,
|
||||
exercise1Name: block.exercise1.nameEn,
|
||||
exercise2Name: block.exercise2.nameEn,
|
||||
rounds: block.rounds,
|
||||
workTime: block.workTime,
|
||||
restTime: block.restTime
|
||||
)
|
||||
}
|
||||
|
||||
let payload = WatchWorkoutPayload(
|
||||
programId: program.id,
|
||||
programTitle: program.titleEn,
|
||||
bodyZone: program.bodyZone,
|
||||
level: program.level,
|
||||
totalRounds: program.totalRounds,
|
||||
blocks: blocks,
|
||||
warmupDuration: program.warmup.totalDuration,
|
||||
cooldownDuration: program.cooldown.totalDuration
|
||||
)
|
||||
|
||||
guard let data = try? JSONEncoder().encode(payload) else { return }
|
||||
|
||||
session.sendMessage(
|
||||
[WCMessageKey.type: WCMessageType.startWorkout.rawValue,
|
||||
WCMessageKey.workoutPayload: data],
|
||||
replyHandler: nil
|
||||
)
|
||||
}
|
||||
|
||||
func sendTimerTick(_ tick: TimerTickPayload) {
|
||||
guard let session, session.isReachable else { return }
|
||||
guard let data = try? JSONEncoder().encode(tick) else { return }
|
||||
session.sendMessage(
|
||||
[WCMessageKey.type: WCMessageType.timerTick.rawValue,
|
||||
"tick": data],
|
||||
replyHandler: nil
|
||||
)
|
||||
}
|
||||
|
||||
func sendPause() {
|
||||
sendSimple(type: .pauseWorkout)
|
||||
}
|
||||
|
||||
func sendResume() {
|
||||
sendSimple(type: .resumeWorkout)
|
||||
}
|
||||
|
||||
func sendEndWorkout() {
|
||||
sendSimple(type: .endWorkout)
|
||||
}
|
||||
|
||||
// ─── WCSessionDelegate ────────────────────────────────────────
|
||||
|
||||
nonisolated func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
|
||||
let reachable = session.isReachable
|
||||
let installed = session.isWatchAppInstalled
|
||||
Task { @MainActor in
|
||||
self.isWatchReachable = reachable
|
||||
self.isWatchAppInstalled = installed
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
|
||||
let reachable = session.isReachable
|
||||
Task { @MainActor in
|
||||
self.isWatchReachable = reachable
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
guard let typeRaw = message[WCMessageKey.type] as? String,
|
||||
let type = WCMessageType(rawValue: typeRaw) else { return }
|
||||
|
||||
switch type {
|
||||
case .heartRateUpdate:
|
||||
let hr = message[WCMessageKey.heartRate] as? Double ?? 0
|
||||
Task { @MainActor in self.onHeartRateUpdate?(hr) }
|
||||
|
||||
case .sessionCompleted:
|
||||
guard let data = message[WCMessageKey.sessionResult] as? Data,
|
||||
let result = try? JSONDecoder().decode(WatchSessionResult.self, from: data) else { return }
|
||||
Task { @MainActor in self.onSessionCompleted?(result) }
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Required for iOS
|
||||
nonisolated func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
nonisolated func sessionDidDeactivate(_ session: WCSession) {
|
||||
session.activate()
|
||||
}
|
||||
|
||||
// ─── Private helpers ──────────────────────────────────────────
|
||||
|
||||
private func sendSimple(type: WCMessageType) {
|
||||
guard let session, session.isReachable else { return }
|
||||
session.sendMessage([WCMessageKey.type: type.rawValue], replyHandler: nil)
|
||||
}
|
||||
}
|
||||
103
tabatago-swift/TabataGo/Services/PurchaseService.swift
Normal file
103
tabatago-swift/TabataGo/Services/PurchaseService.swift
Normal file
@@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
import RevenueCat
|
||||
|
||||
/// Wraps RevenueCat — manages entitlements and purchase flows.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class PurchaseService {
|
||||
|
||||
static let shared = PurchaseService()
|
||||
|
||||
private(set) var isInitialized = false
|
||||
private(set) var currentPlan: SubscriptionPlan = .free
|
||||
private(set) var offerings: Offerings? = nil
|
||||
private(set) var isPurchasing = false
|
||||
private(set) var error: String? = nil
|
||||
|
||||
var isPremium: Bool { currentPlan.isPremium }
|
||||
|
||||
private static let apiKey: String =
|
||||
Bundle.main.infoDictionary?["REVENUECAT_API_KEY"] as? String ?? ""
|
||||
|
||||
private static let entitlementId = "1000 Corp Pro"
|
||||
|
||||
private init() {}
|
||||
|
||||
@MainActor
|
||||
func initialize() async {
|
||||
guard !isInitialized, !AppEnvironment.isPreview else { return }
|
||||
|
||||
let key = Self.apiKey
|
||||
guard !key.isEmpty else {
|
||||
#if DEBUG
|
||||
print("[PurchaseService] No API key configured — skipping RevenueCat init")
|
||||
#endif
|
||||
isInitialized = true
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
Purchases.logLevel = .debug
|
||||
#endif
|
||||
Purchases.configure(withAPIKey: key)
|
||||
isInitialized = true
|
||||
await refreshEntitlement()
|
||||
await loadOfferings()
|
||||
}
|
||||
|
||||
/// True when RevenueCat SDK has been configured with a valid-looking key.
|
||||
private var isSDKConfigured: Bool {
|
||||
isInitialized && !Self.apiKey.isEmpty
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func refreshEntitlement() async {
|
||||
guard isSDKConfigured else { return }
|
||||
do {
|
||||
let info = try await Purchases.shared.customerInfo()
|
||||
updatePlan(from: info)
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadOfferings() async {
|
||||
guard isSDKConfigured else { return }
|
||||
do {
|
||||
offerings = try await Purchases.shared.offerings()
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func purchase(package: Package) async throws {
|
||||
guard isSDKConfigured else { return }
|
||||
isPurchasing = true
|
||||
defer { isPurchasing = false }
|
||||
let result = try await Purchases.shared.purchase(package: package)
|
||||
updatePlan(from: result.customerInfo)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func restorePurchases() async throws {
|
||||
guard isSDKConfigured else { return }
|
||||
let info = try await Purchases.shared.restorePurchases()
|
||||
updatePlan(from: info)
|
||||
}
|
||||
|
||||
private func updatePlan(from info: CustomerInfo) {
|
||||
let isActive = info.entitlements[Self.entitlementId]?.isActive == true
|
||||
if isActive {
|
||||
// Determine monthly vs yearly from active subscriptions
|
||||
if info.activeSubscriptions.contains(where: { $0.contains("yearly") || $0.contains("annual") }) {
|
||||
currentPlan = .premiumYearly
|
||||
} else {
|
||||
currentPlan = .premiumMonthly
|
||||
}
|
||||
} else {
|
||||
currentPlan = .free
|
||||
}
|
||||
}
|
||||
}
|
||||
247
tabatago-swift/TabataGo/Services/SupabaseService.swift
Normal file
247
tabatago-swift/TabataGo/Services/SupabaseService.swift
Normal file
@@ -0,0 +1,247 @@
|
||||
import Foundation
|
||||
import Supabase
|
||||
|
||||
// ─── Service ─────────────────────────────────────────────────────
|
||||
|
||||
/// Fetches workout programs from Supabase, with offline cache fallback.
|
||||
actor SupabaseService {
|
||||
|
||||
static let shared = SupabaseService()
|
||||
|
||||
private let client: SupabaseClient
|
||||
|
||||
/// True only when both URL and key are real values, and we are not in a preview/test sandbox.
|
||||
private let isConfigured: Bool
|
||||
|
||||
private init() {
|
||||
// Bail out entirely in Xcode Canvas / unit-test sandboxes — no network
|
||||
// calls, no SupabaseClient init with placeholder credentials.
|
||||
guard !AppEnvironment.isPreview else {
|
||||
client = SupabaseClient(
|
||||
supabaseURL: URL(string: "https://localhost")!,
|
||||
supabaseKey: ""
|
||||
)
|
||||
isConfigured = false
|
||||
return
|
||||
}
|
||||
|
||||
let urlRaw = Bundle.main.infoDictionary?["SUPABASE_URL"] as? String ?? ""
|
||||
let key = Bundle.main.infoDictionary?["SUPABASE_ANON_KEY"] as? String ?? ""
|
||||
let url = URL(string: urlRaw) ?? URL(string: "https://localhost")!
|
||||
|
||||
// Consider configured only when both values look non-empty and non-placeholder.
|
||||
let validURL = !urlRaw.isEmpty && urlRaw != "https://localhost"
|
||||
let validKey = !key.isEmpty
|
||||
isConfigured = validURL && validKey
|
||||
|
||||
client = SupabaseClient(supabaseURL: url, supabaseKey: key)
|
||||
|
||||
#if DEBUG
|
||||
print("[SupabaseService] URL=\(urlRaw) configured=\(isConfigured)")
|
||||
#endif
|
||||
}
|
||||
|
||||
// ─── Program Fetching ────────────────────────────────────────
|
||||
|
||||
/// Fetch all workout programs. Returns nil in preview/test environments or when unconfigured.
|
||||
func fetchAllPrograms() async throws -> [WorkoutProgram]? {
|
||||
guard isConfigured else { return nil }
|
||||
|
||||
let programRows: [WorkoutProgramRow] = try await client
|
||||
.from("workout_programs")
|
||||
.select("*")
|
||||
.order("sort_order")
|
||||
.execute()
|
||||
.value
|
||||
|
||||
let tabataRows: [ProgramTabataRow] = try await client
|
||||
.from("program_tabatas")
|
||||
.select("*")
|
||||
.order("position")
|
||||
.execute()
|
||||
.value
|
||||
|
||||
let warmupRows: [TimedExerciseRow] = try await client
|
||||
.from("workout_warmup_exercises")
|
||||
.select("*")
|
||||
.order("position")
|
||||
.execute()
|
||||
.value
|
||||
|
||||
let stretchRows: [TimedExerciseRow] = try await client
|
||||
.from("workout_stretch_exercises")
|
||||
.select("*")
|
||||
.order("position")
|
||||
.execute()
|
||||
.value
|
||||
|
||||
return assemble(
|
||||
programs: programRows,
|
||||
tabatas: tabataRows,
|
||||
warmups: warmupRows,
|
||||
stretches: stretchRows
|
||||
)
|
||||
}
|
||||
|
||||
/// Sync a completed workout session to Supabase (premium users only).
|
||||
func syncSession(_ session: WorkoutSession) async throws {
|
||||
guard isConfigured else { return }
|
||||
let payload = SessionPayload(
|
||||
workout_id: session.programId,
|
||||
completed_at: ISO8601DateFormatter().string(from: session.completedAt),
|
||||
duration_seconds: session.durationSeconds,
|
||||
calories_burned: session.caloriesBurned,
|
||||
average_heart_rate: session.averageHeartRate
|
||||
)
|
||||
try await client.from("workout_sessions").insert(payload).execute()
|
||||
}
|
||||
|
||||
// ─── Assembly ────────────────────────────────────────────────
|
||||
|
||||
private func assemble(
|
||||
programs: [WorkoutProgramRow],
|
||||
tabatas: [ProgramTabataRow],
|
||||
warmups: [TimedExerciseRow],
|
||||
stretches: [TimedExerciseRow]
|
||||
) -> [WorkoutProgram] {
|
||||
programs.map { prog in
|
||||
let progTabatas = tabatas
|
||||
.filter { $0.program_id == prog.id }
|
||||
.sorted { $0.position < $1.position }
|
||||
|
||||
let progWarmups = warmups
|
||||
.filter { $0.program_id == prog.id }
|
||||
.sorted { $0.position < $1.position }
|
||||
|
||||
let progStretches = stretches
|
||||
.filter { $0.program_id == prog.id }
|
||||
.sorted { $0.position < $1.position }
|
||||
|
||||
let blocks = progTabatas.map { row in
|
||||
TabataBlock(
|
||||
id: row.id,
|
||||
position: row.position,
|
||||
exercise1: TabataExercise(
|
||||
name: row.exercise_1_name,
|
||||
nameEn: row.exercise_1_name_en ?? row.exercise_1_name,
|
||||
tip: row.exercise_1_tip,
|
||||
tipEn: row.exercise_1_tip_en,
|
||||
modification: row.exercise_1_modification,
|
||||
modificationEn: row.exercise_1_modification_en,
|
||||
progression: row.exercise_1_progression,
|
||||
progressionEn: row.exercise_1_progression_en,
|
||||
videoUrl: row.exercise_1_video_url
|
||||
),
|
||||
exercise2: TabataExercise(
|
||||
name: row.exercise_2_name,
|
||||
nameEn: row.exercise_2_name_en ?? row.exercise_2_name,
|
||||
tip: row.exercise_2_tip,
|
||||
tipEn: row.exercise_2_tip_en,
|
||||
modification: row.exercise_2_modification,
|
||||
modificationEn: row.exercise_2_modification_en,
|
||||
progression: row.exercise_2_progression,
|
||||
progressionEn: row.exercise_2_progression_en,
|
||||
videoUrl: row.exercise_2_video_url
|
||||
),
|
||||
rounds: row.rounds,
|
||||
workTime: row.work_time,
|
||||
restTime: row.rest_time
|
||||
)
|
||||
}
|
||||
|
||||
let warmupMovements = progWarmups.map {
|
||||
TimedMovement(name: $0.name, nameEn: $0.name_en ?? $0.name, duration: $0.duration, videoUrl: $0.video_url)
|
||||
}
|
||||
let stretchMovements = progStretches.map {
|
||||
TimedMovement(name: $0.name, nameEn: $0.name_en ?? $0.name, duration: $0.duration, videoUrl: $0.video_url)
|
||||
}
|
||||
|
||||
let totalRounds = blocks.reduce(0) { $0 + $1.rounds }
|
||||
let workSeconds = blocks.reduce(0) { $0 + $1.rounds * ($1.workTime + $1.restTime) }
|
||||
let warmupTotal = warmupMovements.reduce(0) { $0 + $1.duration }
|
||||
let stretchTotal = stretchMovements.reduce(0) { $0 + $1.duration }
|
||||
|
||||
return WorkoutProgram(
|
||||
id: prog.id,
|
||||
title: prog.title,
|
||||
titleEn: prog.title_en ?? prog.title,
|
||||
description: prog.description ?? "",
|
||||
descriptionEn: prog.description_en ?? prog.description ?? "",
|
||||
bodyZone: prog.body_zone,
|
||||
level: prog.level,
|
||||
musicVibe: prog.music_vibe ?? "electronic",
|
||||
accentColor: prog.accent_color ?? "#FF6B35",
|
||||
isFree: prog.is_free,
|
||||
estimatedCalories: prog.estimated_calories ?? 0,
|
||||
estimatedDuration: (warmupTotal + workSeconds + stretchTotal) / 60,
|
||||
totalRounds: totalRounds,
|
||||
warmup: WarmupSection(movements: warmupMovements, totalDuration: warmupTotal),
|
||||
cooldown: CooldownSection(movements: stretchMovements, totalDuration: stretchTotal),
|
||||
blocks: blocks,
|
||||
thumbnailUrl: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Row types (Supabase decode) ─────────────────────────────────
|
||||
|
||||
struct WorkoutProgramRow: Decodable {
|
||||
let id: String
|
||||
let title: String
|
||||
let title_en: String?
|
||||
let description: String?
|
||||
let description_en: String?
|
||||
let body_zone: String
|
||||
let level: String
|
||||
let music_vibe: String?
|
||||
let accent_color: String?
|
||||
let is_free: Bool
|
||||
let estimated_calories: Int?
|
||||
let sort_order: Int?
|
||||
}
|
||||
|
||||
struct ProgramTabataRow: Decodable {
|
||||
let id: String
|
||||
let program_id: String
|
||||
let position: Int
|
||||
let exercise_1_name: String
|
||||
let exercise_1_name_en: String?
|
||||
let exercise_1_tip: String?
|
||||
let exercise_1_tip_en: String?
|
||||
let exercise_1_modification: String?
|
||||
let exercise_1_modification_en: String?
|
||||
let exercise_1_progression: String?
|
||||
let exercise_1_progression_en: String?
|
||||
let exercise_1_video_url: String?
|
||||
let exercise_2_name: String
|
||||
let exercise_2_name_en: String?
|
||||
let exercise_2_tip: String?
|
||||
let exercise_2_tip_en: String?
|
||||
let exercise_2_modification: String?
|
||||
let exercise_2_modification_en: String?
|
||||
let exercise_2_progression: String?
|
||||
let exercise_2_progression_en: String?
|
||||
let exercise_2_video_url: String?
|
||||
let rounds: Int
|
||||
let work_time: Int
|
||||
let rest_time: Int
|
||||
}
|
||||
|
||||
struct TimedExerciseRow: Decodable {
|
||||
let id: String
|
||||
let program_id: String
|
||||
let name: String
|
||||
let name_en: String?
|
||||
let duration: Int
|
||||
let position: Int
|
||||
let video_url: String?
|
||||
}
|
||||
|
||||
struct SessionPayload: Encodable {
|
||||
let workout_id: String
|
||||
let completed_at: String
|
||||
let duration_seconds: Int
|
||||
let calories_burned: Double
|
||||
let average_heart_rate: Double?
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
|
||||
// ─── Shared message keys (used by both iOS and watchOS targets) ───
|
||||
|
||||
enum WCMessageKey {
|
||||
static let type = "type"
|
||||
static let workoutPayload = "workout"
|
||||
static let sessionResult = "sessionResult"
|
||||
static let heartRate = "heartRate"
|
||||
static let calories = "calories"
|
||||
static let phase = "phase"
|
||||
static let timeRemaining = "timeRemaining"
|
||||
static let round = "round"
|
||||
static let totalRounds = "totalRounds"
|
||||
static let exerciseName = "exerciseName"
|
||||
static let programId = "programId"
|
||||
static let programTitle = "programTitle"
|
||||
}
|
||||
|
||||
enum WCMessageType: String {
|
||||
case startWorkout = "startWorkout"
|
||||
case pauseWorkout = "pauseWorkout"
|
||||
case resumeWorkout = "resumeWorkout"
|
||||
case endWorkout = "endWorkout"
|
||||
case timerTick = "timerTick" // phone → watch: sync state
|
||||
case sessionCompleted = "sessionCompleted" // watch → phone: HR/cal data
|
||||
case heartRateUpdate = "heartRateUpdate" // watch → phone: live HR
|
||||
}
|
||||
|
||||
// ─── Codable payload for starting a workout on Watch ─────────────
|
||||
|
||||
struct WatchWorkoutPayload: Codable {
|
||||
var programId: String
|
||||
var programTitle: String
|
||||
var bodyZone: String
|
||||
var level: String
|
||||
var totalRounds: Int
|
||||
var blocks: [WatchTabataBlock]
|
||||
var warmupDuration: Int // seconds
|
||||
var cooldownDuration: Int // seconds
|
||||
}
|
||||
|
||||
struct WatchTabataBlock: Codable {
|
||||
var position: Int
|
||||
var exercise1Name: String
|
||||
var exercise2Name: String
|
||||
var rounds: Int
|
||||
var workTime: Int
|
||||
var restTime: Int
|
||||
}
|
||||
|
||||
// ─── Timer tick payload (phone keeps Watch in sync) ───────────────
|
||||
|
||||
struct TimerTickPayload: Codable {
|
||||
var phase: String
|
||||
var timeRemaining: Int
|
||||
var currentRound: Int
|
||||
var totalRoundsInBlock: Int
|
||||
var exerciseName: String?
|
||||
}
|
||||
|
||||
// ─── Session result (Watch → phone after workout ends) ────────────
|
||||
|
||||
struct WatchSessionResult: Codable {
|
||||
var programId: String
|
||||
var startedAt: Date
|
||||
var completedAt: Date
|
||||
var durationSeconds: Int
|
||||
var activeCalories: Double
|
||||
var averageHeartRate: Double?
|
||||
var peakHeartRate: Double?
|
||||
}
|
||||
163
tabatago-swift/TabataGo/Theme/Theme.swift
Normal file
163
tabatago-swift/TabataGo/Theme/Theme.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
7
tabatago-swift/TabataGo/Utilities/Environment.swift
Normal file
7
tabatago-swift/TabataGo/Utilities/Environment.swift
Normal 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"
|
||||
}
|
||||
138
tabatago-swift/TabataGo/Utilities/Strings.swift
Normal file
138
tabatago-swift/TabataGo/Utilities/Strings.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
23
tabatago-swift/TabataGo/ViewModels/HealthViewModel.swift
Normal file
23
tabatago-swift/TabataGo/ViewModels/HealthViewModel.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
60
tabatago-swift/TabataGo/ViewModels/HomeViewModel.swift
Normal file
60
tabatago-swift/TabataGo/ViewModels/HomeViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
124
tabatago-swift/TabataGo/ViewModels/MusicPlayerViewModel.swift
Normal file
124
tabatago-swift/TabataGo/ViewModels/MusicPlayerViewModel.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
334
tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift
Normal file
334
tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
46
tabatago-swift/TabataGo/ViewModels/PurchaseViewModel.swift
Normal file
46
tabatago-swift/TabataGo/ViewModels/PurchaseViewModel.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
241
tabatago-swift/TabataGo/Views/Complete/CompletionView.swift
Normal file
241
tabatago-swift/TabataGo/Views/Complete/CompletionView.swift
Normal 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)
|
||||
}
|
||||
641
tabatago-swift/TabataGo/Views/Onboarding/OnboardingView.swift
Normal file
641
tabatago-swift/TabataGo/Views/Onboarding/OnboardingView.swift
Normal 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)
|
||||
}
|
||||
210
tabatago-swift/TabataGo/Views/Paywall/PaywallView.swift
Normal file
210
tabatago-swift/TabataGo/Views/Paywall/PaywallView.swift
Normal 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)
|
||||
}
|
||||
57
tabatago-swift/TabataGo/Views/Player/NowPlayingView.swift
Normal file
57
tabatago-swift/TabataGo/Views/Player/NowPlayingView.swift
Normal 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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
338
tabatago-swift/TabataGo/Views/Player/PlayerView.swift
Normal file
338
tabatago-swift/TabataGo/Views/Player/PlayerView.swift
Normal 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)
|
||||
}
|
||||
68
tabatago-swift/TabataGo/Views/Programs/BodyZoneView.swift
Normal file
68
tabatago-swift/TabataGo/Views/Programs/BodyZoneView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
221
tabatago-swift/TabataGo/Views/Programs/ProgramDetailView.swift
Normal file
221
tabatago-swift/TabataGo/Views/Programs/ProgramDetailView.swift
Normal 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)
|
||||
}
|
||||
79
tabatago-swift/TabataGo/Views/Settings/PolicyViews.swift
Normal file
79
tabatago-swift/TabataGo/Views/Settings/PolicyViews.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
170
tabatago-swift/TabataGo/Views/Settings/SettingsView.swift
Normal file
170
tabatago-swift/TabataGo/Views/Settings/SettingsView.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
317
tabatago-swift/TabataGo/Views/Tabs/ActivityTab.swift
Normal file
317
tabatago-swift/TabataGo/Views/Tabs/ActivityTab.swift
Normal 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)
|
||||
}
|
||||
329
tabatago-swift/TabataGo/Views/Tabs/HomeTab.swift
Normal file
329
tabatago-swift/TabataGo/Views/Tabs/HomeTab.swift
Normal 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())
|
||||
}
|
||||
52
tabatago-swift/TabataGo/Views/Tabs/MainTabView.swift
Normal file
52
tabatago-swift/TabataGo/Views/Tabs/MainTabView.swift
Normal 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())
|
||||
}
|
||||
130
tabatago-swift/TabataGo/Views/Tabs/ProfileTab.swift
Normal file
130
tabatago-swift/TabataGo/Views/Tabs/ProfileTab.swift
Normal 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())
|
||||
}
|
||||
134
tabatago-swift/TabataGo/Views/Tabs/ProgramsTab.swift
Normal file
134
tabatago-swift/TabataGo/Views/Tabs/ProgramsTab.swift
Normal 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())
|
||||
}
|
||||
62
tabatago-swift/TabataGoTests/TabataGoTests.swift
Normal file
62
tabatago-swift/TabataGoTests/TabataGoTests.swift
Normal file
@@ -0,0 +1,62 @@
|
||||
import XCTest
|
||||
@testable import TabataGo
|
||||
|
||||
final class TabataGoTests: XCTestCase {
|
||||
|
||||
func testStreakComputationConsecutiveDays() {
|
||||
// Consecutive 3 days should yield streak of 3
|
||||
let calendar = Calendar.current
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
let sessions: [Date] = [
|
||||
today,
|
||||
calendar.date(byAdding: .day, value: -1, to: today)!,
|
||||
calendar.date(byAdding: .day, value: -2, to: today)!,
|
||||
]
|
||||
// Validate unique day count
|
||||
XCTAssertEqual(sessions.count, 3)
|
||||
}
|
||||
|
||||
func testWorkoutSessionCompletionRate() {
|
||||
let session = WorkoutSession(
|
||||
programId: "test",
|
||||
programTitle: "Test",
|
||||
bodyZone: "full",
|
||||
level: "Beginner",
|
||||
startedAt: Date(),
|
||||
completedAt: Date(),
|
||||
durationSeconds: 1440,
|
||||
caloriesBurned: 180,
|
||||
roundsCompleted: 8,
|
||||
totalRounds: 8
|
||||
)
|
||||
XCTAssertEqual(session.completionRate, 1.0)
|
||||
}
|
||||
|
||||
func testWorkoutSessionPartialCompletionRate() {
|
||||
let session = WorkoutSession(
|
||||
programId: "test",
|
||||
programTitle: "Test",
|
||||
bodyZone: "full",
|
||||
level: "Beginner",
|
||||
startedAt: Date(),
|
||||
completedAt: Date(),
|
||||
durationSeconds: 720,
|
||||
caloriesBurned: 90,
|
||||
roundsCompleted: 4,
|
||||
totalRounds: 8
|
||||
)
|
||||
XCTAssertEqual(session.completionRate, 0.5)
|
||||
}
|
||||
|
||||
func testSubscriptionPlanIsPremium() {
|
||||
XCTAssertFalse(SubscriptionPlan.free.isPremium)
|
||||
XCTAssertTrue(SubscriptionPlan.premiumMonthly.isPremium)
|
||||
XCTAssertTrue(SubscriptionPlan.premiumYearly.isPremium)
|
||||
}
|
||||
|
||||
func testTimerPhaseColors() {
|
||||
// Phase colors should differ
|
||||
XCTAssertNotEqual(Theme.phaseColor(.work), Theme.phaseColor(.rest))
|
||||
XCTAssertNotEqual(Theme.phaseColor(.work), Theme.phaseColor(.complete))
|
||||
}
|
||||
}
|
||||
19
tabatago-swift/TabataGoUITests/TabataGoUITests.swift
Normal file
19
tabatago-swift/TabataGoUITests/TabataGoUITests.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import XCTest
|
||||
|
||||
final class TabataGoUITests: XCTestCase {
|
||||
|
||||
var app: XCUIApplication!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
app = XCUIApplication()
|
||||
app.launchArguments = ["--uitesting"]
|
||||
app.launch()
|
||||
}
|
||||
|
||||
func testOnboardingFlowCompletes() {
|
||||
// App should show onboarding for fresh installs
|
||||
// In a real test, we'd interact with onboarding steps
|
||||
XCTAssertTrue(app.exists)
|
||||
}
|
||||
}
|
||||
17
tabatago-swift/TabataGoWatch/App/TabataGoWatchApp.swift
Normal file
17
tabatago-swift/TabataGoWatch/App/TabataGoWatchApp.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
import SwiftUI
|
||||
import HealthKit
|
||||
|
||||
@main
|
||||
struct TabataGoWatchApp: App {
|
||||
|
||||
@StateObject private var connectivityManager = WatchConnectivityManager.shared
|
||||
@StateObject private var playerEngine = WatchPlayerEngine()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WatchRootView()
|
||||
.environmentObject(connectivityManager)
|
||||
.environmentObject(playerEngine)
|
||||
}
|
||||
}
|
||||
}
|
||||
29
tabatago-swift/TabataGoWatch/Complications/Info.plist
Normal file
29
tabatago-swift/TabataGoWatch/Complications/Info.plist
Normal file
@@ -0,0 +1,29 @@
|
||||
<?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>TabataGoWidget</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>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,165 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// ─── Timeline entry ───────────────────────────────────────────────────────────
|
||||
|
||||
struct TabataEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let streak: Int
|
||||
let lastWorkoutLabel: String // e.g. "Yesterday" or "2 days ago"
|
||||
}
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
struct TabataProvider: TimelineProvider {
|
||||
|
||||
private let sharedDefaults = UserDefaults(suiteName: "group.com.tabatago.app")
|
||||
|
||||
func placeholder(in context: Context) -> TabataEntry {
|
||||
TabataEntry(date: Date(), streak: 7, lastWorkoutLabel: "Today")
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (TabataEntry) -> Void) {
|
||||
completion(makeEntry())
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<TabataEntry>) -> Void) {
|
||||
let entry = makeEntry()
|
||||
// Refresh at midnight so the streak date label stays accurate
|
||||
let midnight = Calendar.current.startOfDay(for: Date().addingTimeInterval(86_400))
|
||||
completion(Timeline(entries: [entry], policy: .after(midnight)))
|
||||
}
|
||||
|
||||
private func makeEntry() -> TabataEntry {
|
||||
let streak = sharedDefaults?.integer(forKey: "streak") ?? 0
|
||||
let lastDate = sharedDefaults?.object(forKey: "lastWorkoutDate") as? Date
|
||||
let label = relativeLabel(for: lastDate)
|
||||
return TabataEntry(date: Date(), streak: streak, lastWorkoutLabel: label)
|
||||
}
|
||||
|
||||
private func relativeLabel(for date: Date?) -> String {
|
||||
guard let date else { return "Not started" }
|
||||
let days = Calendar.current.dateComponents([.day],
|
||||
from: Calendar.current.startOfDay(for: date),
|
||||
to: Calendar.current.startOfDay(for: Date())).day ?? 0
|
||||
switch days {
|
||||
case 0: return "Today"
|
||||
case 1: return "Yesterday"
|
||||
default: return "\(days) days ago"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Views ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `.accessoryCircular` — streak count inside an orange ring
|
||||
struct CircularComplicationView: View {
|
||||
let entry: TabataEntry
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
VStack(spacing: 0) {
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(.orange)
|
||||
Text("\(entry.streak)")
|
||||
.font(.system(size: 18, weight: .black, design: .rounded))
|
||||
.minimumScaleFactor(0.5)
|
||||
}
|
||||
}
|
||||
.widgetAccentable()
|
||||
}
|
||||
}
|
||||
|
||||
/// `.accessoryRectangular` — streak + last workout date
|
||||
struct RectangularComplicationView: View {
|
||||
let entry: TabataEntry
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "bolt.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
Text("\(entry.streak) day streak")
|
||||
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||
}
|
||||
Text(entry.lastWorkoutLabel)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Open TabataGo →")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.widgetAccentable()
|
||||
}
|
||||
}
|
||||
|
||||
/// `.accessoryCorner` — tiny bolt + streak digit in the corner
|
||||
struct CornerComplicationView: View {
|
||||
let entry: TabataEntry
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AccessoryWidgetBackground()
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
.widgetLabel("\(entry.streak) day streak")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Widget definition ────────────────────────────────────────────────────────
|
||||
|
||||
struct TabataGoComplication: Widget {
|
||||
static let kind = "TabataGoComplication"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: Self.kind, provider: TabataProvider()) { entry in
|
||||
TabataComplicationEntryView(entry: entry)
|
||||
.containerBackground(.black, for: .widget)
|
||||
}
|
||||
.configurationDisplayName("TabataGo")
|
||||
.description("Your current workout streak.")
|
||||
.supportedFamilies([
|
||||
.accessoryCircular,
|
||||
.accessoryRectangular,
|
||||
.accessoryCorner
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
struct TabataComplicationEntryView: View {
|
||||
@Environment(\.widgetFamily) var family
|
||||
let entry: TabataEntry
|
||||
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .accessoryCircular:
|
||||
CircularComplicationView(entry: entry)
|
||||
case .accessoryRectangular:
|
||||
RectangularComplicationView(entry: entry)
|
||||
case .accessoryCorner:
|
||||
CornerComplicationView(entry: entry)
|
||||
default:
|
||||
CircularComplicationView(entry: entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Previews ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#Preview("Circular", as: .accessoryCircular) {
|
||||
TabataGoComplication()
|
||||
} timeline: {
|
||||
TabataEntry(date: .now, streak: 7, lastWorkoutLabel: "Today")
|
||||
}
|
||||
|
||||
#Preview("Rectangular", as: .accessoryRectangular) {
|
||||
TabataGoComplication()
|
||||
} timeline: {
|
||||
TabataEntry(date: .now, streak: 7, lastWorkoutLabel: "Today")
|
||||
}
|
||||
32
tabatago-swift/TabataGoWatch/Resources/Info.plist
Normal file
32
tabatago-swift/TabataGoWatch/Resources/Info.plist
Normal file
@@ -0,0 +1,32 @@
|
||||
<?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 heart rate and calories during workouts.</string>
|
||||
<key>NSHealthUpdateUsageDescription</key>
|
||||
<string>TabataGo saves workout data to Apple Health directly from your Watch.</string>
|
||||
<key>WKApplication</key>
|
||||
<true/>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
<string>com.tabatago.app</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?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.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.tabatago.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
import WatchConnectivity
|
||||
|
||||
/// Watch-side WatchConnectivity manager.
|
||||
/// Receives workout payloads from the phone, sends HR/calorie results back.
|
||||
@MainActor
|
||||
final class WatchConnectivityManager: NSObject, ObservableObject, WCSessionDelegate {
|
||||
|
||||
static let shared = WatchConnectivityManager()
|
||||
|
||||
@Published private(set) var isPhoneReachable = false
|
||||
|
||||
var onStartWorkout: ((WatchWorkoutPayload) -> Void)?
|
||||
var onTimerTick: ((TimerTickPayload) -> Void)?
|
||||
var onPause: (() -> Void)?
|
||||
var onResume: (() -> Void)?
|
||||
var onEnd: (() -> Void)?
|
||||
|
||||
private var session: WCSession?
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
guard WCSession.isSupported() else { return }
|
||||
session = WCSession.default
|
||||
session?.delegate = self
|
||||
session?.activate()
|
||||
}
|
||||
|
||||
// ─── Send data to phone ───────────────────────────────────────
|
||||
|
||||
func sendHeartRate(_ bpm: Double) {
|
||||
send([WCMessageKey.type: WCMessageType.heartRateUpdate.rawValue,
|
||||
WCMessageKey.heartRate: bpm])
|
||||
}
|
||||
|
||||
func sendSessionResult(_ result: WatchSessionResult) {
|
||||
guard let data = try? JSONEncoder().encode(result) else { return }
|
||||
send([WCMessageKey.type: WCMessageType.sessionCompleted.rawValue,
|
||||
WCMessageKey.sessionResult: data])
|
||||
}
|
||||
|
||||
// ─── WCSessionDelegate ────────────────────────────────────────
|
||||
|
||||
nonisolated func session(_ session: WCSession, activationDidCompleteWith state: WCSessionActivationState, error: Error?) {
|
||||
let reachable = session.isReachable
|
||||
Task { @MainActor in self.isPhoneReachable = reachable }
|
||||
}
|
||||
|
||||
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
|
||||
let reachable = session.isReachable
|
||||
Task { @MainActor in self.isPhoneReachable = 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 }
|
||||
|
||||
// Extract Data payloads before crossing actor boundary — [String: Any] is not Sendable.
|
||||
let workoutData = message[WCMessageKey.workoutPayload] as? Data
|
||||
let tickData = message["tick"] as? Data
|
||||
|
||||
Task { @MainActor in
|
||||
switch type {
|
||||
case .startWorkout:
|
||||
guard let data = workoutData,
|
||||
let payload = try? JSONDecoder().decode(WatchWorkoutPayload.self, from: data) else { return }
|
||||
onStartWorkout?(payload)
|
||||
|
||||
case .timerTick:
|
||||
guard let data = tickData,
|
||||
let tick = try? JSONDecoder().decode(TimerTickPayload.self, from: data) else { return }
|
||||
onTimerTick?(tick)
|
||||
|
||||
case .pauseWorkout: onPause?()
|
||||
case .resumeWorkout: onResume?()
|
||||
case .endWorkout: onEnd?()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Private ─────────────────────────────────────────────────
|
||||
|
||||
private func send(_ message: [String: Any]) {
|
||||
guard let session, session.isReachable else { return }
|
||||
session.sendMessage(message, replyHandler: nil)
|
||||
}
|
||||
}
|
||||
297
tabatago-swift/TabataGoWatch/Services/WatchPlayerEngine.swift
Normal file
297
tabatago-swift/TabataGoWatch/Services/WatchPlayerEngine.swift
Normal file
@@ -0,0 +1,297 @@
|
||||
import Foundation
|
||||
import HealthKit
|
||||
import WatchKit
|
||||
|
||||
// ─── Watch-local phase enum (mirrors iOS TimerPhase without cross-target dep) ──
|
||||
|
||||
enum WatchPhase: String, Codable, CaseIterable {
|
||||
case prep = "PREP"
|
||||
case warmup = "WARMUP"
|
||||
case work = "WORK"
|
||||
case rest = "REST"
|
||||
case interBlockRest = "INTER_BLOCK_REST"
|
||||
case cooldown = "COOLDOWN"
|
||||
case complete = "COMPLETE"
|
||||
}
|
||||
|
||||
// ─── Engine ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Drives the watch-side workout timer.
|
||||
///
|
||||
/// Design: The phone is the source-of-truth; the Watch engine *also* runs
|
||||
/// its own 1-second countdown so the display stays smooth even if WC
|
||||
/// messages are delayed. On every `timerTick` from the phone the Watch
|
||||
/// snaps its state to match, preventing drift.
|
||||
///
|
||||
/// HealthKit: The engine owns an `HKWorkoutSession` + `HKLiveWorkoutBuilder`
|
||||
/// so that calorie / heart-rate data is written directly from the Watch,
|
||||
/// which has the most accurate wrist sensors.
|
||||
@MainActor
|
||||
final class WatchPlayerEngine: NSObject, ObservableObject {
|
||||
|
||||
// ── Published state ───────────────────────────────────────────────
|
||||
@Published private(set) var isActive = false
|
||||
@Published private(set) var isPaused = false
|
||||
@Published private(set) var phase = WatchPhase.prep
|
||||
@Published private(set) var timeRemaining = 0
|
||||
@Published private(set) var currentRound = 1
|
||||
@Published private(set) var totalRoundsInBlock = 8
|
||||
@Published private(set) var currentExerciseName: String? = nil
|
||||
@Published private(set) var heartRate = 0.0
|
||||
@Published private(set) var activeCalories = 0.0
|
||||
|
||||
// ── Private state ─────────────────────────────────────────────────
|
||||
private var payload: WatchWorkoutPayload?
|
||||
private var startedAt: Date?
|
||||
private var timer: Timer?
|
||||
|
||||
// ── HealthKit ─────────────────────────────────────────────────────
|
||||
private let healthStore = HKHealthStore()
|
||||
private var workoutSession: HKWorkoutSession?
|
||||
private var liveBuilder: HKLiveWorkoutBuilder?
|
||||
|
||||
// ── Connectivity back-ref ─────────────────────────────────────────
|
||||
private let wc = WatchConnectivityManager.shared
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
wireConnectivityCallbacks()
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────
|
||||
|
||||
func togglePause() {
|
||||
if isPaused { resume() } else { pause() }
|
||||
}
|
||||
|
||||
func endWorkout() {
|
||||
stopTimer()
|
||||
Task { await finalizeHealthKit() }
|
||||
}
|
||||
|
||||
// ─── Connectivity callbacks ───────────────────────────────────────
|
||||
|
||||
private func wireConnectivityCallbacks() {
|
||||
wc.onStartWorkout = { [weak self] payload in
|
||||
Task { @MainActor in self?.handleStartWorkout(payload) }
|
||||
}
|
||||
wc.onTimerTick = { [weak self] tick in
|
||||
Task { @MainActor in self?.handleTick(tick) }
|
||||
}
|
||||
wc.onPause = { [weak self] in Task { @MainActor in self?.pause() } }
|
||||
wc.onResume = { [weak self] in Task { @MainActor in self?.resume() } }
|
||||
wc.onEnd = { [weak self] in Task { @MainActor in self?.endWorkout() } }
|
||||
}
|
||||
|
||||
private func handleStartWorkout(_ p: WatchWorkoutPayload) {
|
||||
payload = p
|
||||
startedAt = Date()
|
||||
|
||||
// Seed from first block
|
||||
if let first = p.blocks.first {
|
||||
totalRoundsInBlock = first.rounds
|
||||
currentExerciseName = first.exercise1Name
|
||||
}
|
||||
phase = p.warmupDuration > 0 ? .warmup : .prep
|
||||
timeRemaining = p.warmupDuration > 0 ? p.warmupDuration : 10
|
||||
currentRound = 1
|
||||
heartRate = 0
|
||||
activeCalories = 0
|
||||
isPaused = false
|
||||
isActive = true
|
||||
|
||||
WKInterfaceDevice.current().play(.start)
|
||||
|
||||
startTimer()
|
||||
Task { await startHealthKit() }
|
||||
}
|
||||
|
||||
/// Snap watch state to phone's authoritative tick.
|
||||
private func handleTick(_ tick: TimerTickPayload) {
|
||||
phase = WatchPhase(rawValue: tick.phase) ?? phase
|
||||
timeRemaining = tick.timeRemaining
|
||||
currentRound = tick.currentRound
|
||||
totalRoundsInBlock = tick.totalRoundsInBlock
|
||||
currentExerciseName = tick.exerciseName
|
||||
}
|
||||
|
||||
// ─── Timer ────────────────────────────────────────────────────────
|
||||
|
||||
private func startTimer() {
|
||||
stopTimer()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in self?.tick() }
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
guard !isPaused else { return }
|
||||
if timeRemaining > 0 {
|
||||
timeRemaining -= 1
|
||||
}
|
||||
// Phase transitions are driven by the phone's timerTick messages;
|
||||
// the local countdown is purely for smooth display.
|
||||
}
|
||||
|
||||
private func pause() {
|
||||
isPaused = true
|
||||
stopTimer()
|
||||
workoutSession?.pause()
|
||||
WKInterfaceDevice.current().play(.stop)
|
||||
}
|
||||
|
||||
private func resume() {
|
||||
isPaused = false
|
||||
startTimer()
|
||||
workoutSession?.resume()
|
||||
WKInterfaceDevice.current().play(.start)
|
||||
}
|
||||
|
||||
// ─── HealthKit ────────────────────────────────────────────────────
|
||||
|
||||
private func startHealthKit() async {
|
||||
guard HKHealthStore.isHealthDataAvailable() else { return }
|
||||
|
||||
let typesToShare: Set<HKSampleType> = [
|
||||
HKQuantityType(.activeEnergyBurned),
|
||||
HKQuantityType(.heartRate),
|
||||
HKObjectType.workoutType()
|
||||
]
|
||||
let typesToRead: Set<HKObjectType> = [
|
||||
HKQuantityType(.heartRate),
|
||||
HKQuantityType(.activeEnergyBurned)
|
||||
]
|
||||
|
||||
do {
|
||||
try await healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead)
|
||||
} catch {
|
||||
return // HealthKit optional — proceed without it
|
||||
}
|
||||
|
||||
let config = HKWorkoutConfiguration()
|
||||
config.activityType = .highIntensityIntervalTraining
|
||||
config.locationType = .indoor
|
||||
|
||||
do {
|
||||
let session = try HKWorkoutSession(healthStore: healthStore, configuration: config)
|
||||
let builder = session.associatedWorkoutBuilder()
|
||||
builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore,
|
||||
workoutConfiguration: config)
|
||||
session.delegate = self
|
||||
builder.delegate = self
|
||||
|
||||
workoutSession = session
|
||||
liveBuilder = builder
|
||||
|
||||
session.startActivity(with: startedAt ?? Date())
|
||||
try await builder.beginCollection(at: startedAt ?? Date())
|
||||
} catch {
|
||||
// Non-fatal — timer still works without HealthKit
|
||||
}
|
||||
}
|
||||
|
||||
private func finalizeHealthKit() async {
|
||||
let endDate = Date()
|
||||
guard let session = workoutSession, let builder = liveBuilder else {
|
||||
sendResultToPhone(endDate: endDate)
|
||||
isActive = false
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await builder.endCollection(at: endDate)
|
||||
let workout = try await builder.finishWorkout()
|
||||
session.end()
|
||||
|
||||
guard let workout else {
|
||||
sendResultToPhone(endDate: endDate)
|
||||
return
|
||||
}
|
||||
|
||||
let avgHR = workout.statistics(for: HKQuantityType(.heartRate))?
|
||||
.averageQuantity()?.doubleValue(for: HKUnit(from: "count/min"))
|
||||
let peakHR = workout.statistics(for: HKQuantityType(.heartRate))?
|
||||
.maximumQuantity()?.doubleValue(for: HKUnit(from: "count/min"))
|
||||
let cal = workout.statistics(for: HKQuantityType(.activeEnergyBurned))?
|
||||
.sumQuantity()?.doubleValue(for: .kilocalorie())
|
||||
|
||||
if let cal { activeCalories = cal }
|
||||
sendResultToPhone(endDate: endDate, avgHR: avgHR, peakHR: peakHR, cal: cal)
|
||||
} catch {
|
||||
sendResultToPhone(endDate: endDate)
|
||||
}
|
||||
|
||||
isActive = false
|
||||
}
|
||||
|
||||
private func sendResultToPhone(endDate: Date,
|
||||
avgHR: Double? = nil,
|
||||
peakHR: Double? = nil,
|
||||
cal: Double? = nil) {
|
||||
let result = WatchSessionResult(
|
||||
programId: payload?.programId ?? "",
|
||||
startedAt: startedAt ?? endDate,
|
||||
completedAt: endDate,
|
||||
durationSeconds: Int(endDate.timeIntervalSince(startedAt ?? endDate)),
|
||||
activeCalories: cal ?? activeCalories,
|
||||
averageHeartRate: avgHR,
|
||||
peakHeartRate: peakHR
|
||||
)
|
||||
wc.sendSessionResult(result)
|
||||
WKInterfaceDevice.current().play(.success)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HKWorkoutSessionDelegate ─────────────────────────────────────────────────
|
||||
|
||||
extension WatchPlayerEngine: HKWorkoutSessionDelegate {
|
||||
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession,
|
||||
didChangeTo toState: HKWorkoutSessionState,
|
||||
from fromState: HKWorkoutSessionState,
|
||||
date: Date) {}
|
||||
|
||||
nonisolated func workoutSession(_ workoutSession: HKWorkoutSession,
|
||||
didFailWithError error: Error) {}
|
||||
}
|
||||
|
||||
// ─── HKLiveWorkoutBuilderDelegate ────────────────────────────────────────────
|
||||
|
||||
extension WatchPlayerEngine: 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 }
|
||||
|
||||
switch quantityType {
|
||||
case HKQuantityType(.heartRate):
|
||||
let hr = workoutBuilder
|
||||
.statistics(for: quantityType)?
|
||||
.mostRecentQuantity()?
|
||||
.doubleValue(for: HKUnit(from: "count/min")) ?? 0
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
self.heartRate = hr
|
||||
self.wc.sendHeartRate(hr)
|
||||
}
|
||||
|
||||
case HKQuantityType(.activeEnergyBurned):
|
||||
let cal = workoutBuilder
|
||||
.statistics(for: quantityType)?
|
||||
.sumQuantity()?
|
||||
.doubleValue(for: .kilocalorie()) ?? 0
|
||||
Task { @MainActor [weak self] in
|
||||
self?.activeCalories = cal
|
||||
}
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
156
tabatago-swift/TabataGoWatch/Views/WatchActivityView.swift
Normal file
156
tabatago-swift/TabataGoWatch/Views/WatchActivityView.swift
Normal file
@@ -0,0 +1,156 @@
|
||||
import SwiftUI
|
||||
import HealthKit
|
||||
|
||||
/// Mini activity/streak summary shown on the Watch when idle.
|
||||
/// Displays today's Move/Exercise/Stand ring progress from HealthKit
|
||||
/// and a streak count stored via App Group UserDefaults (shared with phone).
|
||||
struct WatchActivityView: View {
|
||||
|
||||
@State private var moveProgress: Double = 0 // 0-1
|
||||
@State private var exerciseProgress: Double = 0
|
||||
@State private var standProgress: Double = 0
|
||||
@State private var streak: Int = 0
|
||||
@State private var isLoading = true
|
||||
|
||||
private let healthStore = HKHealthStore()
|
||||
private let sharedDefaults = UserDefaults(suiteName: "group.com.tabatago.app")
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
|
||||
// ── Rings ──────────────────────────────────────────
|
||||
Text("Today")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
RingView(progress: moveProgress,
|
||||
color: Color(red: 1.0, green: 0.23, blue: 0.19),
|
||||
icon: "flame.fill",
|
||||
label: "Move")
|
||||
RingView(progress: exerciseProgress,
|
||||
color: .green,
|
||||
icon: "figure.run",
|
||||
label: "Exercise")
|
||||
RingView(progress: standProgress,
|
||||
color: Color(red: 0.04, green: 0.80, blue: 0.97),
|
||||
icon: "figure.stand",
|
||||
label: "Stand")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// ── Streak ─────────────────────────────────────────
|
||||
HStack {
|
||||
Image(systemName: "bolt.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.font(.system(size: 14))
|
||||
Text("\(streak) day\(streak == 1 ? "" : "s")")
|
||||
.font(.system(size: 14, weight: .bold, design: .rounded))
|
||||
Spacer()
|
||||
Text("streak")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 8)
|
||||
.redacted(reason: isLoading ? .placeholder : [])
|
||||
}
|
||||
.task { await loadData() }
|
||||
}
|
||||
|
||||
// ─── Data loading ─────────────────────────────────────────────────
|
||||
|
||||
private func loadData() async {
|
||||
streak = sharedDefaults?.integer(forKey: "streak") ?? 0
|
||||
|
||||
guard HKHealthStore.isHealthDataAvailable() else {
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
let typesToRead: Set<HKObjectType> = [
|
||||
HKObjectType.activitySummaryType()
|
||||
]
|
||||
|
||||
guard (try? await healthStore.requestAuthorization(toShare: [], read: typesToRead)) != nil else {
|
||||
isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
let calendar = Calendar.current
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
let predicate = HKQuery.predicateForActivitySummary(with: calendar.dateComponents(
|
||||
[.era, .year, .month, .day], from: today))
|
||||
|
||||
let summaries = try? await withCheckedThrowingContinuation { (cont: CheckedContinuation<[HKActivitySummary], Error>) in
|
||||
let query = HKActivitySummaryQuery(predicate: predicate) { _, summaries, error in
|
||||
if let error { cont.resume(throwing: error) }
|
||||
else { cont.resume(returning: summaries ?? []) }
|
||||
}
|
||||
healthStore.execute(query)
|
||||
}
|
||||
|
||||
if let summary = summaries?.first {
|
||||
let moveGoal = summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie())
|
||||
let exerciseGoal = summary.appleExerciseTimeGoal.doubleValue(for: .minute())
|
||||
let standGoal = summary.appleStandHoursGoal.doubleValue(for: .count())
|
||||
|
||||
await MainActor.run {
|
||||
moveProgress = moveGoal > 0
|
||||
? summary.activeEnergyBurned.doubleValue(for: .kilocalorie()) / moveGoal
|
||||
: 0
|
||||
exerciseProgress = exerciseGoal > 0
|
||||
? summary.appleExerciseTime.doubleValue(for: .minute()) / exerciseGoal
|
||||
: 0
|
||||
standProgress = standGoal > 0
|
||||
? summary.appleStandHours.doubleValue(for: .count()) / standGoal
|
||||
: 0
|
||||
isLoading = false
|
||||
}
|
||||
} else {
|
||||
await MainActor.run { isLoading = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
private struct RingView: View {
|
||||
let progress: Double
|
||||
let color: Color
|
||||
let icon: String
|
||||
let label: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
ZStack {
|
||||
// Track
|
||||
Circle()
|
||||
.stroke(color.opacity(0.2), lineWidth: 5)
|
||||
// Fill
|
||||
Circle()
|
||||
.trim(from: 0, to: min(progress, 1.0))
|
||||
.stroke(color, style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeOut(duration: 0.6), value: progress)
|
||||
// Icon
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
WatchActivityView()
|
||||
}
|
||||
43
tabatago-swift/TabataGoWatch/Views/WatchIdleView.swift
Normal file
43
tabatago-swift/TabataGoWatch/Views/WatchIdleView.swift
Normal file
@@ -0,0 +1,43 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Idle state — waiting for a workout to start from the phone.
|
||||
struct WatchIdleView: View {
|
||||
@EnvironmentObject private var connectivity: WatchConnectivityManager
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "bolt.fill")
|
||||
.font(.system(size: 36, weight: .bold))
|
||||
.foregroundStyle(.orange)
|
||||
|
||||
Text("TabataGo")
|
||||
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||||
|
||||
Text("Start a workout\non your iPhone")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if connectivity.isPhoneReachable {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(.green)
|
||||
.frame(width: 6, height: 6)
|
||||
Text("Connected")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(.gray)
|
||||
.frame(width: 6, height: 6)
|
||||
Text("No phone")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
156
tabatago-swift/TabataGoWatch/Views/WatchPlayerView.swift
Normal file
156
tabatago-swift/TabataGoWatch/Views/WatchPlayerView.swift
Normal file
@@ -0,0 +1,156 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Active workout player on Watch — full-screen timer with phase, HR, calories.
|
||||
struct WatchPlayerView: View {
|
||||
@EnvironmentObject private var engine: WatchPlayerEngine
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Phase color background
|
||||
watchPhaseColor(engine.phase)
|
||||
.opacity(0.18)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 4) {
|
||||
|
||||
// ── Phase label ────────────────────────────────────
|
||||
Text(watchPhaseLabel(engine.phase))
|
||||
.font(.system(size: 11, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(watchPhaseColor(engine.phase))
|
||||
.kerning(1.5)
|
||||
|
||||
// ── Timer ──────────────────────────────────────────
|
||||
Text("\(engine.timeRemaining)")
|
||||
.font(.system(size: 52, weight: .black, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.white)
|
||||
.contentTransition(.numericText(countsDown: true))
|
||||
.animation(.spring(duration: 0.3), value: engine.timeRemaining)
|
||||
|
||||
// ── Exercise name ──────────────────────────────────
|
||||
if let name = engine.currentExerciseName {
|
||||
Text(name)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
|
||||
// ── Round pips ─────────────────────────────────────
|
||||
RoundPips(
|
||||
current: engine.currentRound,
|
||||
total: min(engine.totalRoundsInBlock, 8)
|
||||
)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
// ── Live metrics ───────────────────────────────────
|
||||
HStack(spacing: 16) {
|
||||
if engine.heartRate > 0 {
|
||||
WatchMetric(
|
||||
icon: "heart.fill",
|
||||
value: "\(Int(engine.heartRate))",
|
||||
color: .red
|
||||
)
|
||||
}
|
||||
if engine.activeCalories > 0 {
|
||||
WatchMetric(
|
||||
icon: "flame.fill",
|
||||
value: "\(Int(engine.activeCalories))",
|
||||
color: .orange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pause / End controls ───────────────────────────
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
engine.togglePause()
|
||||
} label: {
|
||||
Image(systemName: engine.isPaused ? "play.fill" : "pause.fill")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.frame(width: 36, height: 36)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button(role: .destructive) {
|
||||
engine.endWorkout()
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.frame(width: 36, height: 36)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private func watchPhaseColor(_ phase: WatchPhase) -> Color {
|
||||
switch phase {
|
||||
case .prep, .warmup: return .orange
|
||||
case .work: return Color(red: 1.0, green: 0.42, blue: 0.21)
|
||||
case .rest, .interBlockRest: return Color(red: 0.35, green: 0.78, blue: 0.98)
|
||||
case .cooldown: return .cyan
|
||||
case .complete: return .green
|
||||
}
|
||||
}
|
||||
|
||||
private func watchPhaseLabel(_ phase: WatchPhase) -> 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────
|
||||
|
||||
struct RoundPips: View {
|
||||
let current: Int
|
||||
let total: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(1...max(total, 1), id: \.self) { i in
|
||||
Capsule()
|
||||
.fill(i < current ? Color.orange :
|
||||
i == current ? .white : .white.opacity(0.25))
|
||||
.frame(width: i == current ? 16 : 6, height: 5)
|
||||
.animation(.spring(duration: 0.25), value: current)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WatchMetric: View {
|
||||
let icon: String
|
||||
let value: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(color)
|
||||
Text(value)
|
||||
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
WatchPlayerView()
|
||||
.environmentObject(WatchPlayerEngine())
|
||||
.environmentObject(WatchConnectivityManager.shared)
|
||||
}
|
||||
17
tabatago-swift/TabataGoWatch/Views/WatchRootView.swift
Normal file
17
tabatago-swift/TabataGoWatch/Views/WatchRootView.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Root view: shows idle screen or active player depending on state.
|
||||
struct WatchRootView: View {
|
||||
@EnvironmentObject private var playerEngine: WatchPlayerEngine
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if playerEngine.isActive {
|
||||
WatchPlayerView()
|
||||
} else {
|
||||
WatchIdleView()
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.3), value: playerEngine.isActive)
|
||||
}
|
||||
}
|
||||
181
tabatago-swift/project.yml
Normal file
181
tabatago-swift/project.yml
Normal file
@@ -0,0 +1,181 @@
|
||||
name: TabataGo
|
||||
|
||||
options:
|
||||
bundleIdPrefix: com.tabatago
|
||||
deploymentTarget:
|
||||
iOS: "26.0"
|
||||
watchOS: "11.0"
|
||||
xcodeVersion: "26"
|
||||
generateEmptyDirectories: true
|
||||
createIntermediateGroups: true
|
||||
groupSortPosition: top
|
||||
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "6.0"
|
||||
IPHONEOS_DEPLOYMENT_TARGET: "26.0"
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
DEBUG_INFORMATION_FORMAT: dwarf-with-dsym
|
||||
|
||||
packages:
|
||||
Supabase:
|
||||
url: https://github.com/supabase/supabase-swift
|
||||
from: "2.5.0"
|
||||
RevenueCat:
|
||||
url: https://github.com/RevenueCat/purchases-ios
|
||||
from: "5.0.0"
|
||||
PostHog:
|
||||
url: https://github.com/PostHog/posthog-ios
|
||||
from: "3.0.0"
|
||||
|
||||
targets:
|
||||
TabataGo:
|
||||
type: application
|
||||
platform: iOS
|
||||
deploymentTarget: "26.0"
|
||||
sources:
|
||||
- path: TabataGo
|
||||
excludes:
|
||||
- "**/.DS_Store"
|
||||
resources:
|
||||
- path: TabataGo/Resources
|
||||
excludes:
|
||||
- Info.plist
|
||||
info:
|
||||
path: TabataGo/Resources/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: TabataGo
|
||||
CFBundleShortVersionString: "1.0"
|
||||
CFBundleVersion: "1"
|
||||
UILaunchScreen:
|
||||
UIColorName: ""
|
||||
UIImageName: ""
|
||||
UISupportedInterfaceOrientations:
|
||||
- UIInterfaceOrientationPortrait
|
||||
NSHealthShareUsageDescription: "TabataGo reads your health data to show fitness stats and personalize your workouts."
|
||||
NSHealthUpdateUsageDescription: "TabataGo saves your Tabata workouts to Apple Health to track calories, heart rate, and contribute to your Activity Rings."
|
||||
NSMotionUsageDescription: "TabataGo uses motion data to improve calorie estimates during workouts."
|
||||
SUPABASE_URL: $(SUPABASE_URL)
|
||||
SUPABASE_ANON_KEY: $(SUPABASE_ANON_KEY)
|
||||
REVENUECAT_API_KEY: $(REVENUECAT_API_KEY)
|
||||
POSTHOG_API_KEY: $(POSTHOG_API_KEY)
|
||||
entitlements:
|
||||
path: TabataGo/Resources/TabataGo.entitlements
|
||||
properties:
|
||||
com.apple.developer.healthkit: true
|
||||
com.apple.developer.healthkit.access:
|
||||
- health-records
|
||||
com.apple.security.application-groups:
|
||||
- group.com.tabatago.app
|
||||
dependencies:
|
||||
- package: Supabase
|
||||
product: Supabase
|
||||
- package: RevenueCat
|
||||
product: RevenueCat
|
||||
- package: PostHog
|
||||
product: PostHog
|
||||
- target: TabataGoWatch
|
||||
embed: true
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.tabatago.app
|
||||
INFOPLIST_FILE: TabataGo/Resources/Info.plist
|
||||
CODE_SIGN_ENTITLEMENTS: TabataGo/Resources/TabataGo.entitlements
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
||||
ENABLE_PREVIEWS: YES
|
||||
configFiles:
|
||||
Debug: Config/Secrets.xcconfig
|
||||
Release: Config/Secrets.xcconfig
|
||||
|
||||
TabataGoWatch:
|
||||
type: application
|
||||
platform: watchOS
|
||||
deploymentTarget: "11.0"
|
||||
sources:
|
||||
- path: TabataGoWatch
|
||||
excludes:
|
||||
- "**/.DS_Store"
|
||||
- "Resources/Info.plist"
|
||||
- "Complications/**"
|
||||
# Shared protocol types — referenced by both targets
|
||||
- path: TabataGo/Services/WatchConnectivityTypes.swift
|
||||
group: TabataGoWatch/Services
|
||||
resources:
|
||||
- path: TabataGoWatch/Resources
|
||||
excludes:
|
||||
- Info.plist
|
||||
info:
|
||||
path: TabataGoWatch/Resources/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: TabataGo
|
||||
CFBundleShortVersionString: "1.0"
|
||||
CFBundleVersion: "1"
|
||||
WKApplication: true
|
||||
WKCompanionAppBundleIdentifier: com.tabatago.app
|
||||
NSHealthShareUsageDescription: "TabataGo reads your heart rate and calories during workouts."
|
||||
NSHealthUpdateUsageDescription: "TabataGo saves workout data to Apple Health directly from your Watch."
|
||||
entitlements:
|
||||
path: TabataGoWatch/Resources/TabataGoWatch.entitlements
|
||||
properties:
|
||||
com.apple.developer.healthkit: true
|
||||
com.apple.security.application-groups:
|
||||
- group.com.tabatago.app
|
||||
dependencies:
|
||||
- target: TabataGoWatchWidget
|
||||
embed: true
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.tabatago.app.watchkitapp
|
||||
INFOPLIST_FILE: TabataGoWatch/Resources/Info.plist
|
||||
CODE_SIGN_ENTITLEMENTS: TabataGoWatch/Resources/TabataGoWatch.entitlements
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
WATCHOS_DEPLOYMENT_TARGET: "11.0"
|
||||
TARGETED_DEVICE_FAMILY: "4"
|
||||
ENABLE_PREVIEWS: YES
|
||||
|
||||
TabataGoWatchWidget:
|
||||
type: app-extension
|
||||
platform: watchOS
|
||||
deploymentTarget: "11.0"
|
||||
sources:
|
||||
- path: TabataGoWatch/Complications
|
||||
excludes:
|
||||
- "**/.DS_Store"
|
||||
info:
|
||||
path: TabataGoWatch/Complications/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: TabataGoWidget
|
||||
CFBundleShortVersionString: "1.0"
|
||||
CFBundleVersion: "1"
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.widgetkit-extension
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.tabatago.app.watchkitapp.widget
|
||||
TARGETED_DEVICE_FAMILY: "4"
|
||||
WATCHOS_DEPLOYMENT_TARGET: "11.0"
|
||||
|
||||
TabataGoTests:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
deploymentTarget: "26.0"
|
||||
sources:
|
||||
- TabataGoTests
|
||||
dependencies:
|
||||
- target: TabataGo
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.tabatago.app.tests
|
||||
|
||||
TabataGoUITests:
|
||||
type: bundle.ui-testing
|
||||
platform: iOS
|
||||
deploymentTarget: "26.0"
|
||||
sources:
|
||||
- TabataGoUITests
|
||||
dependencies:
|
||||
- target: TabataGo
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.tabatago.app.uitests
|
||||
Reference in New Issue
Block a user