This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
The workspace-level df_packages/CLAUDE.md covers the umbrella-repo shape, cross-package wiring, the @scripts/ PowerShell helpers, the lint baseline, and the release flow. This file only documents what is specific to df_localization.
A localization runtime for Flutter apps built around the .tr() string extension from df_config. .tr() dispatches through TranslationManager.config — a FileConfig whose mapper callback decides what string to return for a given key. All three controllers in this package work by installing their own FileConfig on TranslationManager.config. That mapper is the single integration point — everything else (locale persistence, caching, network calls, debouncing) is bookkeeping around it.
'Some text||some-key'.tr()—||separates the default English text from the translation key. The key is optional; without||, the default text doubles as the key.'Hello {__NAME__}'.tr(args: {'__NAME__': 'Robert'})— placeholders are{...}. The double-underscore convention (__NAME__) exists so Google Translate leaves the token alone when auto-translating sentences.
Two extensions ship side by side:
.tr()(fromdf_config) — flat key/value lookup plus naive{name}placeholder substitution viadf_config's primary + secondary pattern passes. Use it for everything that doesn't need plurals..trIcu({args, preferKey, locale})(this package,lib/src/icu/tr_icu_extension.dart) — looks up the same template the mapper would return, then runs it throughintl'sMessageFormat. Supports the full ICU subset:plural,select,selectordinal, simple{name}placeholders, and#inside plural branches. Locale comes fromActiveLocale.currentunless explicitly overridden.
Implementation detail to preserve: .trIcu() calls .tr(secondarySettings: null) to skip the df_config second-pass placeholder substitution — otherwise it would mangle the ICU braces before MessageFormat ever sees them. If you change that, ICU patterns will break in subtle ways. Covered by the ICU plural / select expansion via .trIcu() tests.
All three live in lib/src/ and are mutually exclusive at runtime (the last init/setLocale wins because they share TranslationManager.config).
| Controller | Source of translations | When to use |
|---|---|---|
TranslationController |
Static asset files (assets/translations/en-us.yaml etc.) loaded via rootBundle. Singleton (TranslationController.i after createInstance). |
Ship-with-the-app translations, fully offline. |
AutoTranslationController |
Three pluggable brokers: a TRemoteDatabaseInterface (e.g. Firestore), an optional TCachedDatabaseInterface (e.g. SharedPreferences), and an optional TTranslationInterface (Google Translate or LlmTranslatorBroker). In debug mode missing keys get auto-translated and written back to both databases; in release the translator is normally off and the app just reads the database. |
Apps where you want copy to translate itself the first time it's rendered in dev, then freeze that as data for production. |
RemoteTranslationController |
A single Future<Map<String, String>> Function(Locale) callback. No translator broker, no DatabaseInterface cache — caching is the host's problem (wire it through reliable or similar). |
Apps where translations are produced server-side and the client just fetches a flat map per locale. |
AutoTranslationScope is the InheritedWidget wrapper for AutoTranslationController; the other two have no widget and are driven directly. Reach the active controller via AutoTranslationScope.controllerOf(context) / .localeOf(context).
df_config ≥ 0.8.0 is the contract. The relevant surface:
TranslationManagerisabstract final class— static only. Do not try tonew TranslationManager(); it won't compile.- Install a config with
await TranslationManager.setConfig(fileConfig). It returnsFuture<FileConfig>, internally serialises writes on a_writeChainso rapidsetLocalecalls cannot leave the active config half-written. Always await it — otherwise callers that hit.tr()synchronously after will see the previous mapper. - Read with
TranslationManager.config— cheap synchronous getter. - For tests, call
TranslationManager.resetForTesting()insetUp; otherwise the static_activeconfig leaks between tests. - For safety-critical apps, set
TranslationManager.onError = (source, error, stack) => ...to capture errors that.tr()andsetConfigwould otherwise swallow. We do not set this from withindf_localization; it's a hook for the host app. - The mapper signature is
dynamic Function(TGetKeyAndDefaultValueResult textResult)whereTGetKeyAndDefaultValueResultis a record({String key, String defaultValue}). Access withtextResult.key/textResult.defaultValue.
FileConfig constructors used here are mapper-only (FileConfig(mapper: ...), no ref), so setConfig's internal readAssociatedFile is a no-op. The file-loading path is the asset-based TranslationController, which routes through TranslationFileReader.read(...) — that helper builds a FileConfig with a ConfigFileRef and calls TranslationManager.setConfig for you.
-
Stale-load protection in both
AutoTranslationControllerandRemoteTranslationController: eachsetLocalebumps_activeRequestId; in-flight async loads check it before writing to_pCache. Dropping this causes the wrong locale's data to land in the cache when the user toggles quickly. -
Stale-locale protection inside
_translateAndUpdate(auto-translate path): the mapper closes overrequestIdandactiveLocaleat the time it fires and passes them into_translateAndUpdate. After the translationawaitreturns, the function bails ifrequestId != _activeRequestId— without this, a de-translation that resolves after the user switched to fr writes a de string into the fr cache and patches it onto the fr DB path. Covered by thestale-locale guardtest. -
Per-key translate dedupe in
AutoTranslationController._didRequestTranslate: each missing key fires exactly one translation request per locale, regardless of how many.tr()calls reference it on first frame. Reset on everysetLocale. Do not reintroduce a globalThrottlearound_translateAndUpdate— a prior version did, and it dropped every key but one during the first-frame burst. -
_pCacheupdates must build a new map, not mutate in place.setLocale's fallback for "no remote translations yet" isconst <String, TranslatedText>{}— unmodifiable. Using_pCache.update((e) => e..[key] = x)against that crashes withCannot modify unmodifiable map. The correct pattern is_pCache.update((e) => {...e, key: x}). Covered by theAI translates → in-memory cache + remote DB + persistent DBtest. -
TranslationController.setLocaleawaits the file read. Returning early would race.tr()calls fired immediately after. The bootstrap path (loading a persisted locale at app start) still goes through the pod'sfromValuecallback, which fires_readSafely(...).ignore()— that one is fire-and-forget because nothing is awaiting it. Do not also fire_readfromtoValue;setLocalealready awaits explicitly there. -
TranslatedTextshape stored in the database:{to, from}.fromis the original default text — kept so a future migration can detect when source copy changed and the translation is stale.RemoteTranslationControllerdeliberately uses a flatMap<String, String>instead (the server is responsible for staleness). -
init()is idempotent onAutoTranslationController/RemoteTranslationController— concurrent callers share_initFuture.AutoTranslationScopecalls it ininitState. -
Locale persistence uses
SharedPod<Locale, String>fromdf_podundercacheKey(default'locale'). Serialization goes throughgetNormalizedLanguageTag/localeFromStringinlib/src/utils/. If you change either side, change both.
TranslatorInterface<T> (lib/src/interfaces/translator_interface.dart) returns Async<...> from df_safer_dart, not raw Futures. New brokers must follow that convention: Async<String> translateSentence(...) and Async<String> translate({required List<T> contents}).
Implementations shipped:
GoogleTranslatorBroker— direct REST call to the Google Translate v2 API. Cheapest option for short UI strings.LlmTranslatorBroker— the unified LLM broker. Backed by anyAiBrokerfromai_broker. Use the named factories:LlmTranslatorBroker.claude(apiKey: ...)— defaults toclaude-3-haiku-20240307.LlmTranslatorBroker.gemini(apiKey: ...)— defaults togemini-1.5-flash-8b.LlmTranslatorBroker.openai(apiKey: ...)— defaults togpt-4o-mini.LlmTranslatorBroker(broker: SomeFutureBroker(), apiKey: ..., model: ...)— for any provider not in the list above.
Per-vendor message-type clones (ClaudeContent / GeminiContent / OpenAIContent) were deleted — LlmTranslatorBroker uses AiMessage directly. If you need provider-specific behaviour, configure the systemPrompt / temperature / maxTokens parameters or subclass.
The AiBroker and friends (AnthropicBroker, GeminiBroker, OpenAiBroker, AiMessage, ChatRequest, AiBrokerException) are re-exported from _common.dart — depend on those rather than re-importing package:ai_broker directly inside this package's lib/src/.
DatabaseInterface (lib/src/interfaces/database_interface.dart) is three Async-returning methods: read(path), write({path, data}), patch({path, data}). Implementations: FirestoreDatabseBroker (remote — Firestore REST) and PersistentDatabaseBroker (local — SharedPreferences).
lib/_common.dartis the internal umbrella. Sources import it as'/_common.dart'(root-relative — possible becauseprefer_relative_importsis enforced and the analyzer treats it as relative to the package'slib/). Add new ambient deps here, not per-file. It re-exportsdf_config,df_pod,df_safer_dart,df_debouncer,http,dart:convert, and the namedai_brokersymbols.lib/df_localization.dartis the public library — narrower than_common.dart. It exportsdf_config,df_pod,df_safer_dart(so consumers get.tr()and pods transitively) but nothttp,dart:convert,df_debouncer, orai_broker. Keep that asymmetry — those are implementation details.lib/src/_src.g.dartis generated bydf_generate_dart_indexes. Do not hand-edit. Adding a new file underlib/src/requires regenerating this barrel (or hand-add the export and let the next regeneration confirm).- Known intentional misspellings — do not "fix" them as drive-bys:
- Class
FirestoreDatabseBroker(missinga) — public, used by consumers. - Typedef
TTransaltionMap—@Deprecated, kept for one minor cycle. New code usesTTranslationMap.
- Class
All locale utilities live in lib/src/utils/ (plus lib/src/active_locale.dart). They're designed to slot into Flutter's standard i18n machinery rather than replace it.
-
ActiveLocale(lib/src/active_locale.dart) — process-wide static holder of the currently active locale. Updated by every controller'ssetLocale(search forActiveLocale.setto find the three call sites). Read by.trIcu()for CLDR plural rules and by anything that needs the current locale without depending on a specific controller. Falls back to the platform locale when no controller has set it.ActiveLocale.resetForTesting()is the test seam — must be called fromsetUpalongsideTranslationManager.resetForTesting(). -
getSystemLocale()/getSystemLocales()(lib/src/utils/get_system_locale.dart) — wrapWidgetsBinding.instance.platformDispatcher.locale[s]. Work uniformly on iOS, Android, macOS, Windows, Linux, and Web because Flutter normalises the lookup behindPlatformDispatcher. Prefer these over the raw accessor — they're the documented public surface and are easy to mock in tests. -
bestLocale(supported, {preferred})(lib/src/utils/best_locale.dart) — picks the best supported locale from the device's preferences. Designed to drop straight intoMaterialApp.localeListResolutionCallback. Priority: exact (language + country) → language-only → first ofsupported. Emptysupportedfalls back togetSystemLocale(). -
isRtlLocale(locale)/getTextDirection(locale)(lib/src/utils/is_rtl_locale.dart) — RTL detection viaintl'sBidi.isRtlLanguage(canonical Unicode RTL script list). UsegetTextDirectionto drive aDirectionalitywidget outside aMaterialApp(custom dialogs, error overlays). Inside aMaterialAppthe framework already wires this viaGlobalWidgetsLocalizations, so don't double-wrap.
When a host app uses this package alongside Flutter's stock i18n:
MaterialApp.locale = AutoTranslationScope.localeOf(context)(or aValueListenableBuilderonTranslationController.i.pLocale) — pipes the active locale into Flutter'sLocalizationssoMaterialLocalizations,CupertinoLocalizations, etc. resolve correctly.MaterialApp.localizationsDelegates: [GlobalMaterialLocalizations.delegate, ...]— Flutter handles RTLDirectionalityautomatically; we don't need our own delegate for that.MaterialApp.localeListResolutionCallback: (pref, sup) => bestLocale(sup, preferred: pref)— when the user's device language changes, Flutter calls this with the platform's preferred list and we pick from the app'ssupportedLocales.MaterialApp.supportedLocalesshould mirror the locales for which translations actually exist on disk / in the remote DB.
We deliberately do NOT ship a LocalizationsDelegate — every controller is already global state via TranslationManager, so being in the delegate chain would add ceremony without benefit. If a host wants their own delegate to observe locale changes, they can write a one-class wrapper themselves.
The bin/gen_translations.dart script greps a Flutter project for .tr() calls and asks any LLM to translate them, writing the result as a YAML/JSON file. Exposed via pubspec.yaml as gen-translations: gen_translations. The bin folder has its own minimal pubspec.yaml that declares ai_broker (and intentionally not df_safer_dart — the bin tool stays on raw Futures).
Activate and run:
dart pub global activate df_localization
gen-translations --provider claude --locale "de-de" --api_key "..." --output "assets/translations"--provider accepts claude, gemini, openai; per-provider default model is used if --model is omitted. Defaults: --root = cwd, --type = yaml.
test/auto_translation_flow_test.dart covers the end-to-end auto-translate flow against the AutoTranslationController:
- AI translates → in-memory cache + remote DB + persistent DB.
- Release mode — a fresh controller loads from a pre-populated remote DB.
- Stale-locale guard — a de-translation that resolves after a switch to fr does not poison the fr cache.
- Translator failure — no DB writes, cache untouched, key not retried.
test/translation_controller_test.dart covers the file-based TranslationController and the system-locale helpers.
test/icu_and_locale_test.dart covers ICU expansion (English / Russian plurals, select on gender, simple placeholders, per-call locale override), RTL detection across Arabic / Hebrew / Persian / Urdu, bestLocale priority order, and that every controller's setLocale updates ActiveLocale.
Test boilerplate that must be in every test file in this package:
TestWidgetsFlutterBinding.ensureInitialized()at the top ofmain()(needed forWidgetsBinding.instance.platformDispatcherand the SharedPreferences plugin stub).- In
setUp:SharedPreferences.setMockInitialValues({}),TranslationManager.resetForTesting(), andActiveLocale.resetForTesting(). Without these, the static_activeconfig andActiveLocale._currentleak between tests and the wrong mapper / locale fires. - The broker-based controllers are tested by injecting fake
DatabaseInterface/TranslatorInterfaceimplementations.TranslationController.setReaderis@visibleForTestingand is the intended seam for the file-based controller — pass a fakeTranslationFileReaderwhosefileReaderreturns an in-memory string. - Use
Completer<void>as a "gate" on a fake translator to interleave asetLocalebetween request and response (see the stale-locale guard test for the pattern). - Note that
TranslationController._readSafelycallsassert(false, ...)on read failure. FakefileReaders in tests must return valid YAML (at minimum a non-null mapping like'placeholder: ""\n') — otherwise the debug-mode assertion fires even if you don't care about the file content.
./deploy.sh merges main into prod and pushes prod, which triggers .github/workflows/prod.yml to publish to pub.dev. The standard +message / ++message commit-prefix flow from the workspace CLAUDE.md also works on main.