AppLocalizations Without BuildContext in Flutter
Sooner or later you need a translated string in code that has no widget tree: a Bloc emitting a validation error, a Riverpod service that builds a share message, or a push notification handler running in a background isolate. AppLocalizations.of(context) doesn't help there.
The usual fix is a global navigatorKey and AppLocalizations.of(navigatorKey.currentContext!). It works until it doesn't. The context is null before the first frame, it doesn't exist at all in a background isolate, and it ties your business logic to the widget tree, so it also breaks unit tests.
gen-l10n already gives you something better. This guide covers it.
The function gen-l10n already generates
When you run flutter gen-l10n (or just build with generate: true in pubspec.yaml), the generated app_localizations.dart contains a top-level function next to the AppLocalizations class:
AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'en':
return AppLocalizationsEn();
case 'es':
return AppLocalizationsEs();
}
throw FlutterError(
'AppLocalizations.delegate failed to load unsupported locale "$locale". ...');
}
This is the same function the generated delegate calls inside load(). It takes a plain Locale and returns a ready AppLocalizations instance. No context and no widget tree. Since Locale comes from dart:ui, it also works in a background isolate.
Two things to know before using it.
First, it throws on an unsupported locale. Never pass it a raw device locale like Locale('de') if you don't ship app_de.arb. Resolve the locale first (see below).
Second, import it from your source tree. The synthetic package:flutter_gen is deprecated. Set synthetic-package: false in l10n.yaml (or rely on newer Flutter defaults) and import from where the files are generated, for example package:my_app/l10n/app_localizations.dart.
# l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
synthetic-package: false
A safe resolver
Flutter exports basicLocaleListResolution from package:flutter/widgets.dart. It's the same fallback logic MaterialApp uses by default: it matches on language and country, then on language only, and falls back to the first supported locale.
import 'dart:ui';
import 'package:flutter/widgets.dart';
import 'package:my_app/l10n/app_localizations.dart';
Locale resolveAppLocale(List<Locale> preferred) =>
basicLocaleListResolution(preferred, AppLocalizations.supportedLocales);
AppLocalizations l10nFor(List<Locale> preferred) =>
lookupAppLocalizations(resolveAppLocale(preferred));
// Anywhere, no context:
final l10n = l10nFor(PlatformDispatcher.instance.locales);
That one helper is enough for most service-layer code. The rest of this post is about keeping it in sync with the locale the user actually picked.
Riverpod: a provider that rebuilds when the locale changes
If your app lets users switch language at runtime, put the locale in a provider. Then derive AppLocalizations from it. Anything that watches the derived provider rebuilds when the language changes.
import 'dart:ui';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:my_app/l10n/app_localizations.dart';
class LocaleNotifier extends Notifier<Locale> {
@override
Locale build() => resolveAppLocale(PlatformDispatcher.instance.locales);
void setLocale(Locale locale) => state = resolveAppLocale([locale]);
}
final localeProvider =
NotifierProvider<LocaleNotifier, Locale>(LocaleNotifier.new);
final l10nProvider = Provider<AppLocalizations>(
(ref) => lookupAppLocalizations(ref.watch(localeProvider)),
);
Feed the same provider to MaterialApp, so widgets and services can never disagree:
class App extends ConsumerWidget {
const App({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp(
locale: ref.watch(localeProvider),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const HomePage(),
);
}
}
A service can now depend on l10nProvider like any other dependency:
final shareTextProvider = Provider.family<String, String>((ref, itemName) {
final l10n = ref.watch(l10nProvider);
return l10n.shareMessage(itemName);
});
In tests, override localeProvider with a fixed locale and assert on real translated output, with no pumpWidget needed. The full switch-and-persist flow, including how to avoid restarting a go_router app, is in our guide on changing the locale at runtime without restarting go_router.
Bloc: emit error codes, translate at the edge
You can inject an AppLocalizations into a Bloc constructor. For Bloc, though, the cleaner pattern is often not translating in the Bloc at all. Emit a typed code and map it to text in the widget:
enum LoginError { emptyEmail, invalidEmail, network }
class LoginState {
const LoginState({this.error});
final LoginError? error;
}
class LoginCubit extends Cubit<LoginState> {
LoginCubit() : super(const LoginState());
void submit(String email) {
if (email.isEmpty) return emit(const LoginState(error: LoginError.emptyEmail));
if (!email.contains('@')) {
return emit(const LoginState(error: LoginError.invalidEmail));
}
// ...
}
}
extension LoginErrorText on LoginError {
String message(AppLocalizations l10n) => switch (this) {
LoginError.emptyEmail => l10n.errorEmailRequired,
LoginError.invalidEmail => l10n.errorEmailInvalid,
LoginError.network => l10n.errorNetwork,
};
}
In the UI:
BlocListener<LoginCubit, LoginState>(
listenWhen: (prev, next) => next.error != null && prev.error != next.error,
listener: (context, state) {
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error!.message(l10n))),
);
},
child: const LoginForm(),
)
If you skip nullable-getter: false in l10n.yaml, AppLocalizations.of returns a nullable value, hence the !.
The tradeoff: codes vs strings
Emitting codes has real advantages for Bloc localization error messages:
- The Bloc stays pure Dart and is trivial to unit test (
expect(state.error, LoginError.invalidEmail)). - A language switch re-renders the message in the new language, because the text is built at render time, not stored in state.
- The compiler's exhaustive
switchwarns you when a new error has no translation.
The cost is one mapping extension per error type, and it doesn't fit messages that need runtime data. For those, carry the data in the code (LoginError.tooManyAttempts(minutes: 5) as a sealed class) instead of a pre-built string.
Use lookupAppLocalizations inside the Bloc only when the output leaves the app as text, for example a string you write to a file, send to an API or put in a notification. A good rule: translate as late as possible, and as early as necessary.
Push notifications in a background isolate
This is where the navigatorKey hack can't work at all. On Android, firebase_messaging runs your background handler in a separate isolate with no widget tree. The handler must be a top-level function annotated with @pragma('vm:entry-point') so release builds don't tree-shake it.
To localize notification text on the device, send a data-only FCM message (no notification block, otherwise the OS shows its own untranslated text). Then build the text in the handler:
import 'dart:ui';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:my_app/l10n/app_localizations.dart';
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
// The isolate can't read your Riverpod/Bloc state, so persist the
// user's choice when they switch language and read it back here.
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString('languageCode');
final l10n = l10nFor(
saved != null ? [Locale(saved)] : PlatformDispatcher.instance.locales,
);
final count = int.tryParse(message.data['count'] ?? '') ?? 1;
await showLocalNotification(
title: l10n.newMessagesTitle,
body: l10n.newMessagesBody(count),
);
}
void main() {
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
runApp(const ProviderScope(child: App()));
}
showLocalNotification is your own wrapper around a plugin such as flutter_local_notifications. Keep it in one place so the handler stays readable. Write languageCode from LocaleNotifier.setLocale and the foreground and background paths will always agree.
newMessagesBody(count) is an ICU plural, for example "{count, plural, one{You have 1 new message} other{You have {count} new messages}}". Plurals work fine through lookupAppLocalizations, because the generated class carries its own locale name.
Testing the context-free path
Because none of this needs a widget, you can test translations as plain unit tests:
test('Spanish email error', () {
final l10n = lookupAppLocalizations(const Locale('es'));
expect(LoginError.invalidEmail.message(l10n), isNotEmpty);
});
For the widget side (snackbars, BlocListener), you still need the delegates in your test harness. See fixing AppLocalizations.of(context) returning null in widget tests.
Keep the ARB files honest
Context-free strings tend to be the ones nobody looks at: notification bodies, error messages and share text. They are also where plurals hide. A notification that reads fine in English can be missing the few or many form Polish, Russian or Arabic need. The FlutterLocalisation ARB editor lets you manage app_<locale>.arb files across all your locales in one UI. Its ICU plural validation flags any locale that is missing a plural category its language requires, before a broken notification ships.
Try FlutterLocalisation free
Stop hand-editing ARB JSON for every locale. Try FlutterLocalisation free and see pricing when your app grows.