🚀 Join the waitlist now! waitlist.floot.dev
LogoFlootdocs

Internationalization

Set up and manage multi-language support in your Floot app.

Your project ships with English and French, and adding a third language means touching two halves that work very differently. The app's strings live in ARB files and are compiled by flutter gen-l10n. The transactional emails' strings live in TypeScript, typed as Record<Language, …>, and are checked by Deno.

That difference is worth knowing up front, because it decides how you find the work: on the Flutter side you copy a file and fill it in, on the Supabase side you widen one type and let the type checker hand you the list.

The examples below use Spanish.

Front-end (Flutter)

Locale

Add the locale

AppLocale is a sealed class, so every supported locale is a named subclass. This is what stops a typo'd locale code from reaching the widget tree.

lib/core/presentation/utils/helpers/app_locale.dart
final class SpanishLocale extends AppLocale {
  const SpanishLocale() : super('es'); // 👈 the language code
}

Add the language to the AppLanguage enum

  • code is the language code, and it is the value written to the database.

  • name is what the language picker shows, so write it in that language.

  • The analyzer will then flag toLocale() as non-exhaustive.

    lib/core/domain/models/app_language.dart
    enum AppLanguage {
      /// English language.
      english('en', 'English'),
    
      /// French language.
      french('fr', 'Français'),
    
      /// Spanish language.
      spanish('es', 'Español'), // 👈 new language
    
      /// System language.
      system('system', 'System');
    
      const AppLanguage(this.code, this.name);
    
      // ...existing code
    
      Locale? toLocale() {
        return switch (this) {
          AppLanguage.english => const EnglishLocale(),
          AppLanguage.french => const FrenchLocale(),
          AppLanguage.spanish => const SpanishLocale(), // 👈 new locale
          AppLanguage.system => null,
        };
      }
    }

Update the appLocale getter

context.appLocale maps the active Locale back onto an AppLocale subclass. It is a second exhaustive switch over AppLanguage, so the analyzer flags this one too.

lib/core/presentation/utils/extensions/build_context_x.dart
extension BuildContextX on BuildContext {
  AppLocale get appLocale {
    final locale = Localizations.localeOf(this);

    return switch (AppLanguage.fromString(locale.languageCode)) {
      AppLanguage.english => const EnglishLocale(),
      AppLanguage.french => const FrenchLocale(),
      AppLanguage.spanish => const SpanishLocale(), // 👈 new locale
      AppLanguage.system => const EnglishLocale(),
    };
  }

  // ...existing code
}

You do not register the locale anywhere else

FlootApp passes AppLocalizations.supportedLocales and AppLocalizations.localizationsDelegates straight to MaterialApp, and both are generated from whichever ARB files exist. Add the ARB file below and the locale becomes supported; there is no hand-maintained list to keep in sync.

Translations

Duplicate app_fr.arb

Copy lib/core/presentation/l10n/app_fr.arb to app_<language_code>.arb, app_es.arb here.

Copy the French file rather than the English one on purpose. app_en.arb is the template-arb-file, so it carries an @key metadata entry for every message, descriptions and placeholder types that only the template needs. app_fr.arb holds the same messages with none of that overhead.

Set the locale and translate

Change @@locale, then replace every value. LLMs are good at this, and the file is large enough that you will want the help.

lib/core/presentation/l10n/app_es.arb
{
  "@@locale": "es",
  "aboutToQuitPage": "Estás a punto de salir de la página.",
  "account": "Cuenta"
  // ...the rest
}

Keep every key. A message missing from app_es.arb falls back to the template's English text at runtime rather than failing the build, so an incomplete file shows up as stray English in the UI, not as an error.

Generate

Terminal
flutter gen-l10n

The Dart output lands in lib/core/presentation/l10n/generated, per l10n.yaml. pubspec.yaml also sets generate: true, so flutter pub get and a normal build regenerate it for you, running the command directly is just the fastest way to see the result.

How to use localized text

Reach for the built-in context extension:

  context.l10n!.hello // 'Hola' (Locale == "es"), 'Salut' (Locale == "fr")

Back-end (Supabase)

The emails your project sends, sign-up confirmation, password reset, security alerts and the rest, are React components rendered inside Edge Functions, and their copy is stored in objects typed Record<Language, …>. That type is the mechanism: widen Language and every one of those objects stops compiling, with a file and a line for each.

