i18n_keyless 3.8.0
i18n_keyless: ^3.8.0 copied to clipboard
Keyless i18n for Flutter. No ARB files, no gen-l10n, no keys. Write T('Bonjour') and ship 48 languages at runtime with AI translations.
i18n_keyless #
Keyless i18n for Flutter. No ARB files, no flutter gen-l10n, no keys: write T('Bonjour')
and ship 48 languages at runtime. Add a language without a release.
The Flutter port of i18n-keyless: the source string is the translation key, the API translates a missing string once with AI, and every device caches the result. Same protocol, same dashboard and same API key as the React, React Native and Node SDKs.
Quick start #
final i18n = I18nKeylessClient(); // 1. one client
await i18n.init(I18nKeylessConfig(apiKey: 'YOUR_API_KEY', // 2. init once
languages: LanguagesConfig(primary: Lang.fr, supported: [Lang.fr, Lang.en]),
storage: SharedPreferencesStorage()));
runApp(I18nKeylessScope(client: i18n, child: const MyApp())); // 3. wrap the app
T('Bonjour le monde'); // 4. render a string
I18nKeyless.of(context).setCurrentLanguage(Lang.en); // 5. switch language
Install: flutter pub add i18n_keyless. Get an API key at
https://i18n-keyless.com/#get-api-key. Then flutter run: the first render shows
Bonjour le monde, the API translates it once, and every user in English sees Hello world from the local cache.
How it works #
T('Bonjour')looks the string up in the local cache, synchronously. Hit: the translation renders. Miss: the source text renders and the string is queued.- The queue posts each missing string to
POST /translate(30 in flight at most, one request per string). When it drains, the dictionary of the current language is fetched in bulk (GET /translate/:lang, withETag/If-None-Matchrevalidation) and merged into the cache, which is persisted in your storage. - Every
Tandcontext.t()below theI18nKeylessScoperebuilds when the cache or the language changes.
A network error never throws and never clears a stored translation: 10 s timeout per attempt, 3 attempts with a 500 ms then 1500 ms backoff on network errors, 429 and 5xx, no retry on other 4xx.
API #
I18nKeylessClient #
| Member | What it does |
|---|---|
init(I18nKeylessConfig) |
Validates the config, hydrates the cache from storage, then starts the bulk fetch and the usage POST in the background. Returns once the cache is hydrated. |
getTranslation(text, {context, namespace, replace, unpersistedNamespace, forceTemporary, originLanguage, debug}) |
Synchronous lookup with translate-on-miss. Never throws; returns text until the translation lands. Does not trim. |
translate(text, TranslationOptions) |
The same with an options object. |
setCurrentLanguage(Lang) |
Switches the language (an unsupported one falls back to languages.fallback), notifies at once, fetches the new dictionary. |
currentLanguage, primaryLanguage, supportedLanguages |
The state. |
changes (Stream<void>), addListener / removeListener |
Fire on every translation or language change. |
waitForIdle() |
Completes when no request or storage write is pending (tests, splash screens). |
clearStorageAndStore() |
Removes the cache from storage and memory. Keeps the device id and the config. |
dispose() |
Releases the HTTP client and listeners. |
I18nKeylessConfig #
| Field | Default | Notes |
|---|---|---|
apiKey |
required | Always, even with custom handlers. Sent as Authorization: Bearer. |
apiUrl |
https://api.i18n-keyless.com |
A self-hosted backend, no trailing slash. |
languages |
required | LanguagesConfig(primary, supported, fallback, initWithDefault, skipCurrentLanguageHydration). |
storage |
MemoryStorage() |
SharedPreferencesStorage() on a device. See Storage adapters. |
bundle |
none | I18nKeylessBundle(manifest:, load:), the translations shipped as assets. See The precompiled bundle. |
defaultNamespace |
default |
Applied to every call that has no namespace. |
sendUsage |
true |
false neither records nor sends usage analytics (the ssr: true of the JavaScript SDKs). Translate-on-miss still works. |
handleTranslate, getAllTranslations, sendTranslationsUsage |
Custom handlers: they replace the HTTP calls, in that priority. | |
onInit(lang), onSetLanguage(lang) |
Callbacks. | |
httpClient |
http.Client() |
Inject a MockClient in tests. |
debug, logger |
false, print |
Logging. |
Widgets #
// A Text whose content is translated. Every Text layout parameter is accepted.
T('Bonjour {name}', replace: {'{name}': user.name}, style: theme.textTheme.titleLarge)
// The string itself: hint texts, tooltips, snack bars, other widgets.
TextField(decoration: InputDecoration(hintText: context.t('Votre email')))
// The client: language switcher, current language.
final i18n = I18nKeyless.of(context); // or context.i18n
i18n.setCurrentLanguage(Lang.en);
i18n.currentLanguage; // Lang.en
T and context.t() trim the source text and, in a debug build, warn once per text that
carried leading or trailing whitespace (it would change the key). getTranslation does not
trim.
Per-translation options #
Named parameters of T(...), context.t(...) and getTranslation(...):
context: disambiguates meaning.T('8 heures', context: 'heure')andT('8 heures', context: 'durée')are two translations, stored as8 heures__heureand8 heures__durée.replace: interpolation. The keys include the delimiters:replace: {'{name}': user.name}. Regex-special characters are literal. An empty replacement leaves the placeholder in place.namespace: a fetch/storage partition, not a semantic key. A client downloads and persists only the namespaces it renders. Reserved default:default.unpersistedNamespace: memory-only namespace, for high-cardinality transient content (one per discussion). Never persisted, never reported in usage.forceTemporary:{Lang.en: 'Hi there'}overrides the AI translation from code. The value travels to the API; the override arrives with the next dictionary fetch.originLanguage: for user generated content, the language that string is written in when it is not the primary one.debug: logs the resolution of that one string.
Languages #
Lang is an enum of the 48 supported codes (Lang.fr, Lang.ptBR, Lang.zhHans); the
wire code is lang.code. availableLangs and availableLangCodes list them.
Lang.fromCode('pt-BR') is the exact match; resolveLang('pt-AO') maps any BCP-47 tag
onto a supported language (Lang.pt), with supported and fallback filters:
final device = WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag();
final lang = resolveLang(device, supported: [Lang.fr, Lang.en], fallback: Lang.en);
toAppStoreLocale(Lang.fr) is fr-FR, the App Store Connect listing slot.
Pure Dart #
package:i18n_keyless/i18n_keyless_core.dart has no Flutter import: the client, the
queue, the transport, MemoryStorage, the languages. Use it in a Dart CLI or a server.
Add a language without a release #
Translations are not in the binary. Add Lang.de to languages.supported and ship once;
from then on:
- Add or remove a language in the dashboard at https://i18n-keyless.com/dashboard, or
through the MCP server (
set_project_languages). - Edit any translation in the dashboard. The next dictionary fetch of every device receives it: at the next app launch, at the next language switch, or after the next missing string.
A string you add in the code is translated once, for every language, the first time any
user renders it. No ARB file to update, no flutter gen-l10n, no key to invent, no
review of a generated Dart file.
Storage adapters #
I18nKeylessStorage is three async methods:
abstract class I18nKeylessStorage {
Future<String?> getItem(String key);
Future<void> setItem(String key, String value);
Future<void> removeItem(String key);
}
SharedPreferencesStorage()(bundled, onshared_preferences): the default choice on a device.SharedPreferencesStorage(SharedPreferencesAsync(...))to pass your own instance.MemoryStorage(): the default whenstorageis omitted. Nothing survives a restart.- Your own: Hive, a file, Drift,
flutter_secure_storage. Returnnullfor a missing key.
The keys and their serialisation are the ones of the JavaScript SDKs
(i18n-keyless-translations, i18n-keyless-translations__<namespace>,
i18n-keyless-current-language, i18n-keyless-user-id, ...). The device id under
i18n-keyless-user-id is what the API counts as one user; clearStorageAndStore() keeps it
on purpose.
The precompiled bundle #
Ship the translations with the app and keep the API for the misses. Export the files with
the MCP export_bundle tool or GET /translate/bundle: a directory holding manifest.json
and one <namespace>/<lang>.json per dictionary. Declare it as assets and hand the
manifest and a loader to init:
flutter:
assets:
- assets/i18n-keyless/
- assets/i18n-keyless/default/
final bundle = await loadI18nKeylessBundleFromAssets('assets/i18n-keyless');
await i18n.init(I18nKeylessConfig(apiKey: '...', languages: ..., bundle: bundle));
loadI18nKeylessBundleFromAssets reads manifest.json through rootBundle (or the
assetBundle you pass) and builds the loader; in pure Dart, build
I18nKeylessBundle(manifest: BundleManifest.fromJson(...), load: (namespace, lang) async => ...)
yourself.
A namespace the manifest covers in the current language is seeded from the file instead of
fetched, at boot and on every language switch, with the bundle's cursor, so the next fetch
for it is the delta after a miss. What storage holds for the namespace wins only when it is
strictly newer and in the same language: a device that fetched after a human review keeps
the reviewed text, and a stale bundle never overwrites fresher data. A pair the manifest
does not list is fetched as before; a file the loader cannot read is logged and fetched. A
miss still POSTs. Nothing else changes (docs/PROTOCOL.md section 7.4).
Server and tests #
A Flutter app is a device: every request carries sdk: flutter and the persisted
unique_id. There is no server mode. To run the core on a server (a Dart backend, a
build step), set sendUsage: false so it records and sends no usage analytics, and use
MemoryStorage. The Version header carries the package version (3.3.0), which selects
the v3 dialect of the API (zh-Hans, cs).
In tests, inject httpClient: MockClient(...) and use await client.waitForIdle().
Example #
example/lib/main.dart is a two-page app (T, context.t, context, replace, a
language switcher) that runs offline against the mock backend of the repository:
node ../../examples/_mock-server/server.mjs # http://localhost:8787
cd example && flutter run
On an Android emulator the host is 10.0.2.2. Set apiUrl: 'https://api.i18n-keyless.com'
and your apiKey to use the real service.
Limitations #
- Every
Tbelow the scope rebuilds when a translation lands or the language changes (anInheritedNotifier). Fine for a screen; a list of thousands ofTwidgets is not the intended use. - The flat cache is shared across languages: after a switch, a string that has no entry in the new language shows the previous language's text until its translation arrives.
- Every boot in a non-primary language fetches the full dictionary of each namespace (the delta cursor is reset by the boot language switch; the ETag map is in memory).
- The queue id ignores the context: two contexts of the same string in one batch make one
POST /translate; the second one is translated on a later render. - A rebuild does not re-request a string already queued for the current language until its namespace's bulk fetch has landed (stricter than the JavaScript SDKs, fewer requests).
- The API translates in the background; a string added in production shows its source text to the first user who renders it.
Links #
- Docs: https://docs.i18n-keyless.com
- Protocol and conformance vectors:
docs/PROTOCOL.md,conformance/in the repository - Agent skill:
SKILL.mdnext to this file;llms.txtfor one pasteable file - License: MIT