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

@@ -38,14 +38,14 @@ struct TabataProvider: TimelineProvider {
}
private func relativeLabel(for date: Date?) -> String {
guard let date else { return "Not started" }
guard let date else { return String(localized: LocalizedStringResource("watch.complication.notStarted")) }
let days = Calendar.current.dateComponents([.day],
from: Calendar.current.startOfDay(for: date),
to: Calendar.current.startOfDay(for: Date())).day ?? 0
switch days {
case 0: return "Today"
case 1: return "Yesterday"
default: return "\(days) days ago"
case 0: return String(localized: LocalizedStringResource("watch.complication.today"))
case 1: return String(localized: LocalizedStringResource("watch.complication.yesterday"))
default: return String(format: String(localized: LocalizedStringResource("watch.complication.daysAgoFmt")), days)
}
}
}
@@ -82,13 +82,13 @@ struct RectangularComplicationView: View {
Image(systemName: "bolt.fill")
.foregroundStyle(.orange)
.font(.system(size: 10, weight: .bold))
Text("\(entry.streak) day streak")
Text(String(format: String(localized: LocalizedStringResource("watch.complication.dayStreakFmt")), entry.streak))
.font(.system(size: 12, weight: .bold, design: .rounded))
}
Text(entry.lastWorkoutLabel)
.font(.system(size: 11))
.foregroundStyle(.secondary)
Text("Open TabataGo →")
Text(String(localized: LocalizedStringResource("watch.complication.openApp")))
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.orange)
}
@@ -108,7 +108,7 @@ struct CornerComplicationView: View {
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.orange)
}
.widgetLabel("\(entry.streak) day streak")
.widgetLabel(String(format: String(localized: LocalizedStringResource("watch.complication.dayStreakFmt")), entry.streak))
}
}
@@ -123,7 +123,7 @@ struct TabataGoComplication: Widget {
.containerBackground(.black, for: .widget)
}
.configurationDisplayName("TabataGo")
.description("Your current workout streak.")
.description(String(localized: LocalizedStringResource("watch.complication.description")))
.supportedFamilies([
.accessoryCircular,
.accessoryRectangular,

View File

@@ -0,0 +1,207 @@
{
"sourceLanguage" : "en",
"strings" : {
"watch.phase.getReady" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "BEREIT" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "GET READY" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "LISTO" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "PRÊT" } }
}
},
"watch.phase.warmUp" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "AUFWÄRMEN" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "WARM UP" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "CALENTAMIENTO" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "ÉCHAUFFEMENT" } }
}
},
"watch.phase.work" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "ARBEIT" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "WORK" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "TRABAJO" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "TRAVAIL" } }
}
},
"watch.phase.rest" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "PAUSE" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "REST" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "DESCANSO" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "REPOS" } }
}
},
"watch.phase.break" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "PAUSE" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "BREAK" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "PAUSA" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "PAUSE" } }
}
},
"watch.phase.coolDown" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "ABKÜHLEN" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "COOL DOWN" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "ENFRIAMIENTO" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "RÉCUPÉRATION" } }
}
},
"watch.phase.done" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "FERTIG" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "DONE" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "HECHO" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "TERMINÉ" } }
}
},
"watch.idle.startOnPhone" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Workout auf\ndeinem iPhone starten" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Start a workout\non your iPhone" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "Inicia un entrenamiento\nen tu iPhone" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Démarrer un entraînement\nsur votre iPhone" } }
}
},
"watch.idle.connected" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Verbunden" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Connected" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "Conectado" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Connecté" } }
}
},
"watch.idle.noPhone" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Kein Telefon" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "No phone" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "Sin teléfono" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Pas de téléphone" } }
}
},
"watch.activity.today" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Heute" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Today" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "Hoy" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Aujourd'hui" } }
}
},
"watch.activity.move" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Bewegung" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Move" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "Mover" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Bouger" } }
}
},
"watch.activity.exercise" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Sport" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Exercise" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "Ejercicio" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Exercice" } }
}
},
"watch.activity.stand" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Stehen" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Stand" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "Estar de pie" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Debout" } }
}
},
"watch.activity.streak" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Serie" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "streak" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "racha" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "série" } }
}
},
"watch.complication.notStarted" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Nicht begonnen" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Not started" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "No iniciado" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Pas commencé" } }
}
},
"watch.complication.today" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Heute" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Today" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "Hoy" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Aujourd'hui" } }
}
},
"watch.complication.yesterday" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Gestern" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Yesterday" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "Ayer" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Hier" } }
}
},
"watch.complication.daysAgoFmt" : {
"comment" : "printf format string — %d = number of days",
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "vor %d Tagen" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "%d days ago" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "hace %d días" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "il y a %d jours" } }
}
},
"watch.complication.dayStreakFmt" : {
"comment" : "printf format string — %d = streak count",
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "%d Tage in Folge" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "%d day streak" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "%d días seguidos" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "%d jours de suite" } }
}
},
"watch.complication.openApp" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "TabataGo öffnen →" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Open TabataGo →" } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "Abrir TabataGo →" } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Ouvrir TabataGo →" } }
}
},
"watch.complication.description" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Dein aktueller Workout-Streak." } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Your current workout streak." } },
"es" : { "stringUnit" : { "state" : "translated", "value" : "Tu racha de entrenamiento actual." } },
"fr" : { "stringUnit" : { "state" : "translated", "value" : "Votre série d'entraînements actuelle." } }
}
}
},
"version" : "1.0"
}

View File

