Internationalization

String Catalog, runtime language switching, and adding a locale.

The app ships in English and Simplified Chinese and can switch language live, without a relaunch — a capability plain SwiftUI localization doesn't give you for free. This page explains the String Catalog source of truth, the LocaleController that makes in-session switching work, the one call-site rule you must follow, and how to add a language.

Single source of truth: the String Catalog

Every user-facing string lives in Localizable.xcstrings (a String Catalog), with sourceLanguage: "en" and a zh-Hans translation per key. Keys are dotted and namespaced by feature:

auth.alreadyHaveAccount
todos.filterActive
tabs.home
profile.system

Xcode compiles the catalog into per-locale .lproj bundles at build time. Add a key by editing the catalog in Xcode's String Catalog editor (or the raw JSON) and filling in both languages.

Don't scatter free-form String(localized:) literals in feature code. Add the key to Localizable.xcstrings first, then read it through locale.t("your.key"). Un-cataloged literals won't get a translation and won't react to the in-app language switch.

Live switching: LocaleController

The reason the language flips instantly is LocaleController, an @Observable that owns the selected language and resolves the right resource bundle at read time:

@Observable
final class LocaleController {
    var selectedLanguage: AppLanguage   // .system | .english | .chineseSimplified

    var effectiveLocale: Locale { selectedLanguage.locale }

    var localizedBundle: Bundle {
        guard
            let code = selectedLanguage.resourceCode,          // "en" | "zh-Hans"
            let path = Bundle.main.path(forResource: code, ofType: "lproj"),
            let bundle = Bundle(path: path)
        else { return .main }
        return bundle
    }
}

Because the controller is @Observable, any view body that calls into it re-renders when selectedLanguage changes. The t(_:) helper routes every lookup through the currently-selected bundle and locale:

extension LocaleController {
    func t(_ key: String.LocalizationValue) -> String {
        String(localized: key, bundle: localizedBundle, locale: effectiveLocale)
    }
}

The one rule: always go through locale.t(...)

// ✅ Reacts to the in-app language switch
Text(locale.t("tabs.home"))

// ❌ Binds to Bundle.main + the launch-time locale — won't switch live
Text("tabs.home")

The bare LocalizedStringKey form (Text("tabs.home")) resolves against Bundle.main and the locale fixed at launch, so it ignores an in-session change. Read LocaleController from the environment and route through t(...):

@Environment(LocaleController.self) private var locale
// …
Text(locale.t("todos.filterActive"))

For SwiftUI surfaces that read the locale from the environment rather than a string key — Date.FormatStyle, number formatting, Text(_, format:)RootView mounts .environment(\.locale, locale.effectiveLocale), so those honor the override automatically with no per-call threading.

The AppleLanguages fallback

When you pick a non-system language, LocaleController also writes UserDefaults["AppleLanguages"]. This is belt-and-braces, not the live mechanism: it only takes effect on the next cold launch and exists solely for system-rendered surfaces SwiftUI can't reach through a bundle — keyboard suggestions, the share sheet, certain system alerts. The in-app UI switches immediately via the bundle above; these stragglers catch up next launch.

Adding a language

Add the locale to the catalog. In Xcode, select Localizable.xcstrings → the + in the language list → e.g. Spanish (es). Translate each key (untranslated keys fall back to the source language).

Add a case to AppLanguage (Core/Locale/AppLanguage.swift) and fill in all four switches — the resourceCode must exactly match the .lproj folder Xcode emits ("es"), plus locale, displayName, and id:

case spanish
// resourceCode → "es"
// locale       → Locale(identifier: "es")
// displayName  → "Español"

Build and verify. The new language appears in the picker (backed by AppLanguage.allCases). Select it and confirm the UI re-renders in place — no relaunch — and that Date/number formatting follows.

resourceCode is the subtle one: it must be the exact folder name Xcode generates for that translation (zh-Hans, not zh). If localizedBundle can't find the .lproj, it silently falls back to Bundle.main and the language won't switch live.

Where the language is stored

The choice persists in AppPreferences.languageTag (a UserDefaults key). .system stores nil and defers to the device locale. Language and theme live side by side in AppPreferences, and the Me tab exposes both pickers.

On this page