feat(i18n): complete internationalization for iOS + watchOS across all views
Migrate every hardcoded Text("...") string to the L10n / LocalizedStringResource
type-safe key system with full en/fr/de/es translations (4 languages).
iOS changes (TabataGo target):
- Strings.swift: ~90 new L10n keys across 13 groups (action, tab, home, zone,
level, programs, programDetail, player, profile, settings, policy, paywall,
health, complete, activity, onboarding, goal)
- Localizable.xcstrings: 145 → 245+ keys with fr/de/es translations
- Model enums: FitnessLevel.label & FitnessGoal.label changed from String to
LocalizedStringResource, backed by L10n.level/goal keys
- Component param types changed to LocalizedStringResource: StatBadge,
SectionHeader, ProfileRow, PolicySection, CompletionStat, FeatureRow,
OnboardingHeader, PrimaryButton, SelectionCard
- All 18 view files updated: HomeTab, ActivityTab, ProgramsTab, ProfileTab,
MainTabView, SettingsView, PolicyViews, CompletionView, BodyZoneView,
ProgramDetailView, PaywallView, OnboardingView, PlayerView
Watch changes (TabataGoWatch target):
- New Localizable.xcstrings: 23 keys with en/fr/de/es (phase labels, idle
state, activity rings, complication strings)
- New WatchL10n.swift: type-safe enum (needs manual Xcode target membership)
- Updated: WatchPlayerView, WatchIdleView, WatchActivityView,
TabataGoComplication (inline LocalizedStringResource for widget target)
Both iOS and watchOS targets build with zero errors.
This commit is contained in:
@@ -33,7 +33,7 @@ struct CompletionView: View {
|
||||
.symbolEffect(.bounce, value: confettiTrigger)
|
||||
.padding(.top, 32)
|
||||
|
||||
Text("Workout Complete!")
|
||||
Text(L10n.complete.title)
|
||||
.font(.system(size: 32, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
@@ -46,33 +46,33 @@ struct CompletionView: View {
|
||||
if let session {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
|
||||
CompletionStat(
|
||||
label: "Duration",
|
||||
label: L10n.complete.duration,
|
||||
value: formatDuration(session.durationSeconds),
|
||||
icon: "clock.fill",
|
||||
color: Theme.rest
|
||||
)
|
||||
CompletionStat(
|
||||
label: "Calories",
|
||||
label: L10n.complete.calories,
|
||||
value: "\(Int(session.caloriesBurned)) kcal",
|
||||
icon: "flame.fill",
|
||||
color: Theme.brand
|
||||
)
|
||||
CompletionStat(
|
||||
label: "Rounds",
|
||||
label: L10n.complete.rounds,
|
||||
value: "\(session.roundsCompleted) / \(session.totalRounds)",
|
||||
icon: "repeat",
|
||||
color: Theme.success
|
||||
)
|
||||
if let hr = session.averageHeartRate {
|
||||
CompletionStat(
|
||||
label: "Avg Heart Rate",
|
||||
label: L10n.complete.avgHeartRate,
|
||||
value: "\(Int(hr)) bpm",
|
||||
icon: "heart.fill",
|
||||
color: .red
|
||||
)
|
||||
} else {
|
||||
CompletionStat(
|
||||
label: "Completion",
|
||||
label: L10n.complete.completion,
|
||||
value: "\(Int(session.completionRate * 100))%",
|
||||
icon: "checkmark.circle.fill",
|
||||
color: Theme.success
|
||||
@@ -90,7 +90,7 @@ struct CompletionView: View {
|
||||
HStack {
|
||||
Image(systemName: "heart.text.square.fill")
|
||||
.foregroundStyle(.red)
|
||||
Text(isSavingToHealth ? "Saving..." : "Save to Apple Health")
|
||||
Text(isSavingToHealth ? L10n.complete.saving : L10n.complete.saveToHealth)
|
||||
.fontWeight(.semibold)
|
||||
Spacer()
|
||||
if isSavingToHealth {
|
||||
@@ -110,7 +110,7 @@ struct CompletionView: View {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Theme.success)
|
||||
Text("Saved to Apple Health")
|
||||
Text(L10n.complete.savedToHealth)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
@@ -124,7 +124,7 @@ struct CompletionView: View {
|
||||
Button {
|
||||
showShareSheet = true
|
||||
} label: {
|
||||
Label("Share Workout", systemImage: "square.and.arrow.up")
|
||||
Label(String(localized: L10n.complete.shareWorkout), systemImage: "square.and.arrow.up")
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
@@ -136,7 +136,7 @@ struct CompletionView: View {
|
||||
Button {
|
||||
onDone()
|
||||
} label: {
|
||||
Text("Back to Home")
|
||||
Text(L10n.complete.backToHome)
|
||||
.font(.headline.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -164,7 +164,6 @@ struct CompletionView: View {
|
||||
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,
|
||||
@@ -202,7 +201,7 @@ struct CompletionView: View {
|
||||
}
|
||||
|
||||
struct CompletionStat: View {
|
||||
let label: String
|
||||
let label: LocalizedStringResource
|
||||
let value: String
|
||||
let icon: String
|
||||
let color: Color
|
||||
|
||||
@@ -86,11 +86,11 @@ struct OnboardingView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var buttonLabel: String {
|
||||
private var buttonLabel: LocalizedStringResource {
|
||||
switch step {
|
||||
case .welcome: return "Get Started"
|
||||
case .ready: return "Start My First Workout"
|
||||
default: return "Continue"
|
||||
case .welcome: return L10n.onboarding.getStarted
|
||||
case .ready: return L10n.onboarding.startFirstWorkout
|
||||
default: return L10n.action.continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,10 +129,10 @@ 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"),
|
||||
private let pills: [(String, LocalizedStringResource)] = [
|
||||
("bolt.fill", L10n.onboarding.pill4MinWorkouts),
|
||||
("house.fill", L10n.onboarding.pillNoEquipment),
|
||||
("mic.fill", L10n.onboarding.pillVoiceGuided),
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
@@ -150,7 +150,7 @@ private struct WelcomeStep: View {
|
||||
.font(.system(size: 44, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("High-intensity Tabata workouts,\ndesigned for real results.")
|
||||
Text(L10n.onboarding.tabataDesc)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -197,10 +197,10 @@ private struct NameStep: View {
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
|
||||
OnboardingHeader(title: "What's your name?", subtitle: "We'll personalise your experience.")
|
||||
OnboardingHeader(title: L10n.onboarding.whatIsYourName, subtitle: L10n.onboarding.whatIsYourNameSubtitle)
|
||||
|
||||
VStack(spacing: 16) {
|
||||
TextField("Enter your name", text: $name)
|
||||
TextField(String(localized: L10n.onboarding.enterName), text: $name)
|
||||
.font(.title2)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding()
|
||||
@@ -238,13 +238,13 @@ private struct LevelStep: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
OnboardingHeader(title: "What's your fitness level?", subtitle: "We'll recommend the right workouts.")
|
||||
OnboardingHeader(title: L10n.onboarding.fitnessLevel, subtitle: L10n.onboarding.fitnessLevelSubtitle)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
ForEach(Array(FitnessLevel.allCases.enumerated()), id: \.element) { i, level in
|
||||
SelectionCard(
|
||||
label: level.label,
|
||||
subtitle: levelDescription(level),
|
||||
subtitle: String(localized: levelDescription(level)),
|
||||
icon: levelIcon(level),
|
||||
isSelected: selection == level,
|
||||
color: Theme.levelColor(level.rawValue)
|
||||
@@ -263,19 +263,19 @@ private struct LevelStep: View {
|
||||
.onAppear { appeared = true }
|
||||
}
|
||||
|
||||
private func levelDescription(_ level: FitnessLevel) -> String {
|
||||
private func levelDescription(_ level: FitnessLevel) -> LocalizedStringResource {
|
||||
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"
|
||||
case .beginner: return L10n.onboarding.levelDesc.beginner
|
||||
case .intermediate: return L10n.onboarding.levelDesc.intermediate
|
||||
case .advanced: return L10n.onboarding.levelDesc.advanced
|
||||
}
|
||||
}
|
||||
|
||||
private func levelIcon(_ level: FitnessLevel) -> String {
|
||||
switch level {
|
||||
case .beginner: return "figure.walk"
|
||||
case .beginner: return "figure.walk"
|
||||
case .intermediate: return "figure.run"
|
||||
case .advanced: return "figure.highintensity.intervaltraining"
|
||||
case .advanced: return "figure.highintensity.intervaltraining"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,13 +287,13 @@ private struct GoalStep: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 32) {
|
||||
Spacer()
|
||||
OnboardingHeader(title: "What's your main goal?", subtitle: "This helps us curate your program.")
|
||||
OnboardingHeader(title: L10n.onboarding.mainGoal, subtitle: L10n.onboarding.mainGoalSubtitle)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
ForEach(Array(FitnessGoal.allCases.enumerated()), id: \.element) { i, goal in
|
||||
SelectionCard(
|
||||
label: goal.label,
|
||||
subtitle: goalDescription(goal),
|
||||
subtitle: String(localized: goalDescription(goal)),
|
||||
icon: goalIcon(goal),
|
||||
isSelected: selection == goal,
|
||||
color: Theme.brand
|
||||
@@ -312,21 +312,21 @@ private struct GoalStep: View {
|
||||
.onAppear { appeared = true }
|
||||
}
|
||||
|
||||
private func goalDescription(_ goal: FitnessGoal) -> String {
|
||||
private func goalDescription(_ goal: FitnessGoal) -> LocalizedStringResource {
|
||||
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"
|
||||
case .weightLoss: return L10n.onboarding.goalDesc.weightLoss
|
||||
case .cardio: return L10n.onboarding.goalDesc.cardio
|
||||
case .strength: return L10n.onboarding.goalDesc.strength
|
||||
case .wellness: return L10n.onboarding.goalDesc.wellness
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
case .cardio: return "heart.fill"
|
||||
case .strength: return "dumbbell.fill"
|
||||
case .wellness: return "leaf.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -341,7 +341,7 @@ private struct FrequencyStep: View {
|
||||
VStack(spacing: 28) {
|
||||
Spacer(minLength: 20)
|
||||
|
||||
OnboardingHeader(title: "How often can you train?", subtitle: "Be realistic — consistency beats intensity.")
|
||||
OnboardingHeader(title: L10n.onboarding.howOften, subtitle: L10n.onboarding.howOftenSubtitle)
|
||||
|
||||
// Frequency picker
|
||||
HStack(spacing: 12) {
|
||||
@@ -352,7 +352,7 @@ private struct FrequencyStep: View {
|
||||
VStack(spacing: 6) {
|
||||
Text("\(n)x")
|
||||
.font(.system(size: 28, weight: .black, design: .rounded))
|
||||
Text("per week")
|
||||
Text(L10n.onboarding.perWeek)
|
||||
.font(.caption)
|
||||
}
|
||||
.foregroundStyle(frequency == n ? .white : .primary)
|
||||
@@ -374,10 +374,10 @@ private struct FrequencyStep: View {
|
||||
|
||||
// Barriers
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Any challenges?")
|
||||
Text(L10n.onboarding.anyChallenges)
|
||||
.font(.headline)
|
||||
.padding(.horizontal, 24)
|
||||
Text("Optional — helps us personalise tips")
|
||||
Text(L10n.onboarding.challengesSubtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 24)
|
||||
@@ -441,7 +441,7 @@ private struct ReadyStep: View {
|
||||
|
||||
VStack(spacing: 14) {
|
||||
if name.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
Text("You're all set!")
|
||||
Text(L10n.onboarding.allSet)
|
||||
.font(.system(size: 34, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -453,7 +453,7 @@ private struct ReadyStep: View {
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Text("Your personalised Tabata plan is ready.")
|
||||
Text(L10n.onboarding.allSetSubtitle)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -472,8 +472,8 @@ private struct ReadyStep: View {
|
||||
// ─── Reusable components ──────────────────────────────────────────
|
||||
|
||||
struct OnboardingHeader: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let title: LocalizedStringResource
|
||||
let subtitle: LocalizedStringResource
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
@@ -491,7 +491,7 @@ struct OnboardingHeader: View {
|
||||
}
|
||||
|
||||
struct SelectionCard: View {
|
||||
let label: String
|
||||
let label: LocalizedStringResource
|
||||
let subtitle: String
|
||||
let icon: String
|
||||
let isSelected: Bool
|
||||
@@ -544,7 +544,7 @@ struct SelectionCard: View {
|
||||
}
|
||||
|
||||
struct PrimaryButton: View {
|
||||
let label: String
|
||||
let label: LocalizedStringResource
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -37,20 +37,28 @@ struct PaywallView: View {
|
||||
)
|
||||
.symbolEffect(.bounce, value: vm.isPurchasing)
|
||||
|
||||
Text("TabataGo Premium")
|
||||
Text(L10n.paywall.title)
|
||||
.font(.system(size: 32, weight: .black, design: .rounded))
|
||||
.foregroundStyle(.primary)
|
||||
Text("Unlock every workout, every week.")
|
||||
Text(L10n.paywall.subtitle)
|
||||
.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")
|
||||
FeatureRow(icon: "bolt.fill", color: Theme.brand,
|
||||
title: L10n.paywall.unlimitedWorkouts,
|
||||
subtitle: L10n.paywall.unlimitedWorkoutsDesc)
|
||||
FeatureRow(icon: "heart.fill", color: .red,
|
||||
title: L10n.paywall.healthkitSync,
|
||||
subtitle: L10n.paywall.healthkitSyncDesc)
|
||||
FeatureRow(icon: "icloud.fill", color: Theme.rest,
|
||||
title: L10n.paywall.progressSync,
|
||||
subtitle: L10n.paywall.progressSyncDesc)
|
||||
FeatureRow(icon: "waveform", color: Theme.success,
|
||||
title: L10n.paywall.voiceCoaching,
|
||||
subtitle: L10n.paywall.voiceCoachingDesc)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
@@ -78,7 +86,7 @@ struct PaywallView: View {
|
||||
} label: {
|
||||
HStack {
|
||||
if vm.isPurchasing { ProgressView().tint(.white) }
|
||||
Text(vm.isPurchasing ? "Processing..." : "Start Premium")
|
||||
Text(vm.isPurchasing ? L10n.paywall.processing : L10n.paywall.startPremium)
|
||||
.font(.headline.weight(.bold))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
@@ -96,12 +104,12 @@ struct PaywallView: View {
|
||||
Button {
|
||||
Task { await vm.restorePurchases() }
|
||||
} label: {
|
||||
Text("Restore Purchases")
|
||||
Text(L10n.action.restorePurchases)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text("Cancel anytime. Prices in your local currency.")
|
||||
Text(L10n.paywall.cancelAnytime)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -116,10 +124,10 @@ struct PaywallView: View {
|
||||
if succeeded { dismiss() }
|
||||
}
|
||||
.onAppear { AnalyticsService.shared.paywallViewed(source: "paywall_sheet") }
|
||||
.alert("Error", isPresented: $vm.showError) {
|
||||
Button("OK") {}
|
||||
.alert(String(localized: L10n.paywall.error), isPresented: $vm.showError) {
|
||||
Button(String(localized: L10n.action.done)) {}
|
||||
} message: {
|
||||
Text(vm.errorMessage ?? "Something went wrong.")
|
||||
Text(vm.errorMessage ?? String(localized: L10n.paywall.somethingWrong))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,8 +135,8 @@ struct PaywallView: View {
|
||||
struct FeatureRow: View {
|
||||
let icon: String
|
||||
let color: Color
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let title: LocalizedStringResource
|
||||
let subtitle: LocalizedStringResource
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 14) {
|
||||
@@ -168,7 +176,7 @@ struct PackageCard: View {
|
||||
Text(package.storeProduct.localizedTitle)
|
||||
.font(.headline.weight(.semibold))
|
||||
if isYearly {
|
||||
Text("BEST VALUE")
|
||||
Text(L10n.paywall.bestValue)
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
|
||||
@@ -122,14 +122,14 @@ struct PlayerView: View {
|
||||
CompletionView(session: vm.completedSession, program: program, onDone: { dismiss() })
|
||||
.navigationBarBackButtonHidden()
|
||||
}
|
||||
.alert("End Workout?", isPresented: $vm.showExitConfirmation) {
|
||||
Button("End Workout", role: .destructive) {
|
||||
.alert(String(localized: L10n.player.endWorkout), isPresented: $vm.showExitConfirmation) {
|
||||
Button(String(localized: L10n.player.endWorkout), role: .destructive) {
|
||||
vm.abandonWorkout()
|
||||
dismiss()
|
||||
}
|
||||
Button("Keep Going", role: .cancel) {}
|
||||
Button(String(localized: L10n.player.keepGoing), role: .cancel) {}
|
||||
} message: {
|
||||
Text("Your progress will not be saved.")
|
||||
Text(L10n.player.endWorkoutMessage)
|
||||
}
|
||||
} // NavigationStack
|
||||
}
|
||||
@@ -182,7 +182,7 @@ struct PlayerTopBar: View {
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
Text("Block \(block) of \(totalBlocks)")
|
||||
Text(String(format: String(localized: L10n.programDetail.blockOfFmt), block, totalBlocks))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ struct BodyZoneView: View {
|
||||
|
||||
private var zoneTitle: String {
|
||||
switch zone {
|
||||
case "upper-body": return "Upper Body"
|
||||
case "lower-body": return "Lower Body"
|
||||
case "full-body": return "Full Body"
|
||||
case "upper-body": return String(localized: L10n.zone.upper)
|
||||
case "lower-body": return String(localized: L10n.zone.lower)
|
||||
case "full-body": return String(localized: L10n.zone.full)
|
||||
default: return zone.replacingOccurrences(of: "-", with: " ").capitalized
|
||||
}
|
||||
}
|
||||
@@ -29,13 +29,13 @@ struct BodyZoneView: View {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Failed to load programs")
|
||||
Text(L10n.home.failedToLoad)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Retry") { Task { await vm.refresh() } }
|
||||
Button(String(localized: L10n.action.retry)) { Task { await vm.refresh() } }
|
||||
.buttonStyle(.bordered)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
@@ -43,7 +43,7 @@ struct BodyZoneView: View {
|
||||
.listRowBackground(Color.clear)
|
||||
} else if programs.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Programs Yet",
|
||||
String(localized: L10n.programs.noneFound),
|
||||
systemImage: "dumbbell",
|
||||
description: Text("Programs for \(zoneTitle) are coming soon.")
|
||||
)
|
||||
|
||||
@@ -32,9 +32,9 @@ struct ProgramDetailView: View {
|
||||
.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")
|
||||
Label(String(format: String(localized: L10n.programDetail.minFmt), program.estimatedDuration), systemImage: "clock.fill")
|
||||
Label(String(format: String(localized: L10n.programDetail.kcalFmt), program.estimatedCalories), systemImage: "flame.fill")
|
||||
Label(String(format: String(localized: L10n.programDetail.roundsFmt), program.totalRounds), systemImage: "repeat")
|
||||
}
|
||||
.font(.subheadline.weight(.medium))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
@@ -53,7 +53,7 @@ struct ProgramDetailView: View {
|
||||
|
||||
// ── Warmup ─────────────────────────────────
|
||||
if !program.warmup.movements.isEmpty {
|
||||
ExerciseSection(title: "Warm Up", icon: "figure.cooldown", color: Theme.prep) {
|
||||
ExerciseSection(title: String(localized: L10n.programDetail.warmUp), 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)
|
||||
}
|
||||
@@ -63,10 +63,10 @@ struct ProgramDetailView: View {
|
||||
// ── Tabata Blocks ──────────────────────────
|
||||
ForEach(Array(program.blocks.enumerated()), id: \.offset) { i, block in
|
||||
ExerciseSection(
|
||||
title: "Block \(i + 1)",
|
||||
title: String(format: String(localized: L10n.programDetail.blockFmt), i + 1),
|
||||
icon: "bolt.fill",
|
||||
color: Theme.brand,
|
||||
subtitle: "\(block.rounds) rounds · \(block.workTime)s work / \(block.restTime)s rest"
|
||||
subtitle: String(format: String(localized: L10n.programDetail.blockSubtitleFmt), block.rounds, block.workTime, block.restTime)
|
||||
) {
|
||||
ExerciseRow(
|
||||
name: block.exercise1.nameEn,
|
||||
@@ -86,7 +86,7 @@ struct ProgramDetailView: View {
|
||||
|
||||
// ── Cooldown ───────────────────────────────
|
||||
if !program.cooldown.movements.isEmpty {
|
||||
ExerciseSection(title: "Cool Down", icon: "snowflake", color: Theme.rest) {
|
||||
ExerciseSection(title: String(localized: L10n.programDetail.coolDown), icon: "snowflake", color: Theme.rest) {
|
||||
ForEach(program.cooldown.movements, id: \.name) { move in
|
||||
ExerciseRow(name: move.nameEn, duration: "\(move.duration)s", color: Theme.rest)
|
||||
}
|
||||
@@ -120,7 +120,7 @@ struct ProgramDetailView: View {
|
||||
if !canAccess {
|
||||
Image(systemName: "lock.fill")
|
||||
}
|
||||
Text(canAccess ? "Start Workout" : "Unlock Premium")
|
||||
Text(canAccess ? String(localized: L10n.programDetail.startWorkout) : String(localized: L10n.programDetail.unlockPremium))
|
||||
.font(.headline.weight(.bold))
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
|
||||
@@ -5,22 +5,22 @@ struct PrivacyPolicyView: View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Group {
|
||||
PolicySection(title: "Data We Collect") {
|
||||
PolicySection(title: L10n.policy.dataWeCollect) {
|
||||
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") {
|
||||
PolicySection(title: L10n.policy.appleHealth) {
|
||||
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") {
|
||||
PolicySection(title: L10n.policy.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") {
|
||||
PolicySection(title: L10n.policy.purchases) {
|
||||
Text("Subscription purchases are handled by Apple's App Store and RevenueCat. We do not store your payment information.")
|
||||
}
|
||||
PolicySection(title: "Data Storage") {
|
||||
PolicySection(title: L10n.policy.dataStorage) {
|
||||
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") {
|
||||
PolicySection(title: L10n.policy.contact) {
|
||||
Text("For privacy concerns, contact us at privacy@tabatago.app")
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ struct PrivacyPolicyView: View {
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
.navigationTitle("Privacy Policy")
|
||||
.navigationTitle(String(localized: L10n.policy.privacyTitle))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
}
|
||||
}
|
||||
@@ -38,19 +38,19 @@ struct TermsOfServiceView: View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Group {
|
||||
PolicySection(title: "Use of the App") {
|
||||
PolicySection(title: L10n.policy.useOfApp) {
|
||||
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") {
|
||||
PolicySection(title: L10n.policy.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") {
|
||||
PolicySection(title: L10n.policy.healthDisclaimer) {
|
||||
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") {
|
||||
PolicySection(title: L10n.policy.limitationOfLiability) {
|
||||
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") {
|
||||
PolicySection(title: L10n.policy.changesToTerms) {
|
||||
Text("We may update these terms at any time. Continued use of the app after changes constitutes acceptance of the new terms.")
|
||||
}
|
||||
}
|
||||
@@ -58,13 +58,13 @@ struct TermsOfServiceView: View {
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
.navigationTitle("Terms of Service")
|
||||
.navigationTitle(String(localized: L10n.policy.termsTitle))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
}
|
||||
}
|
||||
|
||||
struct PolicySection<Content: View>: View {
|
||||
let title: String
|
||||
let title: LocalizedStringResource
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -12,19 +12,19 @@ struct SettingsView: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
// ── Audio ──────────────────────────────────────────────
|
||||
Section("Audio") {
|
||||
Section(String(localized: L10n.settings.audio)) {
|
||||
if let profile {
|
||||
Toggle("Sound Effects", isOn: Binding(
|
||||
Toggle(String(localized: L10n.settings.soundEffects), isOn: Binding(
|
||||
get: { profile.soundEffectsEnabled },
|
||||
set: { profile.soundEffectsEnabled = $0; save() }
|
||||
))
|
||||
|
||||
Toggle("Voice Coaching", isOn: Binding(
|
||||
Toggle(String(localized: L10n.settings.voiceCoaching), isOn: Binding(
|
||||
get: { profile.voiceCoachingEnabled },
|
||||
set: { profile.voiceCoachingEnabled = $0; save() }
|
||||
))
|
||||
|
||||
Toggle("Music", isOn: Binding(
|
||||
Toggle(String(localized: L10n.settings.music), isOn: Binding(
|
||||
get: { profile.musicEnabled },
|
||||
set: { profile.musicEnabled = $0; save() }
|
||||
))
|
||||
@@ -45,9 +45,9 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
// ── Haptics ────────────────────────────────────────────
|
||||
Section("Haptics") {
|
||||
Section(String(localized: L10n.settings.haptics)) {
|
||||
if let profile {
|
||||
Toggle("Haptic Feedback", isOn: Binding(
|
||||
Toggle(String(localized: L10n.settings.hapticFeedback), isOn: Binding(
|
||||
get: { profile.hapticsEnabled },
|
||||
set: { profile.hapticsEnabled = $0; save() }
|
||||
))
|
||||
@@ -55,16 +55,16 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
// ── Reminders ─────────────────────────────────────────
|
||||
Section("Reminders") {
|
||||
Section(String(localized: L10n.settings.reminders)) {
|
||||
if let profile {
|
||||
Toggle("Daily Reminder", isOn: Binding(
|
||||
Toggle(String(localized: L10n.settings.dailyReminder), isOn: Binding(
|
||||
get: { profile.remindersEnabled },
|
||||
set: { profile.remindersEnabled = $0; save() }
|
||||
))
|
||||
|
||||
if profile.remindersEnabled {
|
||||
DatePicker(
|
||||
"Reminder Time",
|
||||
String(localized: L10n.settings.reminderTime),
|
||||
selection: Binding(
|
||||
get: {
|
||||
var c = DateComponents()
|
||||
@@ -86,12 +86,12 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
// ── HealthKit ─────────────────────────────────────────
|
||||
Section("Apple Health") {
|
||||
Section(String(localized: L10n.health.appleHealth)) {
|
||||
Button {
|
||||
Task { try? await HealthKitService.shared.requestAuthorization() }
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Manage Health Permissions", systemImage: "heart.text.square")
|
||||
Label(String(localized: L10n.settings.manageHealth), systemImage: "heart.text.square")
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.caption)
|
||||
@@ -102,16 +102,18 @@ struct SettingsView: View {
|
||||
}
|
||||
|
||||
// ── Account ────────────────────────────────────────────
|
||||
Section("Account") {
|
||||
Section(String(localized: L10n.settings.account)) {
|
||||
if let profile {
|
||||
HStack {
|
||||
Text("Name")
|
||||
Text(String(localized: L10n.settings.name))
|
||||
Spacer()
|
||||
Text(profile.name.isEmpty ? "Not set" : profile.name)
|
||||
Text(profile.name.isEmpty
|
||||
? String(localized: L10n.settings.notSet)
|
||||
: profile.name)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
HStack {
|
||||
Text("Joined")
|
||||
Text(String(localized: L10n.settings.joined))
|
||||
Spacer()
|
||||
Text(profile.joinDate.formatted(date: .abbreviated, time: .omitted))
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -121,30 +123,30 @@ struct SettingsView: View {
|
||||
Button(role: .destructive) {
|
||||
showingResetAlert = true
|
||||
} label: {
|
||||
Label("Reset All Progress", systemImage: "trash")
|
||||
Label(String(localized: L10n.settings.resetProgress), systemImage: "trash")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
// ── About ──────────────────────────────────────────────
|
||||
Section("About") {
|
||||
Section(String(localized: L10n.settings.about)) {
|
||||
HStack {
|
||||
Text("Version")
|
||||
Text(String(localized: L10n.settings.version))
|
||||
Spacer()
|
||||
Text(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
NavigationLink("Privacy Policy") { PrivacyPolicyView() }
|
||||
NavigationLink("Terms of Service") { TermsOfServiceView() }
|
||||
NavigationLink(String(localized: L10n.policy.privacyTitle)) { PrivacyPolicyView() }
|
||||
NavigationLink(String(localized: L10n.policy.termsTitle)) { TermsOfServiceView() }
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.navigationTitle(String(localized: L10n.settings.title))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.alert("Reset All Progress?", isPresented: $showingResetAlert) {
|
||||
Button("Reset", role: .destructive) { resetProgress() }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
.alert(String(localized: L10n.settings.resetProgressConfirm), isPresented: $showingResetAlert) {
|
||||
Button(String(localized: L10n.settings.confirmReset), role: .destructive) { resetProgress() }
|
||||
Button(String(localized: L10n.action.cancel), role: .cancel) {}
|
||||
} message: {
|
||||
Text("This will permanently delete your workout history and streak. This cannot be undone.")
|
||||
Text(L10n.settings.resetProgressMessage)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,14 +30,14 @@ struct ActivityTab: View {
|
||||
|
||||
// ── Weekly Summary ─────────────────────────────
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("This Week")
|
||||
Text(L10n.home.thisWeek)
|
||||
.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")
|
||||
StatBadge(label: L10n.activity.workouts, value: "\(weeklyCount)", color: Theme.brand, icon: "flame.fill")
|
||||
StatBadge(label: L10n.activity.minutes, value: "\(weeklyMinutes)", color: Theme.rest, icon: "clock.fill")
|
||||
StatBadge(label: L10n.activity.calories, value: "\(Int(weeklyCalories))", color: Theme.success, icon: "bolt.fill")
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
@@ -45,7 +45,7 @@ struct ActivityTab: View {
|
||||
// ── Workout History ────────────────────────────
|
||||
if !sessions.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("History")
|
||||
Text(L10n.activity.history)
|
||||
.font(.title3.weight(.bold))
|
||||
.padding(.horizontal)
|
||||
|
||||
@@ -65,7 +65,7 @@ struct ActivityTab: View {
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.navigationTitle("Activity")
|
||||
.navigationTitle(String(localized: L10n.tab.activity))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.task { await healthVM.refresh() }
|
||||
.refreshable { await healthVM.refresh() }
|
||||
@@ -97,11 +97,11 @@ struct StreakBanner: View {
|
||||
.font(.system(size: 52, weight: .black, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Theme.brand)
|
||||
Text("days")
|
||||
Text(L10n.activity.days)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Current Streak")
|
||||
Text(L10n.activity.currentStreak)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -116,11 +116,11 @@ struct StreakBanner: View {
|
||||
.font(.system(size: 52, weight: .black, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(Theme.success)
|
||||
Text("days")
|
||||
Text(L10n.activity.days)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Best Streak")
|
||||
Text(L10n.activity.bestStreak)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -139,29 +139,29 @@ struct HealthRingsCard: View {
|
||||
HStack {
|
||||
Image(systemName: "heart.fill")
|
||||
.foregroundStyle(.red)
|
||||
Text("Apple Health")
|
||||
Text(L10n.health.appleHealth)
|
||||
.font(.headline.weight(.semibold))
|
||||
Spacer()
|
||||
Text("Today")
|
||||
Text(L10n.health.today)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
HealthRingStat(
|
||||
label: "Move",
|
||||
label: L10n.health.move,
|
||||
value: "\(Int(snapshot.activeCaloricBurn))",
|
||||
unit: "kcal",
|
||||
color: .red
|
||||
)
|
||||
HealthRingStat(
|
||||
label: "Exercise",
|
||||
label: L10n.health.exercise,
|
||||
value: "\(Int(snapshot.exerciseMinutes))",
|
||||
unit: "min",
|
||||
color: .green
|
||||
)
|
||||
HealthRingStat(
|
||||
label: "Stand",
|
||||
label: L10n.health.stand,
|
||||
value: "\(snapshot.standHours)",
|
||||
unit: "hrs",
|
||||
color: .cyan
|
||||
@@ -175,7 +175,7 @@ struct HealthRingsCard: View {
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text("Resting HR")
|
||||
Text(L10n.health.restingHR)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
@@ -187,7 +187,7 @@ struct HealthRingsCard: View {
|
||||
}
|
||||
|
||||
struct HealthRingStat: View {
|
||||
let label: String
|
||||
let label: LocalizedStringResource
|
||||
let value: String
|
||||
let unit: String
|
||||
let color: Color
|
||||
@@ -252,9 +252,9 @@ struct EmptyActivityView: View {
|
||||
Image(systemName: "figure.run.circle")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(Theme.brand.opacity(0.6))
|
||||
Text("No workouts yet")
|
||||
Text(L10n.activity.noWorkouts)
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Complete your first Tabata to see your activity here.")
|
||||
Text(L10n.activity.noWorkoutsMessage)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -47,15 +47,15 @@ struct HomeTab: View {
|
||||
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")
|
||||
StatBadge(label: L10n.home.streak, value: "\(currentStreak)d", color: Theme.brand, icon: "flame.fill")
|
||||
StatBadge(label: L10n.home.thisWeek, value: "\(weeklyCount)", color: Theme.success, icon: "checkmark.circle.fill")
|
||||
StatBadge(label: L10n.home.allTime, value: "\(totalCount)", color: Theme.rest, icon: "trophy.fill")
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// ── Body Zone Grid ──
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SectionHeader(title: "Browse by Zone", subtitle: "Target specific muscle groups")
|
||||
SectionHeader(title: L10n.home.browseTitle, subtitle: L10n.home.browseSubtitle)
|
||||
.padding(.horizontal)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
@@ -72,7 +72,7 @@ struct HomeTab: View {
|
||||
// ── Featured Workouts ──
|
||||
if !vm.featuredPrograms.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
SectionHeader(title: "Featured", subtitle: "Handpicked for you")
|
||||
SectionHeader(title: L10n.home.featuredTitle, subtitle: L10n.home.featuredSubtitle)
|
||||
.padding(.horizontal)
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
@@ -96,13 +96,13 @@ struct HomeTab: View {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Failed to load programs")
|
||||
Text(L10n.home.failedToLoad)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Retry") { Task { await vm.refresh() } }
|
||||
Button(String(localized: L10n.action.retry)) { Task { await vm.refresh() } }
|
||||
.buttonStyle(.bordered)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
@@ -128,8 +128,8 @@ struct HomeTab: View {
|
||||
// ─── Sub-components ───────────────────────────────────────────────
|
||||
|
||||
struct SectionHeader: View {
|
||||
let title: String
|
||||
var subtitle: String? = nil
|
||||
let title: LocalizedStringResource
|
||||
var subtitle: LocalizedStringResource? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
@@ -185,7 +185,7 @@ struct FeaturedProgramCard: View {
|
||||
Label("\(program.estimatedDuration)m", systemImage: "clock.fill")
|
||||
Label("\(program.estimatedCalories) kcal", systemImage: "flame.fill")
|
||||
if program.isFree {
|
||||
Label("Free", systemImage: "checkmark.seal.fill")
|
||||
Label(String(localized: L10n.home.free), systemImage: "checkmark.seal.fill")
|
||||
.foregroundStyle(Theme.success)
|
||||
}
|
||||
}
|
||||
@@ -220,7 +220,6 @@ struct ZoneCard: View {
|
||||
Spacer(minLength: 0)
|
||||
HStack(spacing: 4) {
|
||||
Text("Explore")
|
||||
.font(.caption.weight(.semibold))
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
@@ -238,22 +237,17 @@ struct ZoneCard: View {
|
||||
.frame(height: 140)
|
||||
}
|
||||
|
||||
private var zoneLabel: String {
|
||||
private var zoneLabel: LocalizedStringResource {
|
||||
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
|
||||
case "upper-body": return L10n.zone.upper
|
||||
case "lower-body": return L10n.zone.lower
|
||||
case "full-body": return L10n.zone.full
|
||||
default: return LocalizedStringResource(stringLiteral: 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 zoneDescription: LocalizedStringResource {
|
||||
L10n.zone.description(for: zone)
|
||||
}
|
||||
|
||||
private var zoneIcon: String {
|
||||
|
||||
@@ -16,29 +16,29 @@ struct MainTabView: View {
|
||||
}
|
||||
}
|
||||
|
||||
var label: String {
|
||||
var label: LocalizedStringResource {
|
||||
switch self {
|
||||
case .home: return "Home"
|
||||
case .programs: return "Programs"
|
||||
case .activity: return "Activity"
|
||||
case .profile: return "Profile"
|
||||
case .home: return L10n.tab.home
|
||||
case .programs: return L10n.tab.programs
|
||||
case .activity: return L10n.tab.activity
|
||||
case .profile: return L10n.tab.profile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
Tab(AppTab.home.label, systemImage: AppTab.home.icon, value: AppTab.home) {
|
||||
HomeTab()
|
||||
Tab(value: AppTab.home) { HomeTab() } label: {
|
||||
Label(AppTab.home.label, systemImage: AppTab.home.icon)
|
||||
}
|
||||
Tab(AppTab.programs.label, systemImage: AppTab.programs.icon, value: AppTab.programs) {
|
||||
ProgramsTab()
|
||||
Tab(value: AppTab.programs) { ProgramsTab() } label: {
|
||||
Label(AppTab.programs.label, systemImage: AppTab.programs.icon)
|
||||
}
|
||||
Tab(AppTab.activity.label, systemImage: AppTab.activity.icon, value: AppTab.activity) {
|
||||
ActivityTab()
|
||||
Tab(value: AppTab.activity) { ActivityTab() } label: {
|
||||
Label(AppTab.activity.label, systemImage: AppTab.activity.icon)
|
||||
}
|
||||
Tab(AppTab.profile.label, systemImage: AppTab.profile.icon, value: AppTab.profile) {
|
||||
ProfileTab()
|
||||
Tab(value: AppTab.profile) { ProfileTab() } label: {
|
||||
Label(AppTab.profile.label, systemImage: AppTab.profile.icon)
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.sidebarAdaptable)
|
||||
|
||||
@@ -28,12 +28,14 @@ struct ProfileTab: View {
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(profile?.name ?? "Athlete")
|
||||
Text(profile?.name ?? String(localized: L10n.profile.athlete))
|
||||
.font(.title3.weight(.bold))
|
||||
Text(profile?.goal.label ?? "")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Joined \(profile?.joinDate.formatted(date: .abbreviated, time: .omitted) ?? "")")
|
||||
if let goal = profile?.goal {
|
||||
Text(goal.label)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(String(format: String(localized: L10n.profile.joinedFmt), profile?.joinDate.formatted(date: .abbreviated, time: .omitted) ?? ""))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
@@ -43,13 +45,15 @@ struct ProfileTab: View {
|
||||
}
|
||||
|
||||
// ── Subscription ──────────────────────────────────
|
||||
Section("Subscription") {
|
||||
Section(String(localized: L10n.profile.subscription)) {
|
||||
if profile?.subscription.isPremium == true {
|
||||
HStack {
|
||||
Label("Premium Active", systemImage: "crown.fill")
|
||||
Label(String(localized: L10n.paywall.premiumActive), systemImage: "crown.fill")
|
||||
.foregroundStyle(Theme.brand)
|
||||
Spacer()
|
||||
Text(profile?.subscription == .premiumYearly ? "Yearly" : "Monthly")
|
||||
Text(profile?.subscription == .premiumYearly
|
||||
? String(localized: L10n.profile.yearly)
|
||||
: String(localized: L10n.profile.monthly))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -58,7 +62,7 @@ struct ProfileTab: View {
|
||||
showingPaywall = true
|
||||
} label: {
|
||||
HStack {
|
||||
Label("Upgrade to Premium", systemImage: "crown")
|
||||
Label(String(localized: L10n.paywall.upgradePrompt), systemImage: "crown")
|
||||
.foregroundStyle(Theme.brand)
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
@@ -71,36 +75,36 @@ struct ProfileTab: View {
|
||||
}
|
||||
|
||||
// ── 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")
|
||||
Section(String(localized: L10n.profile.fitnessProfile)) {
|
||||
ProfileRow(label: L10n.profile.level, value: String(localized: profile?.fitnessLevel.label ?? L10n.level.beginner), icon: "chart.bar")
|
||||
ProfileRow(label: L10n.profile.goal, value: String(localized: profile?.goal.label ?? L10n.goal.cardio), icon: "target")
|
||||
ProfileRow(label: L10n.profile.weeklyGoal, value: String(format: String(localized: L10n.profile.weeklyGoalFmt), profile?.weeklyFrequency ?? 3), icon: "calendar")
|
||||
}
|
||||
|
||||
// ── Settings ──────────────────────────────────────
|
||||
Section {
|
||||
NavigationLink(destination: SettingsView()) {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
Label(String(localized: L10n.settings.title), systemImage: "gearshape")
|
||||
}
|
||||
NavigationLink(destination: PrivacyPolicyView()) {
|
||||
Label("Privacy Policy", systemImage: "hand.raised")
|
||||
Label(String(localized: L10n.policy.privacyTitle), systemImage: "hand.raised")
|
||||
}
|
||||
NavigationLink(destination: TermsOfServiceView()) {
|
||||
Label("Terms of Service", systemImage: "doc.text")
|
||||
Label(String(localized: L10n.policy.termsTitle), systemImage: "doc.text")
|
||||
}
|
||||
}
|
||||
|
||||
// ── App Info ──────────────────────────────────────
|
||||
Section {
|
||||
HStack {
|
||||
Text("Version")
|
||||
Text(String(localized: L10n.settings.version))
|
||||
Spacer()
|
||||
Text(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Profile")
|
||||
.navigationTitle(String(localized: L10n.tab.profile))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.sheet(isPresented: $showingPaywall, onDismiss: syncSubscription) {
|
||||
PaywallView()
|
||||
@@ -118,13 +122,13 @@ struct ProfileTab: View {
|
||||
}
|
||||
|
||||
struct ProfileRow: View {
|
||||
let label: String
|
||||
let label: LocalizedStringResource
|
||||
let value: String
|
||||
let icon: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Label(label, systemImage: icon)
|
||||
Label(String(localized: label), systemImage: icon)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -25,7 +25,7 @@ struct ProgramsTab: View {
|
||||
|
||||
/// Label for the level toolbar button.
|
||||
private var levelMenuLabel: String {
|
||||
selectedLevel ?? "All Levels"
|
||||
selectedLevel ?? String(localized: L10n.programs.allLevels)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -33,8 +33,8 @@ struct ProgramsTab: View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
// ── Zone Segmented Control ────────────────────
|
||||
Picker("Body Zone", selection: $selectedZone) {
|
||||
Text("All").tag(String?.none)
|
||||
Picker(String(localized: L10n.programs.filterZone), selection: $selectedZone) {
|
||||
Text(L10n.programs.all).tag(String?.none)
|
||||
ForEach(zones, id: \.self) { zone in
|
||||
Text(zone.replacingOccurrences(of: "-", with: " ").capitalized)
|
||||
.tag(Optional(zone))
|
||||
@@ -48,7 +48,7 @@ struct ProgramsTab: View {
|
||||
ProgressView().frame(minHeight: 120)
|
||||
} else if filtered.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Programs Found",
|
||||
String(localized: L10n.programs.noneFound),
|
||||
systemImage: "magnifyingglass",
|
||||
description: Text("Try changing your filters.")
|
||||
)
|
||||
@@ -67,16 +67,16 @@ struct ProgramsTab: View {
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.navigationTitle("Programs")
|
||||
.navigationTitle(String(localized: L10n.tab.programs))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.searchable(text: $searchText, prompt: "Search workouts...")
|
||||
.searchable(text: $searchText, prompt: String(localized: L10n.programs.searchPrompt))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button {
|
||||
selectedLevel = nil
|
||||
} label: {
|
||||
Label("All Levels", systemImage: selectedLevel == nil ? "checkmark" : "")
|
||||
Label(String(localized: L10n.programs.allLevels), systemImage: selectedLevel == nil ? "checkmark" : "")
|
||||
}
|
||||
Divider()
|
||||
ForEach(levels, id: \.self) { level in
|
||||
|
||||
Reference in New Issue
Block a user