← Back to Blog

Flutter Unsupported Locale: Custom MaterialLocalizations

flutterlocalizationintlrtldart

Flutter Unsupported Locale: Custom MaterialLocalizations

Your app_ku.arb is 100% translated, flutter gen-l10n ran clean, and the date picker still says May, the app bar tooltip still says Back, and numbers still group as 1,234,567. Or the app just dies with No MaterialLocalizations found.

Neither is an ARB bug. gen-l10n generates your strings only; Flutter's own chrome comes from MaterialLocalizations, a separate delegate that has never heard of your locale. Verified against Flutter 3.47.0 (stable, 12 Aug 2026) and intl 0.20.3.

Step 0: check whether your locale is actually unsupported

Half these tickets need none of this. flutter_localizations ships 82 languages — wider than people assume: tl and fil, uz, am, sw, ug and ps are all in it. Check first:

import 'package:flutter_localizations/flutter_localizations.dart';

kMaterialSupportedLanguages.length;          // 82
kMaterialSupportedLanguages.contains('uz');  // true  — nothing to do
kMaterialSupportedLanguages.contains('am');  // true  — nothing to do
kMaterialSupportedLanguages.contains('ku');  // false — you are here

ku (Kurmanji), ckb (Sorani), so (Somali), ha, yo, ig, ti and dv really are absent. intl is a second, independent gate: its dateTimeSymbolMap() and numberFormatSymbols each carry 119 CLDR locales, and ku, ckb and so are in neither.

Failure 1: No MaterialLocalizations found.

Localizations loads the first delegate per type whose isSupported() returns true, and MaterialApp appends DefaultMaterialLocalizations.delegate to the end of your list.

That last-resort delegate is not a catch-all: its isSupported is literally locale.languageCode == 'en'. So with Locale('ku') resolved and nothing claiming it, no MaterialLocalizations exists. MaterialLocalizations.of(context) trips debugCheckHasMaterialLocalizations in debug and throws a null-check error in release — inside a perfectly ordinary MaterialApp, not just a bare WidgetsApp or a widget test.

The silent variant is the opposite mistake: locale: const Locale('ku') set, but Locale('ku') missing from supportedLocales. basicLocaleListResolution falls through to its last line — matchesLanguageCode ?? matchesCountryCode ?? supportedLocales.first — and hands you English. No error, no warning, English chrome and English ARB strings.

Failure 2: intl throws Invalid locale "ku"

Add the delegate and the next crash comes from intl. DateFormat('yMMMd', 'ku') runs Intl.verifiedLocale, walks its fallback chain (canonicalised, language+region, language-only, deprecated aliases), finds nothing, and throws:

ArgumentError: Invalid locale "ku"

Never initialise date data at all and you get the other one: LocaleDataException: Locale data has not been initialized, call initializeDateFormatting(<locale>). This is flutter/flutter#66553, still open.

One non-obvious detail: initializeDateFormatting() is not what supplies date data in a Flutter app. flutter_localizations bypasses date_symbol_data_local.dart and pushes its own generated tables through initializeDateFormattingCustom (see src/utils/date_localizations.dart). Since initializeDateSymbols only assigns while the map is still UninitializedLocaleData, a later initializeDateFormatting() is a silent no-op. Read base data from dateTimeSymbolMap() directly instead.

Failure 3: RTL never switches

First, get the tag right: ku is Kurmanji in Hawar Latin script, and it is LTR. The RTL variant is ckb (Central Kurdish, Arabic script) or ku-Arab. Shipping ku and expecting RTL is the original mistake in flutter/flutter#57216.

Second, Directionality is not derived from the locale. Localizations reads exactly one resource — WidgetsLocalizations.textDirection — and wraps its child in a Directionality with that value. RTL is hard-coded per generated class in flutter_localizations: only ar, fa, he, ps and ur return TextDirection.rtl. For ckb, GlobalWidgetsLocalizations.delegate rejects the locale, so the appended DefaultWidgetsLocalizations.delegate wins — its isSupported returns true for everything, and its textDirection is TextDirection.ltr. You need your own LocalizationsDelegate<WidgetsLocalizations>.

The code

1. Register date and number symbols against a close CLDR base

Clone a structurally similar CLDR locale and overwrite the words. Turkish suits Kurmanji: same Latin alphabet, Monday-first weeks, , decimals, . groups.

// lib/l10n/ku_intl_data.dart
import 'package:intl/date_symbol_data_custom.dart' as date_symbol_data_custom;
import 'package:intl/date_symbol_data_local.dart' show dateTimeSymbolMap;
import 'package:intl/date_symbols.dart';
import 'package:intl/date_time_patterns.dart' show dateTimePatternMap;
import 'package:intl/number_symbols.dart';
import 'package:intl/number_symbols_data.dart' show numberFormatSymbols;

