fluent_flutter 0.1.0 copy "fluent_flutter: ^0.1.0" to clipboard
fluent_flutter: ^0.1.0 copied to clipboard

Project Fluent for Flutter. Load .ftl translations from assets, switch languages, read messages in any widget, and render markup. Built on fluent_bundle.

fluent_flutter — Project Fluent, wired into Flutter

pub package likes pub points GitHub stars license: MIT

fluent_bundle with the Flutter parts added. Your .ftl files load from assets and are found for you. One controller decides the current language — following the device, or set by the user, and switchable while the app runs. A standard LocalizationsDelegate plugs into MaterialApp, and context.fluent reads messages anywhere in your widgets. Translator-written markup becomes styled, tappable text, and editing an .ftl file updates on hot reload.

Every piece can be swapped — your own file loader, your own formatting backend, your own generated Dart class — and each works without the others.

Start here for a Flutter app. Building pure Dart — a command-line tool, a server? Use fluent_bundle directly; everything here is the Flutter layer on top of it.

Status: 0.x. The API can change between minor versions until 1.0.0 — pre-1.0, the minor is the breaking axis, so pin ^0.N.0 and read the changelog on minor bumps.

like it? a ⭐ star or 👍 like is the entire marketing budget. Bugs & features →


👀 Peek inside

Install #

dependencies:
  fluent_flutter: ^0.1.0
  fluent_intl:        # or fluent_icu — the formatting backend (see below)

flutter:
  assets:
    - assets/i18n/

Put one .ftl per locale in the asset folder — assets/i18n/en.ftl, assets/i18n/de.ftl — and that's the whole setup. (Multi-file locales work too: assets/i18n/de/*.ftl.)

Which backend? fluent_intl (pure Dart, zero setup) or fluent_icu (every language and every option) — fluent_bundle's table lays out the choice. This package works the same way with either.


Quick start #

Boot the controller, hand the delegate to MaterialApp, read messages off the context — the whole wiring:

import 'package:fluent_flutter/fluent_flutter.dart';
import 'package:fluent_intl/fluent_intl.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Discovers the .ftl assets, resolves the device locale against them,
  // loads the fallback chain. One await, at startup.
  final controller = await FluentLocaleController.init(
    loader: AssetFluentLoader(),
    fallbackLocale: 'en',
  );

  runApp(MyApp(controller));
}

class MyApp extends StatelessWidget {
  const MyApp(this.controller, {super.key});
  final FluentLocaleController controller;

  @override
  Widget build(BuildContext context) => ListenableBuilder(
    listenable: controller,
    builder: (context, _) => MaterialApp(
      locale: controller.flutterLocale,
      supportedLocales: controller.supportedLocales,
      localizationsDelegates: [
        FluentLocalizationsDelegate(controller, backend: IntlBackend()),
        ...GlobalMaterialLocalizations.delegates,
      ],
      home: const HomePage(),
    ),
  );
}
// Anywhere below MaterialApp:
Text(context.fluent.formatMessage('greet', args: {'name': 'Aria'}))

That's the whole package in one line: the controller decides which language, the delegate builds the translations for it, and context.fluent is how widgets read them.


The controller #

FluentLocaleController is a ChangeNotifier that holds one thing: the current language and its fallbacks. Everything else follows from that.

controller.currentLocale;             // 'de-CH' — the active language
controller.localeChain;               // ['de-CH', 'de', 'en'] — what actually loaded
controller.availableLocales;          // every tag the loader discovered
controller.followingDeviceLocale;     // true until the user picks one

await controller.setLocale('de');     // user picked — rebuilds the app in de
await controller.useDeviceLocale();   // back to following the OS setting

Two behaviors worth knowing, both visible in the example app:

  • Device-following is live. While no explicit locale is set, an OS-level language change re-resolves and rebuilds the app — the controller watches didChangeLocales, you write nothing.
  • It's a fallback chain, not a single pick. Asking for de-CH loads de-CHdeen (a FluentBundleChain): a message missing from Swiss German falls back to German, then English — each formatted in its own language.

Usage #

Loaders #

FluentResourceLoader is what connects "the controller wants de" to "here is the .ftl text for de." Three come built in:

// The one most apps use — discovers .ftl files from the asset bundle:
AssetFluentLoader()                                  // assets/i18n/ by default
AssetFluentLoader(basePath: 'assets/translations')   // or wherever yours live

// Compose sources — merged per locale, earlier loaders winning
// duplicate message ids:
CompositeFluentLoader([downloadedLoader, AssetFluentLoader()])

