Flutter Riverpod Localization: Complete Guide to i18n with Riverpod 2.0
Riverpod has become the go-to state management solution for Flutter. But how do you handle localization with Riverpod's reactive architecture? This guide covers everything from basic setup to advanced patterns for building truly reactive multilingual apps.
Why Riverpod for Localization?
Riverpod offers unique advantages for localization:
- Reactive Updates: Locale changes propagate automatically
- No Context Required: Access translations anywhere with
ref - Testability: Easy to mock translations in tests
- Code Generation: Type-safe locale providers
- Scoping: Override translations per widget tree
Basic Setup: Locale Provider
Step 1: Create the Locale Provider
// lib/providers/locale_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
final localeProvider = StateNotifierProvider<LocaleNotifier, Locale>((ref) {
return LocaleNotifier();
});
class LocaleNotifier extends StateNotifier<Locale> {
LocaleNotifier() : super(const Locale('en')) {
_loadSavedLocale();
}
static const _localeKey = 'app_locale';
Future<void> _loadSavedLocale() async {
final prefs = await SharedPreferences.getInstance();
final savedLocale = prefs.getString(_localeKey);
if (savedLocale != null) {
state = Locale(savedLocale);
}
}
Future<void> setLocale(Locale locale) async {
state = locale;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_localeKey, locale.languageCode);
}
void setLocaleFromCode(String languageCode) {
setLocale(Locale(languageCode));
}
}
Step 2: Create Localization Provider
// lib/providers/localization_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'locale_provider.dart';
// This provider requires a BuildContext to work
final localizationsProvider = Provider<AppLocalizations?>((ref) {
// This will be overridden in the widget tree
return null;
});
// Alternative: Create a provider that holds translations by locale
final translationsProvider = FutureProvider.family<AppLocalizations, Locale>(
(ref, locale) async {
return await AppLocalizations.delegate.load(locale);
},
);
Step 3: Configure MaterialApp
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'providers/locale_provider.dart';
void main() {
runApp(const ProviderScope(child: MyApp()));
}
class MyApp extends ConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final locale = ref.watch(localeProvider);
return MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const HomeScreen(),
);
}
}
Accessing Translations in Widgets
Using Context (Standard Approach)
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(l10n.home_title)),
body: Center(
child: Text(l10n.welcome_message),
),
);
}
}
Without Context (Using Provider Override)
For accessing translations without context, use a provider override:
// lib/main.dart
class MyApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final locale = ref.watch(localeProvider);
return MaterialApp(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
builder: (context, child) {
// Override the localizationsProvider with actual value
return ProviderScope(
overrides: [
localizationsProvider.overrideWithValue(
AppLocalizations.of(context),
),
],
child: child!,
);
},
home: const HomeScreen(),
);
}
}
// Now use in any provider
final greetingProvider = Provider<String>((ref) {
final l10n = ref.watch(localizationsProvider);
return l10n?.welcome_message ?? 'Welcome';
});
Advanced Patterns
Pattern 1: Locale-Aware Data Fetching
final productsProvider = FutureProvider<List<Product>>((ref) async {
final locale = ref.watch(localeProvider);
final api = ref.read(apiClientProvider);
// Fetch products with localized content
return api.getProducts(locale: locale.languageCode);
});
// Products automatically refetch when locale changes!
Pattern 2: Formatted Values Provider
final formattedPriceProvider = Provider.family<String, double>((ref, amount) {
final locale = ref.watch(localeProvider);
final format = NumberFormat.currency(
locale: locale.toString(),
symbol: _getCurrencySymbol(locale),
);
return format.format(amount);
});
// Usage in widget
Text(ref.watch(formattedPriceProvider(99.99))) // "$99.99" or "99,99 €"
Pattern 3: Date Formatting Provider
final dateFormatProvider = Provider.family<String, DateTime>((ref, date) {
final locale = ref.watch(localeProvider);
final format = DateFormat.yMMMMd(locale.toString());
return format.format(date);
});
final relativeDateProvider = Provider.family<String, DateTime>((ref, date) {
final locale = ref.watch(localeProvider);
final l10n = ref.watch(localizationsProvider);
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays == 0) {
return l10n?.today ?? 'Today';
} else if (difference.inDays == 1) {
return l10n?.yesterday ?? 'Yesterday';
} else if (difference.inDays < 7) {
return l10n?.days_ago(difference.inDays) ?? '${difference.inDays} days ago';
} else {
return DateFormat.yMd(locale.toString()).format(date);
}
});
Pattern 4: Validation Messages Provider
final validationMessagesProvider = Provider<ValidationMessages>((ref) {
final l10n = ref.watch(localizationsProvider);
return ValidationMessages(
required: l10n?.validation_required ?? 'This field is required',
email: l10n?.validation_email ?? 'Enter a valid email',
minLength: (int min) =>
l10n?.validation_min_length(min) ?? 'Minimum $min characters',
maxLength: (int max) =>
l10n?.validation_max_length(max) ?? 'Maximum $max characters',
);
});
class ValidationMessages {
final String required;
final String email;
final String Function(int) minLength;
final String Function(int) maxLength;
ValidationMessages({
required this.required,
required this.email,
required this.minLength,
required this.maxLength,
});
}
// Usage in form validation
final validator = ref.read(validationMessagesProvider);
if (value.isEmpty) return validator.required;
if (value.length < 3) return validator.minLength(3);
Pattern 5: Async Translation Loading
final asyncLocalizationsProvider = FutureProvider<AppLocalizations>((ref) async {
final locale = ref.watch(localeProvider);
// Simulate loading translations from remote
await Future.delayed(const Duration(milliseconds: 100));
return await AppLocalizations.delegate.load(locale);
});
// Usage with loading state
class MyWidget extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncL10n = ref.watch(asyncLocalizationsProvider);
return asyncL10n.when(
data: (l10n) => Text(l10n.welcome_message),
loading: () => const CircularProgressIndicator(),
error: (err, stack) => Text('Error: $err'),
);
}
}
Language Switcher with Riverpod
Simple Dropdown Switcher
class LanguageSwitcher extends ConsumerWidget {
const LanguageSwitcher({super.key});
static const supportedLocales = [
Locale('en', 'US'),
Locale('es', 'ES'),
Locale('fr', 'FR'),
Locale('de', 'DE'),
];
String _getLanguageName(Locale locale) {
switch (locale.languageCode) {
case 'en': return 'English';
case 'es': return 'Español';
case 'fr': return 'Français';
case 'de': return 'Deutsch';
default: return locale.languageCode;
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentLocale = ref.watch(localeProvider);
return DropdownButton<Locale>(
value: currentLocale,
items: supportedLocales.map((locale) {
return DropdownMenuItem(
value: locale,
child: Text(_getLanguageName(locale)),
);
}).toList(),
onChanged: (locale) {
if (locale != null) {
ref.read(localeProvider.notifier).setLocale(locale);
}
},
);
}
}
Animated Language Switcher
class AnimatedLanguageSwitcher extends ConsumerStatefulWidget {
const AnimatedLanguageSwitcher({super.key});
@override
ConsumerState<AnimatedLanguageSwitcher> createState() =>
_AnimatedLanguageSwitcherState();
}
class _AnimatedLanguageSwitcherState
extends ConsumerState<AnimatedLanguageSwitcher> {
bool _isExpanded = false;
@override
Widget build(BuildContext context) {
final currentLocale = ref.watch(localeProvider);
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () => setState(() => _isExpanded = !_isExpanded),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_getFlagEmoji(currentLocale),
style: const TextStyle(fontSize: 24),
),
const SizedBox(width: 8),
Icon(
_isExpanded
? Icons.expand_less
: Icons.expand_more,
color: Colors.white,
),
],
),
),
),
if (_isExpanded) ...[
const SizedBox(height: 8),
...AppLocalizations.supportedLocales
.where((l) => l != currentLocale)
.map((locale) => _buildLocaleOption(locale)),
],
],
),
);
}
Widget _buildLocaleOption(Locale locale) {
return GestureDetector(
onTap: () {
ref.read(localeProvider.notifier).setLocale(locale);
setState(() => _isExpanded = false);
},
child: Container(
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.only(bottom: 4),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
child: Text(
_getFlagEmoji(locale),
style: const TextStyle(fontSize: 24),
),
),
);
}
String _getFlagEmoji(Locale locale) {
switch (locale.languageCode) {
case 'en': return '🇺🇸';
case 'es': return '🇪🇸';
case 'fr': return '🇫🇷';
case 'de': return '🇩🇪';
default: return '🌐';
}
}
}
Testing Localization with Riverpod
Unit Testing Providers
void main() {
group('LocaleProvider', () {
test('initial locale is English', () {
final container = ProviderContainer();
addTearDown(container.dispose);
expect(
container.read(localeProvider),
equals(const Locale('en')),
);
});
test('setLocale updates state', () async {
final container = ProviderContainer();
addTearDown(container.dispose);
container.read(localeProvider.notifier).setLocale(const Locale('es'));
expect(
container.read(localeProvider),
equals(const Locale('es')),
);
});
});
group('FormattedPriceProvider', () {
test('formats price for English locale', () {
final container = ProviderContainer(
overrides: [
localeProvider.overrideWith((ref) => LocaleNotifier()..state = const Locale('en', 'US')),
],
);
addTearDown(container.dispose);
final formatted = container.read(formattedPriceProvider(99.99));
expect(formatted, contains('99.99'));
});
test('formats price for German locale', () {
final container = ProviderContainer(
overrides: [
localeProvider.overrideWith((ref) => LocaleNotifier()..state = const Locale('de', 'DE')),
],
);
addTearDown(container.dispose);
final formatted = container.read(formattedPriceProvider(99.99));
expect(formatted, contains('99,99'));
});
});
}
Widget Testing
void main() {
testWidgets('displays localized welcome message', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
localizationsProvider.overrideWithValue(
MockAppLocalizations(
welcomeMessage: 'Bienvenue!',
),
),
],
child: const MaterialApp(home: HomeScreen()),
),
);
expect(find.text('Bienvenue!'), findsOneWidget);
});
testWidgets('language switcher changes locale', (tester) async {
await tester.pumpWidget(
const ProviderScope(
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: LanguageSwitcher(),
),
),
);
// Open dropdown
await tester.tap(find.byType(DropdownButton<Locale>));
await tester.pumpAndSettle();
// Select Spanish
await tester.tap(find.text('Español').last);
await tester.pumpAndSettle();
// Verify locale changed (would need to check provider state)
});
}
Best Practices Summary
Use StateNotifier for locale management - Handles persistence and state updates cleanly
Watch locale in dependent providers - Ensures automatic refetch when language changes
Override localizationsProvider in widget tree - Enables context-free translation access
Create specialized formatting providers - Numbers, dates, and currencies locale-aware
Test with ProviderContainer overrides - Easy mocking for unit tests
Use family providers for parameterized translations - Clean API for formatted strings
Conclusion
Riverpod's reactive architecture makes localization elegant and maintainable. Key takeaways:
- Locale changes automatically propagate through the widget tree
- Providers can access translations without BuildContext
- Testing is straightforward with provider overrides
- Formatting providers keep UI code clean
Combine Riverpod with FlutterLocalisation for a complete solution that handles both state management and translation management seamlessly.
Managing translations for your Riverpod app? Try FlutterLocalisation free with Git sync and team collaboration.