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