// Get these signed off by a native speaker.
const kuMonths = <String>[
  'Çile', 'Sibat', 'Adar', 'Nîsan', 'Gulan', 'Hezîran',
  'Tîrmeh', 'Tebax', 'Îlon', 'Cotmeh', 'Mijdar', 'Kanûn',
];
const kuShortMonths = <String>[
  'Çil', 'Sib', 'Adr', 'Nîs', 'Gul', 'Hez',
  'Tîr', 'Teb', 'Îlo', 'Cot', 'Mij', 'Kan',
];
const kuNarrowMonths = <String>[
  'Ç', 'S', 'A', 'N', 'G', 'H', 'T', 'T', 'Î', 'C', 'M', 'K',
];
// DateSymbols weekday lists are Sunday-first.
const kuWeekdays = <String>[
  'Yekşem', 'Duşem', 'Sêşem', 'Çarşem', 'Pêncşem', 'Înî', 'Şemî',
];
const kuShortWeekdays = <String>['Yş', 'Dş', 'Sş', 'Çş', 'Pş', 'În', 'Şm'];
const kuNarrowWeekdays = <String>['Y', 'D', 'S', 'Ç', 'P', 'Î', 'Ş'];

bool _registered = false;

void registerKurdishIntlData() {
  if (_registered) return;
  _registered = true;

  const base = 'tr';

  // dateTimeSymbolMap() builds the CLDR map on demand; it does not need
  // initializeDateFormatting() to have run.
  final Map<String, dynamic> symbols =
      dateTimeSymbolMap()[base]!.serializeToMap()
        ..['NAME'] = 'ku'
        ..['AMPMS'] = <String>['BN', 'PN'];

  const overrides = <String, List<String>>{
    'MONTHS': kuMonths,
    'SHORTMONTHS': kuShortMonths,
    'NARROWMONTHS': kuNarrowMonths,
    'WEEKDAYS': kuWeekdays,
    'SHORTWEEKDAYS': kuShortWeekdays,
    'NARROWWEEKDAYS': kuNarrowWeekdays,
  };
  overrides.forEach((String key, List<String> value) {
    symbols[key] = value;
    symbols['STANDALONE$key'] = value; // every key has a STANDALONE twin
  });

  // Throws ArgumentError unless locale == symbols.NAME.
  date_symbol_data_custom.initializeDateFormattingCustom(
    locale: 'ku',
    symbols: DateSymbols.deserializeFromMap(symbols),
    patterns: dateTimePatternMap()[base]!,
  );

  // NumberFormat has no registration API: it reads this public, mutable
  // map directly.
  numberFormatSymbols['ku'] = const NumberSymbols(
    NAME: 'ku',
    DECIMAL_SEP: ',',
    GROUP_SEP: '.',
    PERCENT: '%',
    ZERO_DIGIT: '0',
    PLUS_SIGN: '+',
    MINUS_SIGN: '-',
    EXP_SYMBOL: 'E',
    PERMILL: '‰',
    INFINITY: '∞',
    NAN: 'NaN',
    DECIMAL_PATTERN: '#,##0.###',
    SCIENTIFIC_PATTERN: '#E0',
    PERCENT_PATTERN: '%#,##0',
    CURRENCY_PATTERN: '¤#,##0.00',
    DEF_CURRENCY_CODE: 'IQD',
  );
}

2. Subclass DefaultMaterialLocalizations

MaterialLocalizations has well over a hundred members and Flutter adds more each release. Implement the interface directly and every upgrade breaks the build. Extend DefaultMaterialLocalizations and new members arrive with an English default.

// lib/l10n/ku_material_localizations.dart
import 'package:flutter/foundation.dart' show SynchronousFuture;
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';

import 'ku_intl_data.dart';

class KuMaterialLocalizations extends DefaultMaterialLocalizations {
  const KuMaterialLocalizations();

  static const LocalizationsDelegate<MaterialLocalizations> delegate =
      _KuMaterialLocalizationsDelegate();

  // --- chrome; everything you skip stays English until translated ---
  @override
  String get backButtonTooltip => 'Vegere';
  @override
  String get okButtonLabel => 'Baş e';
  @override
  String get rowsPerPageTitle => 'Rêz di rûpelê de:';

  // --- calendar ---
  // narrowWeekdays stays Sunday-first; firstDayOfWeekIndex indexes into it.
  @override
  List<String> get narrowWeekdays => kuNarrowWeekdays;
  @override
  int get firstDayOfWeekIndex => 1; // Monday

  // --- dates: backed by the symbols registered in step 1 ---
  @override
  String formatYear(DateTime date) => DateFormat('y', 'ku').format(date);
  @override
  String formatMonthYear(DateTime date) => DateFormat('MMMM y', 'ku').format(date);
  @override
  String formatShortDate(DateTime date) => DateFormat('yMMMd', 'ku').format(date);
  @override
  String formatMediumDate(DateTime date) =>
      DateFormat('EEE, d MMM', 'ku').format(date);
  @override
  String formatFullDate(DateTime date) =>
      DateFormat('EEEE, d MMMM y', 'ku').format(date);
  // If you also override formatCompactDate, override dateSeparator,
  // dateHelpText and parseCompactDate to match — the inherited set is
  // mm/dd/yyyy and the text-entry date field will reject your format.

