Translate a Flutter app with Ollama or LM Studio
Updated · GLabs325
A model running on your own machine can draft translations for your ARB files, with no subscription and without sending your text to an online service. The setup, a starter Dart script, and the places where the model gets it wrong.
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. This is the best reason to do it. - 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, not variety.
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. This is 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. Your app's name, your paid tier, a feature name: models like to translate them. List them in the prompt as terms to keep, and check them afterwards.
Consistency. Each string is translated in isolation, so "item" can become "article" here and "élément" there. A short glossary in the prompt fixes most of it.
Length. French and German often run longer than English. A button that fit in English may overflow; only the screen will tell you.
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.
Frequently asked questions
Which model should I use for app strings?
A recent general-purpose 12B model such as gemma3:12b gives good drafts for short strings. On a smaller machine, a 4B version works, with more mistakes.
Does my text leave my machine?
No. Ollama and LM Studio run the model locally; requests go to localhost.
Can I write straight into my ARB files?
Better to write to a drafts file and review before merging: a model can break an ICU plural or translate a brand name.