So do not work from a list of templates. Work from the type checker.

Add the language to the Language union

supabase/functions/send-email/_templates/l10n.ts
export type Language = "en" | "fr" | "es"; // 👈 "es" added

Ask Deno what is now incomplete

Terminal
cd supabase/functions
deno task check

Every block that still lacks the new language is reported as a TS2741, with its path and line:

TS2741 [ERROR]: Property 'es' is missing in type '{ readonly en: ...; readonly fr: ...; }'
but required in type 'Record<Language, EmailContent>'.
const translations: Record<Language, EmailContent> = {
      ~~~~~~~~~~~~
    at .../send-email/_templates/sign-up/confirm-email.tsx:93:7

The subject lines in l10n.ts are reported the same way, because subjects is a Record<Language, …> too.

Work down the list, then re-run

Fill in the new key in each reported object and run deno task check again. When no TS2741 naming your language code is left, every typed email string has been translated.

How many blocks that is depends on which layers your project was generated with, which is exactly why this page does not enumerate them.

What the type checker cannot see

Record<Language, …> covers the email copy, and nothing else. A handful of expressions decide a language without being constrained by the union, so the type checker stays silent about them and they keep compiling, while quietly serving English to your new users. Find them by hand and confirm the list against your own project:

Terminal
grep -rn '=== "fr"' supabase/functions
grep -rn 'as Language' supabase/functions

The first grep finds places that special-case French and fall back to English for everything else; the second finds unchecked casts from users_prefs.language (a plain string column) straight to Language, which reaches translations[language] as undefined and throws if the stored value is ever outside the union.

Footer is rendered by every email template, and it does not read from l10n.ts; it carries its own translations object:

supabase/functions/send-email/_templates/components/footer.tsx
const translations: Record<Language, FooterContent> = {
  en: {
    address: `123 Main Street, Suite 100, San Francisco, CA 94105`,
    support: 'Need help? Contact us at',
    rightsReserved: 'All rights reserved.',
  },
  // ...
} as const;

deno task check will report it like any other block, so you will not forget to translate it. What it cannot tell you is that address is placeholder text. That string is your company's postal address, required in most jurisdictions on commercial email, and it is duplicated once per language, so changing it means editing every entry in this object, not one constant.

Edit the address before you send anything

The support address and the product name beside it come from supabase/functions/send-email/_templates/constants.ts and are set once. The postal address is not: it lives here, per language.

The language column is VARCHAR(2)

The user's choice is persisted in public.users_prefs:

supabase/migrations/*_init.sql
CREATE TABLE IF NOT EXISTS public.users_prefs (
  user_id UUID PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE,
  language VARCHAR(2) DEFAULT 'en',
  created_at TIMESTAMPTZ DEFAULT now(),
  updated_at TIMESTAMPTZ DEFAULT now()
);

Two characters. That is enough for es, de or ja, and it rules out every regional and script variant: pt-BR, zh-Hans, en-GB. Postgres does not truncate an over-long value, it raises 22001 value too long for type character varying(2), so the write fails rather than corrupting the preference.

If you need those codes, widen the column. Nothing else assumes the length:

Before you have ever applied the migration, edit the line above in place: language TEXT DEFAULT 'en'. Afterwards, add a migration instead:

supabase/migrations/<timestamp>_widen_language.sql
ALTER TABLE public.users_prefs
  ALTER COLUMN language TYPE TEXT;

Nothing to regenerate on the TypeScript side. supabase/functions/_shared/database.types.ts already types the column as string | null, because VARCHAR(2) and TEXT are both string there; the cap was never expressed in the types, which is part of why it is easy to miss.

On the Flutter side, AppLanguage.code is what gets written, so a regional code only needs the enum entry and a matching AppLocale subclass. Note that context.appLocale switches on locale.languageCode alone, which is pt for both pt and pt-BR, distinguishing them means matching on countryCode or scriptCode there as well.

There is no CHECK constraint either

users_prefs.language accepts any two characters, and authenticated holds UPDATE on the table. The column is the app's contract with itself, not a validated enum, which is what makes the unchecked cast in email_processor.ts reachable rather than theoretical.

On this page