AssetFluentLoader reads the AssetManifest, so discovery is free: every {tag}.ftl file and {tag}/*.ftl folder under the base path becomes an available locale, no registry to maintain. Adding Portuguese to your app is git add assets/i18n/pt.ftl.

🧩 writing your own loader (server-delivered translations)

The interface is two methods — return FTL sources for a tag (empty list for an unknown one, never a throw), and evict any cache when asked:

class ServerFluentLoader extends FluentResourceLoader {
  @override
  Future<List<String>> load(String localeTag) async {
    final res = await http.get(Uri.parse('$cdn/$localeTag.ftl'));
    return res.statusCode == 200 ? [res.body] : const [];
  }

  @override
  Future<List<String>> availableLocales() async => fetchManifest();
}

Compose it in front of the asset loader and you have over-the-air translation updates over a bundled baseline: CompositeFluentLoader([ServerFluentLoader(), AssetFluentLoader()]) — the server's message ids win, everything it doesn't override still comes from the assets.

Reading messages #

context.fluent (or FluentLocalization.of(context)) gives you the messages for the current language — the delegate rebuilds it whenever the language changes, so any widget that reads it re-renders with the right strings:

final fluent = context.fluent;

fluent.formatMessage('greet', args: {'name': 'Aria'});      // "Hallo, Aria!"
fluent.formatMessage('login', attribute: 'title');          // attributes too
fluent.formatMessageAsSpans('banner');                      // the markup tree
fluent.localeChain;                                         // ['de', 'en']

It's the full fluent_bundle formatting — selectors, plurals, NUMBER / DATETIME through your chosen backend — for the current language and its fallbacks.

Markup — styled and tappable text #

Translators author inline structure in the FTL; package:fluent_flutter/markup.dart renders it. FluentText is the one-widget path:

banner = Read <bold>the guide</bold> — then <a>tap here</a>!
FluentText(
  'banner',
  styles: const {'bold': TextStyle(fontWeight: FontWeight.bold)},
  tags: {
    'a': (node, children) => TextSpan(
      style: const TextStyle(decoration: TextDecoration.underline),
      // The recognizer goes on the LEAF text spans — Flutter only fires
      // a recognizer on the span that owns text, so one on the wrapper
      // would never fire. applyRecognizer handles that for you:
      children: applyRecognizer(_linkTap, children),
    ),
  },
)

styles covers the common case (a tag is just a TextStyle); tags builders get the node (attributes included — <a href="..."> arrives parsed) and the already-built children, and return any InlineSpan — links, WidgetSpan icons, whatever the design needs. Under the widget sits fluentSpansToInline(...), the same conversion as a plain function for when you're composing Text.rich yourself.

A tag with no style and no builder is a translator/developer mismatch — debug builds assert on it (loudly, with the tag and message id), release builds render the children unstyled. Pass onUnknownTag to handle it yourself instead.

Typed access with fluent_gen #

Using fluent_gen's generated class? TypedFluentDelegate bridges it — same controller, same language handling, typed reads:

localizationsDelegates: [
  TypedFluentDelegate<AppMessages>(
    controller,
    backend: IntlBackend(),
    // The loaded chain IS a FluentBundle — the generated constructor
    // takes it unchanged, and every typed accessor gains fallback:
    create: (bundle, tag) => AppMessages(bundle),
  ),
  ...GlobalMaterialLocalizations.delegates,
],

// then, in widgets:
Localizations.of<AppMessages>(context, AppMessages)!.welcome(name: 'Aria');

This package doesn't depend on fluent_gen — the bridge is just a create callback, so the generated class stays a build-time-only dependency, and you can plug in a hand-written class the same way.

Hot reload #

Wrap your app once, and edited .ftl assets update on hot reload — cleared, re-loaded, and rebuilt:

runApp(FluentHotReload(controller: controller, child: MyApp(controller)));

It only runs in debug builds (kDebugMode gates it); in a release build it does nothing. Editing translations becomes: change the .ftl, press r, look at the screen.


Error handling #

This package follows fluent_bundle's rule — formatting never throws, failures come back as values — and behaves the same way at its own edges:

  • A language the loader doesn't have never crashes anything — the fallback chain just lands on your fallbackLocale. That fallback must exist in the loader, which is checked in debug builds: an app whose last-resort language can't load has nothing left to show.
  • A message missing from the head locale falls down the chain silently (that's the chain's job); a message missing from every locale renders the id and records the miss into an errors: list you pass to formatMessage.
  • An unknown markup tag asserts in debug, renders children unstyled in release, and is yours via onUnknownTag (see Markup).

Platform support #

Everything here is widgets, assets, and pure Dart — no platform code of its own:

Android iOS macOS Windows Linux Web

Backend engines have their own platform stories — pure Dart everywhere for fluent_intl; see icu_kit's platform table for fluent_icu.


Not in the box #

  • Message formatting itself. That's fluent_bundle (re-exported here, so one import covers both) plus your chosen backend. This package decides when and from where bundles load — never how values format.
  • Code generation. Typed accessors come from fluent_gen; TypedFluentDelegate is the bridge, not the generator.
  • Locale persistence. setLocale doesn't write to disk — where the user's choice lives (shared_preferences, your settings store) is app policy. Restore it at startup: controller.setLocale(saved).
  • Material / Cupertino widget translations. Flutter's own GlobalMaterialLocalizations.delegates handle the framework's built-in strings — run them alongside, exactly as the Quick start shows.

Docs #

The README covers the everyday stuff. wanna go deeper?

Doc What's inside
Architecture How it's built: the loader, the controller, the delegate
Capabilities What's shipped, what's planned, what won't happen
Updating Maintenance recipes and the pinned Flutter-behavior watchlist
Example Every feature in one app: language switcher, markup, fallback, hot reload — tested end to end

License #

MIT. See LICENSE.

1
likes
160
points
108
downloads

Documentation

API reference

Publisher

verified publisherwhuppi.com

Weekly Downloads

Project Fluent for Flutter. Load .ftl translations from assets, switch languages, read messages in any widget, and render markup. Built on fluent_bundle.

Repository (GitHub)
View/report issues
Contributing

Topics

#i18n #localization #fluent #l10n

License

MIT (license)

Dependencies

fluent_bundle, flutter

More

Packages that depend on fluent_flutter