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:
Millian Lamiaux
2026-04-22 00:41:19 +02:00
parent e28bebea79
commit 0f5b7b9e18
23 changed files with 4423 additions and 340 deletions

View File

@@ -6,11 +6,11 @@ import Foundation
enum FitnessLevel: String, Codable, CaseIterable {
case beginner, intermediate, advanced
var label: String {
var label: LocalizedStringResource {
switch self {
case .beginner: "Beginner"
case .intermediate: "Intermediate"
case .advanced: "Advanced"
case .beginner: L10n.level.beginner
case .intermediate: L10n.level.intermediate
case .advanced: L10n.level.advanced
}
}
}
@@ -19,12 +19,12 @@ enum FitnessGoal: String, Codable, CaseIterable {
case weightLoss = "weight-loss"
case cardio, strength, wellness
var label: String {
var label: LocalizedStringResource {
switch self {
case .weightLoss: "Weight Loss"
case .cardio: "Cardio"
case .strength: "Strength"
case .wellness: "Wellness"
case .weightLoss: L10n.goal.weightLoss
case .cardio: L10n.goal.cardio
case .strength: L10n.goal.strength
case .wellness: L10n.goal.wellness
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -136,7 +136,7 @@ extension View {
// Stat Badge
struct StatBadge: View {
let label: String
let label: LocalizedStringResource
let value: String
var color: Color = .primary
var icon: String? = nil

View File

@@ -4,16 +4,17 @@ import Foundation
/// Usage: Text(L10n.action.start) or String(localized: L10n.player.phase.work)
enum L10n {
enum action {
static let back = LocalizedStringResource("action.back")
static let cancel = LocalizedStringResource("action.cancel")
static let `continue` = LocalizedStringResource("action.continue")
static let done = LocalizedStringResource("action.done")
static let save = LocalizedStringResource("action.save")
static let share = LocalizedStringResource("action.share")
static let start = LocalizedStringResource("action.start")
static let startWorkout = LocalizedStringResource("action.startWorkout")
static let startTraining = LocalizedStringResource("action.startTraining")
static let unlockPremium = LocalizedStringResource("action.unlockPremium")
static let back = LocalizedStringResource("action.back")
static let cancel = LocalizedStringResource("action.cancel")
static let `continue` = LocalizedStringResource("action.continue")
static let done = LocalizedStringResource("action.done")
static let retry = LocalizedStringResource("action.retry")
static let save = LocalizedStringResource("action.save")
static let share = LocalizedStringResource("action.share")
static let start = LocalizedStringResource("action.start")
static let startWorkout = LocalizedStringResource("action.startWorkout")
static let startTraining = LocalizedStringResource("action.startTraining")
static let unlockPremium = LocalizedStringResource("action.unlockPremium")
static let restorePurchases = LocalizedStringResource("action.restorePurchases")
}
enum tab {
@@ -26,14 +27,21 @@ enum L10n {
static let featuredTitle = LocalizedStringResource("home.featuredTitle")
static let featuredSubtitle = LocalizedStringResource("home.featuredSubtitle")
static let browseTitle = LocalizedStringResource("home.browseTitle")
static let browseSubtitle = LocalizedStringResource("home.browseSubtitle")
static let streak = LocalizedStringResource("home.streak")
static let thisWeek = LocalizedStringResource("home.thisWeek")
static let allTime = LocalizedStringResource("home.allTime")
static let free = LocalizedStringResource("home.free")
static let failedToLoad = LocalizedStringResource("home.failedToLoad")
}
enum zone {
static let upper = LocalizedStringResource("zone.upper")
static let lower = LocalizedStringResource("zone.lower")
static let full = LocalizedStringResource("zone.full")
static let upper = LocalizedStringResource("zone.upper")
static let lower = LocalizedStringResource("zone.lower")
static let full = LocalizedStringResource("zone.full")
static let upperDescription = LocalizedStringResource("zone.upper.description")
static let lowerDescription = LocalizedStringResource("zone.lower.description")
static let fullDescription = LocalizedStringResource("zone.full.description")
static let defaultDescription = LocalizedStringResource("zone.default.description")
static func label(for zone: String) -> LocalizedStringResource {
switch zone.lowercased() {
@@ -43,12 +51,45 @@ enum L10n {
default: return LocalizedStringResource(stringLiteral: zone.capitalized)
}
}
static func description(for zone: String) -> LocalizedStringResource {
switch zone.lowercased() {
case "upper-body": return upperDescription
case "lower-body": return lowerDescription
case "full-body": return fullDescription
default: return defaultDescription
}
}
}
enum level {
static let beginner = LocalizedStringResource("level.beginner")
static let intermediate = LocalizedStringResource("level.intermediate")
static let advanced = LocalizedStringResource("level.advanced")
}
enum programs {
static let filterZone = LocalizedStringResource("programs.filterZone")
static let all = LocalizedStringResource("programs.all")
static let allLevels = LocalizedStringResource("programs.allLevels")
static let noneFound = LocalizedStringResource("programs.noneFound")
static let searchPrompt = LocalizedStringResource("programs.searchPrompt")
}
enum programDetail {
static let warmUp = LocalizedStringResource("programDetail.warmUp")
static let coolDown = LocalizedStringResource("programDetail.coolDown")
static let startWorkout = LocalizedStringResource("programDetail.startWorkout")
static let unlockPremium = LocalizedStringResource("programDetail.unlockPremium")
/// printf: %d = block number
static let blockFmt = LocalizedStringResource("programDetail.blockFmt")
/// printf: %d block, %d of total
static let blockOfFmt = LocalizedStringResource("programDetail.blockOfFmt")
/// printf: %d rounds, %d workTime, %d restTime
static let blockSubtitleFmt = LocalizedStringResource("programDetail.blockSubtitleFmt")
/// printf: %d rounds
static let roundsFmt = LocalizedStringResource("programDetail.roundsFmt")
/// printf: %d minutes
static let minFmt = LocalizedStringResource("programDetail.minFmt")
/// printf: %d kcal
static let kcalFmt = LocalizedStringResource("programDetail.kcalFmt")
}
enum player {
enum phase {
static let getReady = LocalizedStringResource("player.phase.getReady")
@@ -77,6 +118,7 @@ enum L10n {
}
enum complete {
static let title = LocalizedStringResource("complete.title")
static let saving = LocalizedStringResource("complete.saving")
static let saveToHealth = LocalizedStringResource("complete.saveToHealth")
static let savedToHealth = LocalizedStringResource("complete.savedToHealth")
static let backToHome = LocalizedStringResource("complete.backToHome")
@@ -84,23 +126,68 @@ enum L10n {
static let calories = LocalizedStringResource("complete.calories")
static let rounds = LocalizedStringResource("complete.rounds")
static let avgHeartRate = LocalizedStringResource("complete.avgHeartRate")
static let completion = LocalizedStringResource("complete.completion")
static let shareWorkout = LocalizedStringResource("complete.shareWorkout")
}
enum activity {
static let currentStreak = LocalizedStringResource("activity.currentStreak")
static let bestStreak = LocalizedStringResource("activity.bestStreak")
static let history = LocalizedStringResource("activity.history")
static let workouts = LocalizedStringResource("activity.workouts")
static let minutes = LocalizedStringResource("activity.minutes")
static let noWorkouts = LocalizedStringResource("activity.noWorkouts")
static let noWorkoutsMessage = LocalizedStringResource("activity.noWorkoutsMessage")
static let currentStreak = LocalizedStringResource("activity.currentStreak")
static let bestStreak = LocalizedStringResource("activity.bestStreak")
static let history = LocalizedStringResource("activity.history")
static let workouts = LocalizedStringResource("activity.workouts")
static let minutes = LocalizedStringResource("activity.minutes")
static let calories = LocalizedStringResource("activity.calories")
static let days = LocalizedStringResource("activity.days")
static let today = LocalizedStringResource("activity.today")
static let noWorkouts = LocalizedStringResource("activity.noWorkouts")
static let noWorkoutsMessage = LocalizedStringResource("activity.noWorkoutsMessage")
}
enum profile {
static let subscription = LocalizedStringResource("profile.subscription")
static let fitnessProfile = LocalizedStringResource("profile.fitnessProfile")
static let level = LocalizedStringResource("profile.level")
static let goal = LocalizedStringResource("profile.goal")
static let weeklyGoal = LocalizedStringResource("profile.weeklyGoal")
static let yearly = LocalizedStringResource("profile.yearly")
static let monthly = LocalizedStringResource("profile.monthly")
static let athlete = LocalizedStringResource("profile.athlete")
/// printf: %@ = formatted date. e.g. "Joined Jan 15, 2026"
static let joinedFmt = LocalizedStringResource("profile.joinedFmt")
/// printf: %d = frequency. e.g. "3x / week"
static let weeklyGoalFmt = LocalizedStringResource("profile.weeklyGoalFmt")
}
enum onboarding {
static let whatIsYourName = LocalizedStringResource("onboarding.whatIsYourName")
static let fitnessLevel = LocalizedStringResource("onboarding.fitnessLevel")
static let mainGoal = LocalizedStringResource("onboarding.mainGoal")
static let howOften = LocalizedStringResource("onboarding.howOften")
static let allSet = LocalizedStringResource("onboarding.allSet")
static let whatIsYourName = LocalizedStringResource("onboarding.whatIsYourName")
static let whatIsYourNameSubtitle = LocalizedStringResource("onboarding.whatIsYourNameSubtitle")
static let fitnessLevel = LocalizedStringResource("onboarding.fitnessLevel")
static let fitnessLevelSubtitle = LocalizedStringResource("onboarding.fitnessLevelSubtitle")
static let mainGoal = LocalizedStringResource("onboarding.mainGoal")
static let mainGoalSubtitle = LocalizedStringResource("onboarding.mainGoalSubtitle")
static let howOften = LocalizedStringResource("onboarding.howOften")
static let howOftenSubtitle = LocalizedStringResource("onboarding.howOftenSubtitle")
static let allSet = LocalizedStringResource("onboarding.allSet")
static let allSetSubtitle = LocalizedStringResource("onboarding.allSetSubtitle")
static let getStarted = LocalizedStringResource("onboarding.getStarted")
static let startFirstWorkout = LocalizedStringResource("onboarding.startFirstWorkout")
static let enterName = LocalizedStringResource("onboarding.enterName")
static let perWeek = LocalizedStringResource("onboarding.perWeek")
static let anyChallenges = LocalizedStringResource("onboarding.anyChallenges")
static let challengesSubtitle = LocalizedStringResource("onboarding.challengesSubtitle")
static let pill4MinWorkouts = LocalizedStringResource("onboarding.pill4MinWorkouts")
static let pillNoEquipment = LocalizedStringResource("onboarding.pillNoEquipment")
static let pillVoiceGuided = LocalizedStringResource("onboarding.pillVoiceGuided")
static let tabataDesc = LocalizedStringResource("onboarding.tabataDesc")
enum levelDesc {
static let beginner = LocalizedStringResource("onboarding.level.beginnerDesc")
static let intermediate = LocalizedStringResource("onboarding.level.intermediateDesc")
static let advanced = LocalizedStringResource("onboarding.level.advancedDesc")
}
enum goalDesc {
static let weightLoss = LocalizedStringResource("onboarding.goal.weightLossDesc")
static let cardio = LocalizedStringResource("onboarding.goal.cardioDesc")
static let strength = LocalizedStringResource("onboarding.goal.strengthDesc")
static let wellness = LocalizedStringResource("onboarding.goal.wellnessDesc")
}
}
enum goal {
static let weightLoss = LocalizedStringResource("goal.weightLoss")
@@ -109,24 +196,62 @@ enum L10n {
static let wellness = LocalizedStringResource("goal.wellness")
}
enum settings {
static let title = LocalizedStringResource("settings.title")
static let audio = LocalizedStringResource("settings.audio")
static let soundEffects = LocalizedStringResource("settings.soundEffects")
static let voiceCoaching = LocalizedStringResource("settings.voiceCoaching")
static let music = LocalizedStringResource("settings.music")
static let haptics = LocalizedStringResource("settings.haptics")
static let hapticFeedback = LocalizedStringResource("settings.hapticFeedback")
static let reminders = LocalizedStringResource("settings.reminders")
static let dailyReminder = LocalizedStringResource("settings.dailyReminder")
static let resetProgress = LocalizedStringResource("settings.resetProgress")
static let title = LocalizedStringResource("settings.title")
static let audio = LocalizedStringResource("settings.audio")
static let soundEffects = LocalizedStringResource("settings.soundEffects")
static let voiceCoaching = LocalizedStringResource("settings.voiceCoaching")
static let music = LocalizedStringResource("settings.music")
static let haptics = LocalizedStringResource("settings.haptics")
static let hapticFeedback = LocalizedStringResource("settings.hapticFeedback")
static let reminders = LocalizedStringResource("settings.reminders")
static let dailyReminder = LocalizedStringResource("settings.dailyReminder")
static let reminderTime = LocalizedStringResource("settings.reminderTime")
static let manageHealth = LocalizedStringResource("settings.manageHealth")
static let account = LocalizedStringResource("settings.account")
static let notSet = LocalizedStringResource("settings.notSet")
static let about = LocalizedStringResource("settings.about")
static let resetProgress = LocalizedStringResource("settings.resetProgress")
static let resetProgressConfirm = LocalizedStringResource("settings.resetProgressConfirm")
static let confirmReset = LocalizedStringResource("settings.confirmReset")
static let name = LocalizedStringResource("settings.name")
static let joined = LocalizedStringResource("settings.joined")
static let version = LocalizedStringResource("settings.version")
static let resetProgressMessage = LocalizedStringResource("settings.resetProgressMessage")
}
enum policy {
static let privacyTitle = LocalizedStringResource("policy.privacyTitle")
static let termsTitle = LocalizedStringResource("policy.termsTitle")
static let dataWeCollect = LocalizedStringResource("policy.dataWeCollect")
static let appleHealth = LocalizedStringResource("policy.appleHealth")
static let analytics = LocalizedStringResource("policy.analytics")
static let purchases = LocalizedStringResource("policy.purchases")
static let dataStorage = LocalizedStringResource("policy.dataStorage")
static let contact = LocalizedStringResource("policy.contact")
static let useOfApp = LocalizedStringResource("policy.useOfApp")
static let subscription = LocalizedStringResource("policy.subscription")
static let healthDisclaimer = LocalizedStringResource("policy.healthDisclaimer")
static let limitationOfLiability = LocalizedStringResource("policy.limitationOfLiability")
static let changesToTerms = LocalizedStringResource("policy.changesToTerms")
}
enum paywall {
static let title = LocalizedStringResource("paywall.title")
static let subtitle = LocalizedStringResource("paywall.subtitle")
static let startPremium = LocalizedStringResource("paywall.startPremium")
static let premiumActive = LocalizedStringResource("paywall.premiumActive")
static let upgradePrompt = LocalizedStringResource("paywall.upgradePrompt")
static let cancelAnytime = LocalizedStringResource("paywall.cancelAnytime")
static let title = LocalizedStringResource("paywall.title")
static let subtitle = LocalizedStringResource("paywall.subtitle")
static let startPremium = LocalizedStringResource("paywall.startPremium")
static let premiumActive = LocalizedStringResource("paywall.premiumActive")
static let upgradePrompt = LocalizedStringResource("paywall.upgradePrompt")
static let cancelAnytime = LocalizedStringResource("paywall.cancelAnytime")
static let processing = LocalizedStringResource("paywall.processing")
static let bestValue = LocalizedStringResource("paywall.bestValue")
static let unlimitedWorkouts = LocalizedStringResource("paywall.unlimitedWorkouts")
static let unlimitedWorkoutsDesc = LocalizedStringResource("paywall.unlimitedWorkoutsDesc")
static let healthkitSync = LocalizedStringResource("paywall.healthkitSync")
static let healthkitSyncDesc = LocalizedStringResource("paywall.healthkitSyncDesc")
static let progressSync = LocalizedStringResource("paywall.progressSync")
static let progressSyncDesc = LocalizedStringResource("paywall.progressSyncDesc")
static let voiceCoaching = LocalizedStringResource("paywall.voiceCoaching")
static let voiceCoachingDesc = LocalizedStringResource("paywall.voiceCoachingDesc")
static let error = LocalizedStringResource("paywall.error")
static let somethingWrong = LocalizedStringResource("paywall.somethingWrong")
}
enum health {
static let appleHealth = LocalizedStringResource("health.appleHealth")
@@ -134,5 +259,6 @@ enum L10n {
static let exercise = LocalizedStringResource("health.exercise")
static let stand = LocalizedStringResource("health.stand")
static let restingHR = LocalizedStringResource("health.restingHR")
static let today = LocalizedStringResource("health.today")
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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))
}

View File

@@ -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.")
)

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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)

View File

@@ -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