remove Expo project and all related files

Remove the entire Expo/React Native application: routes (app/), source
code (src/), assets, iOS native build, config plugins, StoreKit config,
npm dependencies, TypeScript/ESLint/Vitest configs, and Expo-specific
documentation. The repository now contains only: admin-web, supabase,
youtube-worker, tabatago-swift, docs, scripts, and CI/tooling configs.
This commit is contained in:
Millian Lamiaux
2026-04-21 21:55:00 +02:00
parent 8c90b73d90
commit 89cca25e22
285 changed files with 11212 additions and 44392 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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