  // --- numbers: this is the thousands-separator fix ---
  @override
  String formatDecimal(int number) =>
      NumberFormat.decimalPattern('ku').format(number);
}

class _KuMaterialLocalizationsDelegate
    extends LocalizationsDelegate<MaterialLocalizations> {
  const _KuMaterialLocalizationsDelegate();

  @override
  bool isSupported(Locale locale) => locale.languageCode == 'ku';

  @override
  Future<MaterialLocalizations> load(Locale locale) {
    registerKurdishIntlData(); // must run before any DateFormat('…', 'ku')
    return SynchronousFuture<MaterialLocalizations>(
      const KuMaterialLocalizations(),
    );
  }

  @override
  bool shouldReload(_KuMaterialLocalizationsDelegate old) => false;
}

DefaultMaterialLocalizations.formatDecimal hard-codes a , group separator in a hand-rolled loop — that is why paginated tables stayed English after the ARB was done. The NumberFormat.decimalPattern('ku') override fixes it.

3. A WidgetsLocalizations delegate for RTL

class CkbWidgetsLocalizations extends DefaultWidgetsLocalizations {
  const CkbWidgetsLocalizations();

  static const LocalizationsDelegate<WidgetsLocalizations> delegate =
      _CkbWidgetsLocalizationsDelegate();

  @override
  TextDirection get textDirection => TextDirection.rtl;
}

class _CkbWidgetsLocalizationsDelegate
    extends LocalizationsDelegate<WidgetsLocalizations> {
  const _CkbWidgetsLocalizationsDelegate();

  @override
  bool isSupported(Locale locale) =>
      locale.languageCode == 'ckb' ||
      (locale.languageCode == 'ku' && locale.scriptCode == 'Arab');

  @override
  Future<WidgetsLocalizations> load(Locale locale) =>
      SynchronousFuture<WidgetsLocalizations>(const CkbWidgetsLocalizations());

  @override
  bool shouldReload(_CkbWidgetsLocalizationsDelegate old) => false;
}

4. Wire it up — order and resolution

MaterialApp(
  // First delegate per type that claims the locale wins. Custom ones go
  // first: harmless when the globals reject the locale, required when you
  // want to override one the globals already support.
  localizationsDelegates: const <LocalizationsDelegate<Object>>[
    KuMaterialLocalizations.delegate,
    CkbWidgetsLocalizations.delegate,
    AppLocalizations.delegate, // your gen-l10n output
    GlobalMaterialLocalizations.delegate,
    GlobalWidgetsLocalizations.delegate,
    GlobalCupertinoLocalizations.delegate,
  ],
  supportedLocales: const <Locale>[Locale('en'), Locale('ku'), Locale('ckb')],
  localeListResolutionCallback: (locales, supported) {
    for (final Locale locale in locales ?? const <Locale>[]) {
      // Devices report ku-Arab-IQ, ckb-IR, or the legacy tag ku_IQ.
      if (locale.languageCode == 'ckb' ||
          (locale.languageCode == 'ku' && locale.scriptCode == 'Arab')) {
        return const Locale('ckb');
      }
      for (final Locale candidate in supported) {
        if (candidate.languageCode == locale.languageCode) return candidate;
      }
    }
    // Whatever reaches here gets supportedLocales.first — make that
    // deliberate, not an accident.
    return basicLocaleListResolution(locales, supported);
  },
)

If you render Cupertino widgets (iOS text-selection toolbars pull from CupertinoLocalizations), add a LocalizationsDelegate<CupertinoLocalizations> the same way — that default delegate is en-only too.

Then guard it in CI: for every locale in supportedLocales, assert some LocalizationsDelegate<MaterialLocalizations> returns true from isSupported. A locale added without one is a runtime crash and a green CI run.

5. Belt and braces on direction

MaterialApp.builder runs below MaterialApp's Localizations and above the Navigator, so a Directionality there covers routes, dialogs and overlays:

builder: (BuildContext context, Widget? child) {
  final Locale locale = Localizations.localeOf(context);
  return Directionality(
    // intl's Bidi regex already knows ckb, dv, ug and *-Arab tags.
    textDirection: Bidi.isRtlLanguage(locale.toLanguageTag())
        ? TextDirection.rtl
        : TextDirection.ltr,
    child: child!,
  );
},

Treat it as a safety net, not the fix — it does not update the Semantics text direction Localizations sets, so keep the delegate.

Keeping the ARB side honest

None of this helps if the ARB is wrong, and unsupported locales are where that bites hardest: no reference translation to diff against, and plural categories your en file never taught you. FlutterLocalisation's ARB editor gives translators a UI over app_ku.arb instead of raw JSON, manages the whole locale set in one place, and its ICU plural validation flags a locale missing a plural category the language needs — a bug flutter gen-l10n compiles happily.

Try FlutterLocalisation free, and browse the rest of the Flutter i18n guides while your translator fills in those month names.