Skip to content

Localization

Frankly uses Flutter's ARB-based localization workflow. The English template is client/lib/l10n/app_en.arb; the translated files are:

  • app_es.arb (Spanish)
  • app_zh.arb (Simplified Chinese)
  • app_zh_Hant_TW.arb (Traditional Chinese, Taiwan)

Flutter generates AppLocalizations as a synthetic package. Application code normally accesses it through the context.l10n extension from client/lib/core/localization/localization_helper.dart.

Adding a string

  1. Add a lower-camel-case key and its English value to app_en.arb.
  2. Add placeholder metadata when the string contains placeholders. Keep the placeholder names and types identical in every locale.
  3. Add the same key to app_es.arb, app_zh.arb, and app_zh_Hant_TW.arb.
  4. Replace the hardcoded UI value with context.l10n.keyName (or context.l10n.keyName(argument) for a placeholder). Add the localization helper import if the file does not already have it.
  5. Generate and inspect the localization output:
cd client
fvm flutter gen-l10n
cat missing.json

missing.json is generated by gen-l10n, is intentionally gitignored, and must contain {}. It is a completeness report, not a source file to commit. Do not edit generated localization output by hand.

CI gates

.github/workflows/l10n_check.yml runs for pull requests that change client/** (the path filter skips the job for unrelated changes). It uses the pinned Flutter 3.22.2 toolchain and runs two blocking gates:

  1. Translation completeness: flutter gen-l10n must create missing.json. The inline checker fails loudly if the file is absent or malformed. For a non-empty object it prints one readable block per locale followed by each missing key. Add the key to every ARB file and regenerate; {} passes.
  2. New hardcoded strings: dart run tool/check_hardcoded_strings.dart runs in ratchet mode. It only fails for findings that are not in the checked-in baseline. A diagnostic has the form relative/file.dart:LINE:COL and the normalized string. Use the source location to replace the value with a localization key; do not add a baseline entry merely to silence a real user-facing string.

The repository currently has pre-existing analyzer diagnostics, so a blocking repo-wide flutter analyze --fatal-warnings is deliberately not part of this workflow. Run analyzer locally when changing application code.

Hardcoded-string scanner coverage

The scanner uses the analyzer's unresolved AST API. It does not need build_runner or a resolved element model. Its current coverage includes:

  • first positional strings in Text, SelectableText, and the project HeightConstrainedText widget (including implicit constructor MethodInvocation nodes);
  • RichText and TextSpan(text:) strings;
  • Tooltip(message:);
  • contextual custom-widget parameters: ActionButton(text:), CommunityPageFloatingActionButton(text:), ThickOutlineButton(text:), _IconButton(text:), AppBar(title:), and ConfirmDialog's title, mainText, subText, cancelText, and confirmText;
  • user-facing named parameters used by CustomTextField and dialog APIs, including labelText, hintText, helperText, errorText, counterText, prefixText, suffixText, message, semanticLabel, tooltip, positiveButtonText, and negativeButtonText.

Text nested inside a SnackBar, AppBar, ElevatedButton, showDialog, or another container is covered because the visitor walks descendants. Adjacent strings and interpolations are supported. Conditional (a ? 'X' : 'Y') and null-coalescing (value ?? 'X') arguments are unwrapped, including expressions inside ${...}. For example, Text('$count ${count == 1 ? 'comment' : 'comments'}') reports both comment and comments. That unwrapping deliberately stops at call boundaries, including inside interpolations, so arguments to context.l10n.someKey('argument') are not findings. Escape sequences are decoded before letter detection, which uses the Unicode letter property: CJK, kana, and Hangul labels such as Text('设置') are reported, while spacing, punctuation, separator, and emoji-only strings are ignored.

The scanner intentionally does not infer arbitrary custom widget types from unresolved syntax. It does not recursively attribute literals returned inside callbacks such as validator closures, does not inspect arbitrary named parameters on every custom dialog, and does not treat semantics IDs, URLs, asset names, or other non-user text as localization keys. Generated files (*.g.dart, *.freezed.dart, *.mocks.dart, generated localization output, and everything under lib/l10n) are skipped. These are known limits; add a specific contextual widget/parameter rule when the project adopts another widely used user-facing entry point.

Ratchet baseline

client/tool/hardcoded_strings_baseline.txt contains the accepted existing findings. The current baseline has 193 counted entries whose occurrence counts sum to 199 findings. Each line has four tab-separated columns:

  1. the relative path;
  2. the first 12 hexadecimal characters of the SHA-256 digest of the normalized string (the comparison key);
  3. the number of occurrences for that path/digest pair; and
  4. the normalized string, for reviewer readability. Strings longer than 120 characters are truncated with … in this display-only column.

The scanner compares only the path, digest, and occurrence count; the display column is ignored. It deliberately does not use line numbers: moving code or adding a line above a finding must not rewrite the baseline. If a current count exceeds the baseline count, only the excess occurrences fail with their source locations. If a count decreases or a key disappears, the stale baseline entry is reported as a non-failing reminder to run --update-baseline. Because duplicate occurrences are counted, adding another copy of an existing hardcoded string in the same file is detected and fails the gate.

Default mode compares the current counts with the baseline. New findings fail; stale entries are reported as a non-failing reminder. Use --all (or --no-baseline) to print every finding while doing burn-down work:

cd client
fvm dart run tool/check_hardcoded_strings.dart --all

Run --update-baseline only after reviewing every current finding and deciding that it is an intentional, accepted baseline item. It rewrites the file in stable path/digest order:

fvm dart run tool/check_hardcoded_strings.dart --update-baseline

Review the baseline diff as part of that change. A new product string should normally be localized instead of added to the baseline.

The explicit escape hatch is // ignore: hardcoded_string on the offending line or the line above, optionally followed by a reason. It is reserved for true development-only values (for example a deliberately displayed build version or a feature gated by ENABLE_FAKE_PARTICIPANTS). Every use must state why it is safe; it is not a way to suppress ordinary product copy.

Optional IDE automation

The VS Code extension generate-l10n can automate parts of ARB generation. It is optional and is not required by CI. Issue #544 notes a known bug in the extension, so verify its output and run the repository's gen-l10n and CI checks before committing.