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:
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())
|
||||
}
|
||||
Reference in New Issue
Block a user