If you want to deploy your Flutter app to users that speak another language, you'll need to localize it. And the Flutter documentation already has a detailed internationalization guide covering this topic in depth.
To make life easier, I have previously written a step-by-step guide explaining how to generate the AppLocalizations
class and access it inside our widgets using a BuildContext
extension:
But if we have some business logic that lives outside our widgets, how can we read the localized strings?
This article shows how I tackle localization using the Riverpod package in my own apps.
This article is based on the official
flutter_localization
package, which supports over 78 locales. The Flutter Gems website lists many other localization packages here.
App Localization Requirements
Before we dive into the code, let's figure out what we want to do:
- access localized strings outside our widgets (without using
BuildContext
) - ensure that any listeners (providers and widgets) rebuild when the locale changes
To satisfy these requirements, we need to combine two things:
- a
Provider<AppLocalizations>
that we can use anywhere in the app - a
LocaleObserver
class that we can use to track locale changes
Here's how to implement them. 👇
1. Creating the AppLocalizations Provider
To get the right AppLocalizations
object for the current locale, we can use the lookupAppLocalizations
method, which is generated when we run the flutter gen-l10n
command.
So the first step is to create a provider that uses it:
import 'package:flutter_riverpod/flutter_riverpod.dart';
// lookupAppLocalizations is defined here 👇
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'dart:ui' as ui;
/// provider used to access the AppLocalizations object for the current locale
final appLocalizationsProvider = Provider<AppLocalizations>((ref) {
return lookupAppLocalizations(ui.window.locale);
});
But this is not enough because our provider won't rebuild and notify its listeners when the locale changes.
2. Adding a Locale Observer
To keep track of locale changes, we can create a subclass of WidgetsBindingObserver
and override the didChangeLocales
method:
/// observer used to notify the caller when the locale changes
class _LocaleObserver extends WidgetsBindingObserver {
_LocaleObserver(this._didChangeLocales);
final void Function(List<Locale>? locales) _didChangeLocales;
@override
void didChangeLocales(List<Locale>? locales) {
_didChangeLocales(locales);
}
}
And now that we have this, we can update our provider code:
/// provider used to access the AppLocalizations object for the current locale
final appLocalizationsProvider = Provider<AppLocalizations>((ref) {
// 1. initialize from the initial locale
ref.state = lookupAppLocalizations(ui.window.locale);
// 2. create an observer to update the state
final observer = _LocaleObserver((locales) {
ref.state = lookupAppLocalizations(ui.window.locale);
});
// 3. register the observer and dispose it when no longer needed
final binding = WidgetsBinding.instance;
binding.addObserver(observer);
ref.onDispose(() => binding.removeObserver(observer));
// 4. return the state
return ref.state;
});
This ensures that any other providers or widgets that depend on appLocalizationsProvider
will rebuild when the locale changes.
Next, let's see how to use our provider.
3. Using the AppLocalizations Provider
The main idea is that we can read the appLocalizationsProvider
using a ref
object:
// get the AppLocalizations object (read once)
final loc = ref.read(appLocalizationsProvider);
// read a property defined in the *.arb file
final error = loc.addToCartFailed;
For example, consider this CartService
class that is used to update a shopping cart:
class CartService {
CartService(this.ref);
// declare ref as a property
final Ref ref;
Future<void> addItem(Item item) async {
try {
// fetch the cart
final cart = ref.read(cartRepositoryProvider).fetchCart();
// return a copy with the updated data
final updated = cart.addItem(item);
// set the cart with the updated data
await ref.read(cartRepositoryProvider).setCart(updated);
} catch (e) {
// get the localized error message
final errorMessage = ref.read(appLocalizationsProvider).addToCartFailed;
// throw it as an exception
throw Exception(errorMessage);
}
}
}
// the corresponding provider
final cartServiceProvider = Provider<CartService>((ref) {
return CartService(ref);
});
This CartService
class takes a Ref
argument, which is used to call ref.read(appLocalizationsProvider)
inside the catch block - thus making AppLocalizations
an implicit dependency.
In this case, we should read (not watch) the
appLocalizationsProvider
since we need to retrieve the error message once when theaddItem
method is called.
But if we want to make the AppLocalizations
dependency more explicit, we can do this instead:
class CartService {
CartService({required this.cartRepository, required this.loc});
// declare dependencies as explicit properties
final CartRepository cartRepository;
final AppLocalizations loc;
Future<void> addItem(Item item) async {
try {
// fetch the cart
final cart = cartRepository.fetchCart();
// return a copy with the updated data
final updated = cart.addItem(item);
// set the cart with the updated data
await cartRepository.setCart(updated);
} catch (e) {
// get the localized error message
final errorMessage = loc.addToCartFailed;
// throw it as an exception
throw Exception(errorMessage);
}
}
}
// the corresponding provider
final cartServiceProvider = Provider<CartService>((ref) {
return CartService(
// pass the dependencies explicitly (using watch)
cartRepository: ref.watch(cartRepositoryProvider),
loc: ref.watch(appLocalizationsProvider),
);
});
In this case, we should watch (not read) the
appLocalizationsProvider
in order to rebuild theCartService
returned by thecartServiceProvider
when the locale changes.
Either way, we can now access the AppLocalizations
anywhere inside our providers without using the BuildContext
.
Bonus: Using AppLocalizations for Error Handling
If we use freezed, we can define our own domain-specific exception class:
@freezed
class AppException with _$AppException {
const factory AppException.permissionDenied() = PermissionDenied;
const factory AppException.paymentFailed() = PaymentFailed;
// add other error types here
}
And if we want to map each error to a localized error message (that we will show in the UI), we can create this simple extension:
extension AppExceptionMessage on AppException {
String message(AppLocalizations loc) {
return when(
permissionDenied: () => loc.permissionDeniedMessage,
paymentFailed: () => loc.paymentFailedMessage,
// and so on...
);
}
}
Then, we can set an error state with a localized string inside a StateNotifier<AsyncValue>
subclass that our widgets can listen to:
// inside a StateNotifier subclass
final exception = AppException.permissionDenied();
final loc = ref.read(appLocalizationsProvider);
state = AsyncError(exception.message(loc));
Once again, no BuildContext
is needed to read localized strings outside our widgets.
Conclusion
We've now figured out how to access localized strings in our app:
- inside the widgets → get the
AppLocalizations
object from theBuildContext
(as explained in the previous article) - outside the widgets → get the
AppLocalizations
object from theappLocalizationsProvider
(explained here)
Either way, our widgets and providers will rebuild if the locale changes.
For instructions about how to test the locale change on iOS and Android, see this example project on GitHub:
And feel free to reuse this code in your apps if you want:
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'dart:ui' as ui;
/// provider used to access the AppLocalizations object for the current locale
final appLocalizationsProvider = Provider<AppLocalizations>((ref) {
// 1. initialize from the initial locale
ref.state = lookupAppLocalizations(ui.window.locale);
// 2. create an observer to update the state
final observer = _LocaleObserver((locales) {
ref.state = lookupAppLocalizations(ui.window.locale);
});
// 3. register the observer and dispose it when no longer needed
final binding = WidgetsBinding.instance;
binding.addObserver(observer);
ref.onDispose(() => binding.removeObserver(observer));
// 4. return the state
return ref.state;
});
/// observer used to notify the caller when the locale changes
class _LocaleObserver extends WidgetsBindingObserver {
_LocaleObserver(this._didChangeLocales);
final void Function(List<Locale>? locales) _didChangeLocales;
@override
void didChangeLocales(List<Locale>? locales) {
_didChangeLocales(locales);
}
}
Happy coding!