@@ -0,0 +1,54 @@
import Foundation
/// Type-safe string keys for the Watch target's `Localizable.xcstrings`.
/// Usage: Text(WatchL10n.phase.work) or String(localized: WatchL10n.idle.connected)
enum WatchL10n {
enum phase {
static let getReady = LocalizedStringResource("watch.phase.getReady")
static let warmUp = LocalizedStringResource("watch.phase.warmUp")
static let work = LocalizedStringResource("watch.phase.work")
static let rest = LocalizedStringResource("watch.phase.rest")
static let `break` = LocalizedStringResource("watch.phase.break")
static let coolDown = LocalizedStringResource("watch.phase.coolDown")
static let done = LocalizedStringResource("watch.phase.done")
static func label(for phase: WatchPhase) -> LocalizedStringResource {
switch phase {
case .prep: return getReady
case .warmup: return warmUp
case .work: return work
case .rest: return rest
case .interBlockRest: return `break`
case .cooldown: return coolDown
case .complete: return done
}
}
}
enum idle {
static let startOnPhone = LocalizedStringResource("watch.idle.startOnPhone")
static let connected = LocalizedStringResource("watch.idle.connected")
static let noPhone = LocalizedStringResource("watch.idle.noPhone")
}
enum activity {
static let today = LocalizedStringResource("watch.activity.today")
static let move = LocalizedStringResource("watch.activity.move")
static let exercise = LocalizedStringResource("watch.activity.exercise")
static let stand = LocalizedStringResource("watch.activity.stand")
static let streak = LocalizedStringResource("watch.activity.streak")
}
enum complication {
static let notStarted = LocalizedStringResource("watch.complication.notStarted")
static let today = LocalizedStringResource("watch.complication.today")
static let yesterday = LocalizedStringResource("watch.complication.yesterday")
/// printf format: takes one Int (number of days). e.g. "%d days ago"
static let daysAgoFmt = LocalizedStringResource("watch.complication.daysAgoFmt")
/// printf format: takes one Int (streak count). e.g. "%d day streak"
static let dayStreakFmt = LocalizedStringResource("watch.complication.dayStreakFmt")
static let openApp = LocalizedStringResource("watch.complication.openApp")
static let description = LocalizedStringResource("watch.complication.description")
}
}

View File

@@ -20,7 +20,7 @@ struct WatchActivityView: View {
VStack(spacing: 14) {
// Rings
Text("Today")
Text(LocalizedStringResource("watch.activity.today"))
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
@@ -29,15 +29,15 @@ struct WatchActivityView: View {
RingView(progress: moveProgress,
color: Color(red: 1.0, green: 0.23, blue: 0.19),
icon: "flame.fill",
label: "Move")
label: LocalizedStringResource("watch.activity.move"))
RingView(progress: exerciseProgress,
color: .green,
icon: "figure.run",
label: "Exercise")
label: LocalizedStringResource("watch.activity.exercise"))
RingView(progress: standProgress,
color: Color(red: 0.04, green: 0.80, blue: 0.97),
icon: "figure.stand",
label: "Stand")
label: LocalizedStringResource("watch.activity.stand"))
}
Divider()
@@ -50,7 +50,7 @@ struct WatchActivityView: View {
Text("\(streak) day\(streak == 1 ? "" : "s")")
.font(.system(size: 14, weight: .bold, design: .rounded))
Spacer()
Text("streak")
Text(LocalizedStringResource("watch.activity.streak"))
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
@@ -123,7 +123,7 @@ private struct RingView: View {
let progress: Double
let color: Color
let icon: String
let label: String
let label: LocalizedStringResource
var body: some View {
VStack(spacing: 4) {

View File

@@ -13,7 +13,7 @@ struct WatchIdleView: View {
Text("TabataGo")
.font(.system(size: 18, weight: .bold, design: .rounded))
Text("Start a workout\non your iPhone")
Text(LocalizedStringResource("watch.idle.startOnPhone"))
.font(.system(size: 13))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -23,7 +23,7 @@ struct WatchIdleView: View {
Circle()
.fill(.green)
.frame(width: 6, height: 6)
Text("Connected")
Text(LocalizedStringResource("watch.idle.connected"))
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
@@ -32,7 +32,7 @@ struct WatchIdleView: View {
Circle()
.fill(.gray)
.frame(width: 6, height: 6)
Text("No phone")
Text(LocalizedStringResource("watch.idle.noPhone"))
.font(.system(size: 11))
.foregroundStyle(.secondary)
}

View File

@@ -92,23 +92,23 @@ struct WatchPlayerView: View {
private func watchPhaseColor(_ phase: WatchPhase) -> Color {
switch phase {
case .prep, .warmup: return .orange
case .work: return Color(red: 1.0, green: 0.42, blue: 0.21)
case .prep, .warmup: return .orange
case .work: return Color(red: 1.0, green: 0.42, blue: 0.21)
case .rest, .interBlockRest: return Color(red: 0.35, green: 0.78, blue: 0.98)
case .cooldown: return .cyan
case .complete: return .green
case .cooldown: return .cyan
case .complete: return .green
}
}
private func watchPhaseLabel(_ phase: WatchPhase) -> String {
private func watchPhaseLabel(_ phase: WatchPhase) -> LocalizedStringResource {
switch phase {
case .prep: return "GET READY"
case .warmup: return "WARM UP"
case .work: return "WORK"
case .rest: return "REST"
case .interBlockRest: return "BREAK"
case .cooldown: return "COOL DOWN"
case .complete: return "DONE"
case .prep: return LocalizedStringResource("watch.phase.getReady")
case .warmup: return LocalizedStringResource("watch.phase.warmUp")
case .work: return LocalizedStringResource("watch.phase.work")
case .rest: return LocalizedStringResource("watch.phase.rest")
case .interBlockRest: return LocalizedStringResource("watch.phase.break")
case .cooldown: return LocalizedStringResource("watch.phase.coolDown")
case .complete: return LocalizedStringResource("watch.phase.done")
}
}
}