Modular Flutter L10n
✨ Why Modular Localization?Traditional Flutter localization stores all translations in a single namespace. In large apps, this creates: ❌ Naming collisions – Need verbose prefixes like ✅ Modular L10n solves this by:
🚀 Quick Start1. Install ExtensionVia VS Code:
Prerequisites:
2. Initialize Project (One Command!)
What happens:
📁 Project Structure
📝 ARB File Format (Critical!)Every ARB file MUST include two metadata properties:
Supported Locale FormatsThe extension validates locales against comprehensive standards:
See full list in module_scanner.ts. 🔧 Flutter Setup1. Add Dependencies
Run:
2. Configure MaterialApp
That's it! No need for 3. Platform Configuration (For In-App Switching)Only needed if you want to change language without restarting the app. Android (
|
| Feature | Status | Notes |
|---|---|---|
{name} placeholders |
✅ | Type comes from @key.placeholders |
plural with zero…other |
✅ | Full CLDR category set |
select with arbitrary keywords |
✅ | other required |
selectordinal |
✅ | CLDR ordinal rules, compiled per project |
Exact selectors =0, =5, … |
✅ | Match the raw value, not the offset |
offset:n |
✅ | Categories and # use value - offset |
# inside a plural body |
✅ | The offset-shifted value |
Nested plural / select / selectordinal |
✅ | Arbitrarily deep |
ICU quoting ('', '{') |
✅ | It''s → It's; '{x}' is literal |
# outside a plural |
✅ | Literal text, as in gen_l10n |
format: on a plural operand |
⚠️ | Ignored; the value is raw, like # |
| A key or placeholder that is a Dart keyword | ⚠️ | Reported; the generated Dart would not compile |
A literal { or } |
⚠️ | Allowed, but reported as a diagnostic |
date / number / time argument types |
❌ | Rendered as literal text, reported |
Plural offset with selectordinal |
⚠️ | Accepted; CLDR does not define it |
Text around a plural or select is preserved, and the placeholders in it become
parameters:
{
"greeting": "{name} has {count, plural, one{1 item} other{{count} items}}"
}
ML.of(context).cart.greeting('Ada', 3) // "Ada has 3 items"
⚙️ Configuration
Zero-Config Default Behavior
The extension works out-of-the-box with these defaults:
| Setting | Default | Description |
|---|---|---|
className |
ML |
Generated class name (keep as ML to avoid Flutter Intl conflicts) |
outputPath |
lib/generated/modular_l10n |
Where generated Dart files go |
defaultLocale |
en |
Fallback locale if a translation is missing |
arbFilePattern |
**/l10n/*.arb |
Where to find ARB files (excludes intl_*.arb) |
watchMode |
true |
Auto-regenerate on ARB file changes |
generateCombinedArb |
true |
Create combined ARB files in output directory |
useDeferredLoading |
false |
Enable lazy-loading for web optimization |
moduleAccess |
part |
Module files are parts of the entry point; only l10n.dart/ml.dart may be imported (see One entry point) |
logLevel |
warning |
How chatty the extension is (see Log Verbosity) |
When to Configure
| Scenario | Method |
|---|---|
| Team project (recommended) | Edit pubspec.yaml → version-controlled, consistent |
| Personal preferences | VS Code Settings (settings.json) |
| Never | Most apps don't need custom configuration |
Option 1: pubspec.yaml (Recommended)
# pubspec.yaml
modular_l10n:
enabled: true
class_name: ML
default_locale: en
output_dir: lib/generated/modular_l10n
arb_dir_pattern: "**/l10n/*.arb"
generate_combined_arb: true
use_deferred_loading: false
watch_mode: true
# silent | error | warning | verbose
log_level: warning
# part | library
module_access: part
Option 2: VS Code Settings
// .vscode/settings.json
{
"modularL10n.className": "ML",
"modularL10n.outputPath": "lib/generated/modular_l10n",
"modularL10n.defaultLocale": "en",
"modularL10n.arbFilePattern": "**/l10n/*.arb",
"modularL10n.generateCombinedArb": true,
"modularL10n.useDeferredLoading": false,
"modularL10n.watchMode": true,
"modularL10n.logLevel": "warning",
"modularL10n.moduleAccess": "part"
}
Priority: pubspec.yaml > VS Code settings > defaults — applied per key.
A key you leave out of the modular_l10n: block falls through to your VS Code
setting, and only then to the built-in default. So a team can pin just
class_name in version control without disturbing anyone's personal settings.
moduleAccess
moduleAccess (module_access in pubspec.yaml) decides how the module files
relate to the generated entry point:
| Value | Layout | Direct module imports |
|---|---|---|
part (default) |
<module>_l10n.dart is part of '<class>.dart' |
Compile error — flagged by the extension with a quick fix |
library |
Each <module>_l10n.dart is its own library |
Allowed |
part is the default because a single entry point is what keeps locale changes
and test overrides honest — there is exactly one way to get a module, and it
goes through ML. Set library only while migrating an existing project; it is
kept as an escape hatch, not as a supported style.
Turning it off
enabled: false stands the extension down for that project: no generation, no
watching, no diagnostics, no hover or go-to-definition, no extract code action.
Initialize and Check Compatibility still run, so you can switch it back
on without hand-editing YAML.
modular_l10n:
enabled: false
🔊 Log Verbosity
Watch mode regenerates on every ARB save, so the extension can get loud. Dial it
down with modularL10n.logLevel (VS Code) or modular_l10n.log_level
(pubspec.yaml):
| Level | Output panel | Panel auto-reveals | Notifications |
|---|---|---|---|
silent |
nothing | never | none |
error |
failures only | on failure | errors only |
warning (default) |
failures, warnings, one-line result summaries | on warning or failure | errors, warnings, successes |
verbose |
everything — every file written, every module scanned | on any run | all |
# pubspec.yaml — quiet down a noisy watch-mode project
modular_l10n:
log_level: error
// .vscode/settings.json — turn everything on while debugging a generation issue
{ "modularL10n.logLevel": "verbose" }
What is never suppressed: prompts that require an answer — overwrite
confirmations, the Delete confirmation on Remove Locale, and Flutter Intl
conflict resolution. Silencing those would change behaviour, not just verbosity.
On-save diagnostics (the automatic run triggered by saving an .arb file) no
longer steal focus or raise notifications at any level. Findings still land in
the Problems panel; run Check Missing Translations for the interactive report.
Changing log_level in pubspec.yaml takes effect on the next command — no
window reload required.
🔄 Extension Commands
Access via Command Palette (Ctrl+Shift+P / Cmd+Shift+P):
| Command | Description | When to Use |
|---|---|---|
| Initialize | One-click setup for new projects | First time setup |
| Generate Translations | Regenerate Dart files from ARB | After editing ARB files (auto-runs in watch mode) |
| Add Key | Add new translation key to existing module | Interactive key creation |
| Create Module | Create new feature module with ARB files | Starting a new feature |
| Add Locale | Add new locale to all existing modules | Supporting new language |
| Remove Locale | Remove locale from all modules | Dropping language support |
| Add L10n Folder (right-click) | Add l10n folder to directory | Organizing existing features |
| Migrate from Flutter Intl | Convert Flutter Intl ARB files to modular | Migrating existing projects |
| Extract to ARB (code action) | Extract string literal to ARB file | While coding in Dart files |
| Check Missing Translations | Show warnings for missing/empty translations | After adding keys to default locale |
| Scan Hardcoded Strings | Find user-facing strings that should be localized | Auditing existing code |
| Sort ARB Keys | Sort keys alphabetically in ARB files | Keeping ARB files tidy |
| Find Unused Keys | Find translation keys not referenced in Dart code | Cleaning up unused translations |
| Rename Translation Key | Rename a key across all ARB files and Dart code | Refactoring key names |
| Export Translations (CSV/XLIFF) | Export translations for external translators | Sending to translation team |
| Import Translations (CSV/XLIFF) | Import translated files back into ARB | Receiving translations |
| Generate Pseudo-Locale | Create accented/expanded strings for UI testing | Testing layout with different text lengths |
Code Action: Extract to ARB
Place your cursor inside any string literal in Dart code → the lightbulb appears → choose "Modular L10n: Extract to ARB". No need to select the full string — the extension auto-detects the string boundaries.
// Before — just place your cursor anywhere inside the string
Text('Log In')
^ cursor here is enough!
// After extraction
Text(ML.of(context).auth.loginButton)
// ARB file updated
{
"loginButton": "Log In"
}
Supported string types:
- Single-quoted:
'hello' - Double-quoted:
"hello" - Triple-quoted:
'''multi\nline'''and"""multi\nline""" - Raw strings:
r'no escapes'andr"no escapes" - Escaped characters:
'it\'s working'→ properly unescaped in ARB - Dart interpolation:
'Hello $name'→ auto-converted to"Hello {name}"with placeholder metadata
You can also still select the full string manually — both workflows are supported.
🔍 Editor Features
Inline Translation Hover
Hover over any translation key usage in Dart to see all locale values in a tooltip:
Text(ML.of(context).auth.loginButton)
// ^ hover here to see:
// | Locale | Translation |
// |--------|-------------|
// | **en** | Log In |
// | ar | تسجيل الدخول |
// | fr | Connexion |
Go to ARB Definition
Ctrl+Click (or Cmd+Click on macOS) on any translation key to jump directly to the corresponding entry in the default locale's ARB file.
Text(ML.of(context).auth.loginButton)
// ^ Ctrl+Click → opens auth_en.arb at "loginButton"
Translation and ICU Diagnostics
Problems appear automatically in the Problems panel. Diagnostics run when you
save any ARB file; Modular L10n: Check Missing Translations runs them on demand.
Translations
- A key exists in the default locale but is missing in another locale (error)
- A key exists but has an empty value (warning)
ICU — the generator repairs what it can so a broken file still produces compilable Dart, which means a wrong message could otherwise reach users quietly. These put it back in front of you:
| Diagnostic | Meaning |
|---|---|
icu-syntax |
The message is not well-formed ICU — unbalanced braces, an unknown type, a repeated =N, or an apostrophe that quoted a {name} into literal text |
icu-dart-keyword |
The key or a placeholder is a Dart reserved word (default, class, new, …), so the generated file would not compile |
icu-argument-mismatch |
This translation needs an argument the template does not declare, or uses one in a role its type cannot serve. The template is used for this locale instead |
icu-missing-other |
A plural/select block has no other case, which is required |
icu-hash-literal |
A # outside a plural is literal text — did you mean a plural? (Order #{id} is left alone) |
icu-ordinal-exact |
An exact =0 selector in an ordinal block wins before CLDR rules, which is worth knowing |
icu-control-difference |
This locale adds or drops ICU blocks the template does not have (hint — both render) |
Each is anchored on the offending value's own range, so a key whose name is a
prefix of another's is still pointed at correctly. One problem is reported once:
a parse error suppresses the consequence it causes, so an unclosed block does not
also say "no other".
Direct Module Import Diagnostics
A warning appears on any import '…/<module>_l10n.dart' in your own code, on
open and on save. The analyzer's own message for this
(can't have a part-of directive) doesn't say what to do next, so the
extension names the fix and offers a quick fix that repoints the import at
ml.dart.
Repointing is enough when the module class was imported only to type a
parameter. Code that called XxxL10n.instance or .load still has to move to
ML.of(context) / ML.current — no import rewrite can do that for you.
🛠️ Maintenance Tools
Scan Hardcoded Strings
Find hardcoded user-facing strings that should be localized:
Modular L10n: Scan Hardcoded Strings
Scans lib/ for strings in UI contexts like Text(), label:, title:, hintText:, etc. Automatically filters out non-user-facing strings (imports, routes, asset paths, keys, URLs).
Results appear in both the Output panel and Problems panel as hints.
Find Unused Keys
Find translation keys in ARB files that are never referenced in Dart code:
Modular L10n: Find Unused Keys
Reports unused keys per module and optionally bulk-removes them from all ARB files.
Sort ARB Keys
Sort keys alphabetically in ARB files for cleaner diffs and easier navigation:
Modular L10n: Sort ARB Keys
@@meta keys stay at the top (@@locale,@@context)- Each key's
@keymetadata stays immediately after its key - Sort a single module or all modules at once
Rename Translation Key
Rename a key across all locale ARB files and all Dart code references in one action:
Modular L10n: Rename Translation Key
- Select the module
- Pick the key to rename
- Enter the new name
- All ARB files and Dart files are updated, then code is regenerated
🌐 Export & Import for Translators
Export to CSV
Modular L10n: Export Translations (CSV/XLIFF)
Creates a CSV file with columns: Module, Key, Description, then one column per locale. Opens in Excel/Google Sheets for translators.
Export to XLIFF
Same command, choose XLIFF 1.2 format — the industry standard for translation tools (memoQ, SDL Trados, Crowdin, etc.).
Import Translations
Modular L10n: Import Translations (CSV/XLIFF)
Import a translated CSV or XLIFF file back. The extension matches keys to the correct ARB files and updates them.
🧪 Pseudo-Localization
Test your UI layout with pseudo-translated strings:
Modular L10n: Generate Pseudo-Locale
Generates a special locale (default: en_XA) that transforms your default translations:
| Original | Pseudo-localized |
|---|---|
Log In |
[Ĺöğ Ïñ ~~~~~~] |
Welcome, {name}! |
[Ŵëĺçöɱë, {name}! ~~~~~~~~~~~] |
This helps catch:
- Truncation — expanded text (~30-50% longer) reveals overflow
- Hardcoded strings — anything not in brackets
[...]was missed - Concatenation bugs — brackets show if strings are incorrectly split
- Character encoding — accented characters reveal rendering issues
Placeholders ({name}) and ICU syntax are preserved.
🤝 Coexistence with Flutter Intl
✅ Both extensions can work together! This is intentional.
Recommended Hybrid Setup
| Scope | Extension | Location |
|---|---|---|
| Global strings (app name, shared actions) | Flutter Intl | lib/l10n/intl_*.arb |
| Feature strings (auth flows, settings) | Modular L10n | lib/features/**/l10n/*.arb |
Critical Rules to Avoid Conflicts
Class Name
- ✅ Modular L10n:
ML(default) - ✅ Flutter Intl:
S(default) - ❌ Never use same name for both!
- ✅ Modular L10n:
ARB File Naming
- ✅ Modular:
{module}_{locale}.arb(e.g.,auth_en.arb) - ✅ Flutter Intl:
intl_{locale}.arb(e.g.,intl_en.arb) - ❌ Never name modular files
intl_*.arb(auto-skipped)
- ✅ Modular:
Required Properties
- ✅ Modular: Must have
@@contextproperty - ✅ Flutter Intl: No
@@contextproperty - This is how the extension distinguishes them
- ✅ Modular: Must have
Output Directories
- ✅ Modular:
lib/generated/modular_l10n/ - ✅ Flutter Intl:
lib/generated/ - Keep separate to avoid file overwrites
- ✅ Modular:
Using Both in Code
// Modular translations (feature-specific)
Text(ML.of(context).auth.loginButton)
// Flutter Intl translations (global)
Text(S.of(context).appName)
// Both work with same delegates
MaterialApp(
localizationsDelegates: [
ML.delegate, // ← Modular
S.delegate, // ← Flutter Intl
GlobalMaterialLocalizations.delegate,
// ...
],
)
🚨 Troubleshooting
Build Errors
| Error | Cause | Solution |
|---|---|---|
The argument type 'ML' can't be assigned |
Missing delegate in MaterialApp | Add ML.delegate to localizationsDelegates |
No instance of ML present |
Delegate not registered | Ensure ML.delegate is in localizationsDelegates list |
Undefined class 'ML' |
Generated files not imported | Import package:your_app/generated/modular_l10n/l10n.dart |
The getter 'auth' isn't defined |
Module not generated | Run Modular L10n: Generate Translations |
The imported library '…_l10n.dart' can't have a part-of directive |
A module file was imported directly | Replace that import with one of l10n.dart (or delete it if the barrel is already imported) — see One entry point. Regenerate first if you have not upgraded, or set module_access: library while you migrate |
Undefined name 'AuthL10n' right after fixing an import |
The class is reachable, but the file no longer is | Import l10n.dart/ml.dart; it re-exports every module class |
ARB Files Not Detected
| Issue | Cause | Solution |
|---|---|---|
| Files ignored during scan | Missing @@context or @@locale |
Add both properties to ARB file |
| Wrong file pattern | Custom directory structure | Update arbFilePattern in config |
| Conflicting with Flutter Intl | File named intl_*.arb |
Rename to {module}_{locale}.arb |
| Nothing happens at all | enabled: false in pubspec.yaml |
Set modular_l10n.enabled: true |
Module inside a folder named build, generated, dist, output, or tmp |
Those directory names are excluded | Rename the folder — the exclusion is by exact directory name, so build_order and generated_reports are fine |
Placeholders and Parameters
| Problem | Cause | Fix |
|---|---|---|
Parameters typed Object instead of String/int |
@key metadata lives only in the default locale file |
Add placeholders metadata to the default-locale ARB |
| Translation shows the wrong value in one parameter | A translation uses a placeholder the default locale doesn't declare | The Output panel names the key and locale; add the placeholder to the default-locale ARB |
LocaleDataException from a date placeholder |
Date symbols not loaded | Register GlobalMaterialLocalizations.delegate, or call initializeDateFormatting() — see Date/Time Formatting |
| Argument count changed after upgrading to 4.2.0 | Text around a plural/select is now preserved, so its placeholders became parameters |
Pass the new arguments — see the 4.2.0 changelog; the message used to render without that text |
NoSuchMethodError: Closure call with mismatched arguments |
A locale's ICU structure differs from the template's | Align the structure, or leave the locale untranslated; the template is used either way and a diagnostic names the key |
| A locale shows the template's text and the wrong number suffixes | The locale has no translation of its own — an empty "" value counts as none, which is what Create Module writes |
Fill the value in; the Output panel names the key |
A format: on a plural operand is ignored |
The value has to reach Intl.plural as a num, and # substitutes it raw |
Expected. Use a second placeholder if the message needs both a formatted and a raw reading |
Generated Dart will not compile: Expected a identifier |
A placeholder or message key is a Dart reserved word | icu-dart-keyword names it; rename it in the message and in @key.placeholders |
| A French/Italian/Catalan message lost a parameter | l'{place} is valid ICU — the apostrophe quotes the { and the run ends at the next apostrophe |
Write l''{place}, or quote the whole placeholder as l'{place}' |
ICU Messages
| Problem | Cause | Fix |
|---|---|---|
# renders literally inside a plural |
Regenerate — pre-4.2.0 output left it untouched | Run Modular L10n: Generate Translations |
2nd renders as 4th |
Ordinals were resolved with cardinal rules | Regenerate; selectordinal now uses CLDR ordinal rules |
| Text around a plural is missing | Pre-4.2.0 output dropped it | Regenerate, then update the call sites — the message needs its placeholders now |
Undefined name 'd' from a nested plural |
Pre-4.2.0 read the inner block's cases as the outer one's | Regenerate |
| A locale's translations never load | @@locale used hyphens, so it did not match the message table |
Regenerate; @@locale is normalised on the way in |
An exact selector like =5 is ignored |
Pre-4.2.0 only knew =0, =1, =2 |
Regenerate |
# renders literally where it should not |
It is not inside a plural/selectordinal block |
Use {count, plural, other{#}} |
In-App Language Switching
| Problem | Cause | Fix |
|---|---|---|
| App restarts on Android | Missing configChanges |
Add android:configChanges="locale\|layoutDirection" to AndroidManifest |
| Locale ignored on iOS | Locale not declared | Add all locales to CFBundleLocalizations in Info.plist |
| RTL not working | Missing RTL support | Add android:supportsRtl="true" (delegates handle direction automatically) |
| UI doesn't update | State not rebuilt | Call setState() or use state management after locale change |
Validation Errors
Check Output panel (View → Output → Select "Modular L10n"):
❌ lib/features/auth/l10n/auth_en.arb: Missing required property "@@context"
❌ lib/features/home/l10n/home_ar.arb: Invalid locale "ara" (should be "ar")
💡 Best Practices
1. Module Granularity
Good (feature-level):
lib/features/
├── auth/l10n/ ← Login, signup, password reset
├── profile/l10n/ ← User profile, settings
├── payments/l10n/ ← Checkout, payment methods
Too fine-grained (avoid):
lib/features/
├── login/l10n/ ← Too specific
├── signup/l10n/ ← Group under 'auth' instead
├── forgot_password/l10n/
2. Key Naming
Good (simple, module provides namespace):
{
"@@context": "auth",
"loginButton": "Log In",
"emailLabel": "Email"
}
Access: ML.of(context).auth.loginButton
Avoid (redundant prefix):
{
"@@context": "auth",
"authLoginButton": "Log In", ← 'auth' prefix redundant
"authEmailLabel": "Email"
}
3. Locale Organization
- Add new locales to all modules simultaneously using
Add Localecommand - Use same locale codes across all modules (e.g., all use
en_USor all useen) - Keep default locale (
en) as most complete; other locales can have empty strings initially
4. Version Control
Commit generated files:
# DON'T ignore these
# lib/generated/modular_l10n/
Why? CI/CD builds need them. The extension doesn't run in CI.
Do ignore:
# Generated ARB files (optional)
lib/generated/modular_l10n/arb/
5. One Entry Point
Read translations through ML.of(context) (widgets) or ML.current (everything
else), and import only l10n.dart / ml.dart. Module classes are fine as
parameter types — that's how you pass a module down to a child widget — but the
file itself is not an import target.
// ✅ One import, read through ML, type flows down
import 'package:your_app/generated/modular_l10n/l10n.dart';
final l10n = ML.of(context).cart;
return CartItemsList(l10n: l10n);
// ❌ Bypasses context, so no rebuild when the locale changes
import 'package:your_app/generated/modular_l10n/cart_l10n.dart';
Text(CartL10n.instance.emptyCartTitle)
6. Migration Strategy
When migrating existing Flutter Intl projects:
- Keep Flutter Intl for global strings (low churn)
- Migrate high-churn features first (auth, settings)
- Use
Migrate from Flutter Intlcommand to split by prefix - Gradually move remaining translations module by module
❓ Support & Feedback
- Bug report → GitHub Issues
- Feature request → GitHub Issues (enhancement)
- Questions → GitHub Discussions
📜 License
MIT License – See LICENSE
🙏 Acknowledgments
Built with:
- Intl – Flutter's internationalization library
- glob – File pattern matching
- chokidar – File watching
- yaml – YAML parsing
Inspired by Flutter Intl's developer experience while solving modular architecture needs.
🤝 About the Author
👥 Contributors
✨ Built with ❤️ for Flutter developers scaling international apps
Star us on GitHub if this helps you!