A local model won't replace a translator. For short UI strings, though, it gives you decent drafts for free, and your text never leaves your machine. Most of the work is around the model: giving it context, protecting placeholders, reviewing what comes back.
Installing a model
With Ollama, one command downloads the model, and a local server then listens on http://localhost:11434:
ollama pull gemma3:12b
A 12-billion-parameter model in its default quantized build fits in 16 GB of memory. On a smaller machine, gemma3:4b handles simple strings, with more mistakes on long sentences.
With LM Studio, you pick the model in the app and start the local server: it exposes an OpenAI-compatible API, on http://localhost:1234/v1 by default.
What the model needs to know
Sending "Share list" on its own leaves the model guessing whether it's a button, a title or a verb. Three things make a clear difference:
- The key's description, if you fill in
@key.descriptionin your ARB files (the best reason to fill it in). - The app's context: "family shopping list app, plain tone, informal 'tu' in French".
- Format rules: keep every
{placeholder}and the ICU syntax intact, reply with the translation only.
Set the temperature to 0 as well: for translation you want the most likely answer.
A starter script
This Dart script translates the keys missing (or empty) in app_fr.arb, one at a time, with Ollama. It doesn't touch your ARB files: drafts go to a separate file, and any answer whose placeholders changed is set aside.
import 'dart:convert';
import 'dart:io';
const model = 'gemma3:12b';
final placeholder = RegExp(r'\{[A-Za-z_]\w*\}');
Future<void> main() async {
final source = _read('lib/l10n/app_en.arb');
final target = _read('lib/l10n/app_fr.arb');
final drafts = <String, String>{};
for (final key in source.keys.where((k) => !k.startsWith('@'))) {
if ((target[key] as String?)?.isNotEmpty ?? false) continue;
final text = source[key] as String;
final description = (source['@$key'] as Map?)?['description'] ?? '';
final draft = await _translate(text, description as String);
if (_placeholders(draft) != _placeholders(text)) {
stderr.writeln('$key: placeholders changed, redo by hand');
continue;
}
drafts[key] = draft;
}
File('app_fr.drafts.json').writeAsStringSync(const JsonEncoder.withIndent(' ').convert(drafts));
print('${drafts.length} drafts in app_fr.drafts.json');
}
Map<String, dynamic> _read(String path) => jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;
String _placeholders(String text) => (placeholder.allMatches(text).map((m) => m[0]!).toList()..sort()).join(',');
Future<String> _translate(String text, String description) async {
final client = HttpClient();
final request = await client.postUrl(Uri.parse('http://localhost:11434/api/generate'));
request.headers.contentType = ContentType.json;
request.write(jsonEncode({
'model': model,
'stream': false,
'options': {'temperature': 0},
'prompt': 'Translate this mobile app string from English to French. '
'Keep every {placeholder} and the ICU plural/select syntax unchanged. '
'Context: $description\n'
'Reply with the translation only.\n\n$text',
}));
final response = await request.close();
final body = jsonDecode(await response.transform(utf8.decoder).join()) as Map<String, dynamic>;
client.close();
return (body['response'] as String).trim();
}
Add your app's context to the prompt, and your target language if it isn't French.
Where it goes wrong
ICU plurals are the weak spot. A model may translate the other keyword, drop a branch, or rename {count} in a single branch. The script's placeholder comparison catches part of it; review every plural for the rest. Also watch =1 branches, which apply to zero in French (details in the ICU plurals guide).
Proper names such as your app's name, your paid tier or a feature name tend to get translated. List them in the prompt as terms to keep, and check them afterwards.
Consistency suffers because each string is translated in isolation: "item" can become "article" here and "élément" there. A short glossary in the prompt fixes most of it.
Length changes too: French and German often run longer than English, so a button that fit in English may overflow. Check it on screen.
With LM Studio
Only the request changes. The API follows the OpenAI format: send a list of messages to /v1/chat/completions and read choices[0].message.content from the response. The rest of the script stays the same.
Review, then merge
Open the drafts file next to app_en.arb and go through it key by key. Copy what's good into app_fr.arb, run flutter gen-l10n again, and look at the screens involved. That loop is what LocalePilot automates: suggestions from Ollama or LM Studio land in the workspace as "AI draft to review", QA compares the placeholders, and nothing is written to your files before export.