Flutter Localization with State Management: Provider, Riverpod, and Bloc Patterns
Building a language switcher in Flutter? You need state management that works seamlessly with localization. This guide shows how to implement runtime language switching using Provider, Riverpod, and Bloc.
Why State Management for Localization?
Flutter's built-in localization follows the system locale by default. But users often want to:
- Override system language
- Switch languages instantly
- Persist language preference
- Support languages their system doesn't have
State management solves these requirements elegantly.
The Basic Pattern
All approaches follow this pattern:
User selects language → State updates → MaterialApp rebuilds → New translations load
The key is making MaterialApp reactive to locale changes:
MaterialApp(
locale: currentLocale, // This must be reactive
supportedLocales: [...],
localizationsDelegates: [...],
)
Provider Implementation
Provider is the simplest approach for most apps.
Step 1: Create LocaleProvider
// lib/providers/locale_provider.dart
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class LocaleProvider extends ChangeNotifier {
Locale? _locale;
Locale? get locale => _locale;
// Initialize from saved preference
Future<void> init() async {
final prefs = await SharedPreferences.getInstance();
final languageCode = prefs.getString('language_code');
if (languageCode != null) {
_locale = Locale(languageCode);
notifyListeners();
}
}
// Change locale and persist
Future<void> setLocale(Locale locale) async {
_locale = locale;
notifyListeners();
// Persist preference
final prefs = await SharedPreferences.getInstance();
await prefs.setString('language_code', locale.languageCode);
}
// Clear preference and use system locale
Future<void> clearLocale() async {
_locale = null;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.remove('language_code');
}
}
Step 2: Set Up Provider
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final localeProvider = LocaleProvider();
await localeProvider.init();
runApp(
ChangeNotifierProvider.value(
value: localeProvider,
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return Consumer<LocaleProvider>(
builder: (context, localeProvider, child) {
return MaterialApp(
locale: localeProvider.locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: const HomePage(),
);
},
);
}
}
Step 3: Create Language Switcher Widget
// lib/widgets/language_switcher.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LanguageSwitcher extends StatelessWidget {
const LanguageSwitcher({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final currentLocale = Localizations.localeOf(context);
return PopupMenuButton<Locale>(
icon: const Icon(Icons.language),
tooltip: l10n.changeLanguage,
onSelected: (Locale locale) {
context.read<LocaleProvider>().setLocale(locale);
},
itemBuilder: (context) => [
_buildMenuItem(context, const Locale('en'), 'English', currentLocale),
_buildMenuItem(context, const Locale('es'), 'Español', currentLocale),
_buildMenuItem(context, const Locale('fr'), 'Français', currentLocale),
_buildMenuItem(context, const Locale('de'), 'Deutsch', currentLocale),
_buildMenuItem(context, const Locale('ar'), 'العربية', currentLocale),
],
);
}
PopupMenuItem<Locale> _buildMenuItem(
BuildContext context,
Locale locale,
String name,
Locale currentLocale,
) {
final isSelected = locale.languageCode == currentLocale.languageCode;
return PopupMenuItem<Locale>(
value: locale,
child: Row(
children: [
if (isSelected)
const Icon(Icons.check, size: 18)
else
const SizedBox(width: 18),
const SizedBox(width: 8),
Text(name),
],
),
);
}
}
Usage
AppBar(
title: Text(AppLocalizations.of(context)!.appTitle),
actions: const [
LanguageSwitcher(),
],
)
Riverpod Implementation
Riverpod offers better testability and compile-time safety.
Step 1: Create Locale Notifier
// lib/providers/locale_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
// Shared preferences provider
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
throw UnimplementedError('Initialize in main');
});
// Locale state notifier
class LocaleNotifier extends StateNotifier<Locale?> {
final SharedPreferences prefs;
LocaleNotifier(this.prefs) : super(null) {
_loadSavedLocale();
}
void _loadSavedLocale() {
final languageCode = prefs.getString('language_code');
if (languageCode != null) {
state = Locale(languageCode);
}
}
Future<void> setLocale(Locale locale) async {
state = locale;
await prefs.setString('language_code', locale.languageCode);
}
Future<void> clearLocale() async {
state = null;
await prefs.remove('language_code');
}
}
// Provider
final localeProvider = StateNotifierProvider<LocaleNotifier, Locale?>((ref) {
final prefs = ref.watch(sharedPreferencesProvider);
return LocaleNotifier(prefs);
});
Step 2: Set Up Riverpod
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
runApp(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
],
child: const 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,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: const HomePage(),
);
}
}
Step 3: Language Switcher with Riverpod
// lib/widgets/language_switcher.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LanguageSwitcher extends ConsumerWidget {
const LanguageSwitcher({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final currentLocale = Localizations.localeOf(context);
return DropdownButton<Locale>(
value: currentLocale,
underline: const SizedBox(),
icon: const Icon(Icons.language),
items: const [
DropdownMenuItem(value: Locale('en'), child: Text('English')),
DropdownMenuItem(value: Locale('es'), child: Text('Español')),
DropdownMenuItem(value: Locale('fr'), child: Text('Français')),
DropdownMenuItem(value: Locale('de'), child: Text('Deutsch')),
],
onChanged: (Locale? locale) {
if (locale != null) {
ref.read(localeProvider.notifier).setLocale(locale);
}
},
);
}
}
Riverpod Code Generation (Recommended)
With riverpod_generator:
// lib/providers/locale_provider.dart
import 'package:flutter/material.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'locale_provider.g.dart';
@riverpod
class Locale extends _$Locale {
@override
Locale? build() {
_loadSavedLocale();
return null;
}
Future<void> _loadSavedLocale() async {
final prefs = await SharedPreferences.getInstance();
final languageCode = prefs.getString('language_code');
if (languageCode != null) {
state = Locale(languageCode);
}
}
Future<void> setLocale(Locale locale) async {
state = locale;
final prefs = await SharedPreferences.getInstance();
await prefs.setString('language_code', locale.languageCode);
}
}
Bloc Implementation
Bloc is ideal for complex apps with strict architectural requirements.
Step 1: Define Events and States
// lib/bloc/locale/locale_event.dart
import 'package:flutter/material.dart';
abstract class LocaleEvent {}
class LocaleChanged extends LocaleEvent {
final Locale locale;
LocaleChanged(this.locale);
}
class LocaleCleared extends LocaleEvent {}
class LocaleLoaded extends LocaleEvent {}
// lib/bloc/locale/locale_state.dart
import 'package:flutter/material.dart';
class LocaleState {
final Locale? locale;
final bool isLoading;
const LocaleState({this.locale, this.isLoading = false});
LocaleState copyWith({Locale? locale, bool? isLoading}) {
return LocaleState(
locale: locale ?? this.locale,
isLoading: isLoading ?? this.isLoading,
);
}
}
Step 2: Create Bloc
// lib/bloc/locale/locale_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'locale_event.dart';
import 'locale_state.dart';
class LocaleBloc extends Bloc<LocaleEvent, LocaleState> {
final SharedPreferences prefs;
LocaleBloc(this.prefs) : super(const LocaleState(isLoading: true)) {
on<LocaleLoaded>(_onLocaleLoaded);
on<LocaleChanged>(_onLocaleChanged);
on<LocaleCleared>(_onLocaleCleared);
// Load saved locale on initialization
add(LocaleLoaded());
}
Future<void> _onLocaleLoaded(
LocaleLoaded event,
Emitter<LocaleState> emit,
) async {
final languageCode = prefs.getString('language_code');
final locale = languageCode != null ? Locale(languageCode) : null;
emit(LocaleState(locale: locale, isLoading: false));
}
Future<void> _onLocaleChanged(
LocaleChanged event,
Emitter<LocaleState> emit,
) async {
await prefs.setString('language_code', event.locale.languageCode);
emit(state.copyWith(locale: event.locale));
}
Future<void> _onLocaleCleared(
LocaleCleared event,
Emitter<LocaleState> emit,
) async {
await prefs.remove('language_code');
emit(const LocaleState(locale: null));
}
}
Step 3: Set Up Bloc Provider
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final prefs = await SharedPreferences.getInstance();
runApp(
BlocProvider(
create: (context) => LocaleBloc(prefs),
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<LocaleBloc, LocaleState>(
builder: (context, state) {
if (state.isLoading) {
return const MaterialApp(
home: Scaffold(
body: Center(child: CircularProgressIndicator()),
),
);
}
return MaterialApp(
locale: state.locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: const HomePage(),
);
},
);
}
}
Step 4: Language Switcher with Bloc
// lib/widgets/language_switcher.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class LanguageSwitcher extends StatelessWidget {
const LanguageSwitcher({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return BlocBuilder<LocaleBloc, LocaleState>(
builder: (context, state) {
return ListTile(
leading: const Icon(Icons.language),
title: Text(l10n.language),
subtitle: Text(_getLanguageName(state.locale)),
onTap: () => _showLanguagePicker(context),
);
},
);
}
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 'System Default';
}
}
void _showLanguagePicker(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: const Text('System Default'),
onTap: () {
context.read<LocaleBloc>().add(LocaleCleared());
Navigator.pop(context);
},
),
ListTile(
title: const Text('English'),
onTap: () {
context.read<LocaleBloc>().add(LocaleChanged(const Locale('en')));
Navigator.pop(context);
},
),
ListTile(
title: const Text('Español'),
onTap: () {
context.read<LocaleBloc>().add(LocaleChanged(const Locale('es')));
Navigator.pop(context);
},
),
// Add more languages...
],
),
);
}
}
Comparison: Which to Choose?
| Feature | Provider | Riverpod | Bloc |
|---|---|---|---|
| Setup complexity | Low | Medium | High |
| Boilerplate | Low | Low | High |
| Testability | Good | Excellent | Excellent |
| Type safety | Good | Excellent | Good |
| Learning curve | Easy | Medium | Steep |
| Best for | Simple apps | Most apps | Complex apps |
Recommendations:
- Provider: Quick prototypes, simple apps, teams new to Flutter
- Riverpod: Production apps, better architecture, most use cases
- Bloc: Large teams, strict patterns, complex business logic
Advanced Patterns
Locale with Region Support
class LocaleProvider extends ChangeNotifier {
Locale? _locale;
void setLocale(String languageCode, [String? countryCode]) {
_locale = countryCode != null
? Locale(languageCode, countryCode)
: Locale(languageCode);
notifyListeners();
}
}
// Usage: Different Spanish for Spain vs Mexico
localeProvider.setLocale('es', 'ES'); // Spanish (Spain)
localeProvider.setLocale('es', 'MX'); // Spanish (Mexico)
Locale Resolution Callback
MaterialApp(
locale: localeProvider.locale,
localeResolutionCallback: (deviceLocale, supportedLocales) {
// Custom resolution logic
if (localeProvider.locale != null) {
return localeProvider.locale;
}
// Check if device locale is supported
for (var locale in supportedLocales) {
if (locale.languageCode == deviceLocale?.languageCode) {
return locale;
}
}
// Default to English
return const Locale('en');
},
)
Combining with Theme
class AppSettingsProvider extends ChangeNotifier {
Locale? _locale;
ThemeMode _themeMode = ThemeMode.system;
Locale? get locale => _locale;
ThemeMode get themeMode => _themeMode;
void setLocale(Locale locale) {
_locale = locale;
notifyListeners();
}
void setThemeMode(ThemeMode mode) {
_themeMode = mode;
notifyListeners();
}
}
// Usage in MaterialApp
Consumer<AppSettingsProvider>(
builder: (context, settings, child) {
return MaterialApp(
locale: settings.locale,
themeMode: settings.themeMode,
// ...
);
},
)
Testing Localization State
Provider Tests
void main() {
group('LocaleProvider', () {
late LocaleProvider provider;
late SharedPreferences prefs;
setUp(() async {
SharedPreferences.setMockInitialValues({});
prefs = await SharedPreferences.getInstance();
provider = LocaleProvider();
});
test('setLocale updates state and persists', () async {
await provider.setLocale(const Locale('es'));
expect(provider.locale?.languageCode, 'es');
expect(prefs.getString('language_code'), 'es');
});
test('init loads saved locale', () async {
await prefs.setString('language_code', 'fr');
await provider.init();
expect(provider.locale?.languageCode, 'fr');
});
});
}
Widget Tests
testWidgets('language switcher changes locale', (tester) async {
final provider = LocaleProvider();
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: provider,
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const LanguageSwitcher(),
),
),
);
// Open language menu
await tester.tap(find.byIcon(Icons.language));
await tester.pumpAndSettle();
// Select Spanish
await tester.tap(find.text('Español'));
await tester.pumpAndSettle();
expect(provider.locale?.languageCode, 'es');
});
Summary
State management is essential for runtime language switching in Flutter:
- Provider: Simplest setup, great for most apps
- Riverpod: Better testability and type safety
- Bloc: Strict patterns for complex apps
Key principles:
- Store locale in state management
- Persist preference with SharedPreferences
- Make MaterialApp reactive to locale changes
- Provide clear UI for language selection
- Test thoroughly
Choose based on your app's complexity and team familiarity.
Managing translations for multiple languages? FlutterLocalisation makes it easy with AI translation and team collaboration. Start for free.