Flutter SharedPreferences Locale: Save and Restore User Language Preference
Want your Flutter app to remember the user's language choice? SharedPreferences is the simplest way to persist locale settings across app restarts. This guide shows you exactly how to implement it.
Why Persist Locale with SharedPreferences?
Without persistence, users must re-select their language every time they open your app. SharedPreferences solves this:
| Scenario | Without Persistence | With SharedPreferences |
|---|---|---|
| App restart | Resets to system locale | Remembers user choice |
| Language switch | Lost on close | Persisted forever |
| First launch | System default | System default (correct) |
Quick Setup
Step 1: Add Dependencies
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
shared_preferences: ^2.2.0
Step 2: Create Locale Provider
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class LocaleProvider extends ChangeNotifier {
static const String _localeKey = 'app_locale';
Locale? _locale;
Locale? get locale => _locale;
/// Load saved locale from SharedPreferences
Future<void> loadLocale() async {
final prefs = await SharedPreferences.getInstance();
final localeCode = prefs.getString(_localeKey);
if (localeCode != null) {
_locale = _parseLocale(localeCode);
notifyListeners();
}
}
/// Save and apply new locale
Future<void> setLocale(Locale newLocale) async {
if (_locale == newLocale) return;
_locale = newLocale;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_localeKey, _localeToString(newLocale));
}
/// Clear saved locale (revert to system default)
Future<void> clearLocale() async {
_locale = null;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_localeKey);
}
/// Parse locale string like "en_US" or "en"
Locale _parseLocale(String code) {
final parts = code.split('_');
if (parts.length > 1) {
return Locale(parts[0], parts[1]);
}
return Locale(parts[0]);
}
/// Convert Locale to string for storage
String _localeToString(Locale locale) {
if (locale.countryCode != null && locale.countryCode!.isNotEmpty) {
return '${locale.languageCode}_${locale.countryCode}';
}
return locale.languageCode;
}
}
Step 3: Setup in main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Load saved locale before app starts
final localeProvider = LocaleProvider();
await localeProvider.loadLocale();
runApp(
ChangeNotifierProvider.value(
value: localeProvider,
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
final localeProvider = context.watch<LocaleProvider>();
return MaterialApp(
// Use saved locale or let system decide
locale: localeProvider.locale,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('en'),
Locale('es'),
Locale('fr'),
Locale('de'),
Locale('ja'),
Locale('ar'),
],
home: const HomePage(),
);
}
}
Step 4: Create Language Selector
class LanguageSelector extends StatelessWidget {
const LanguageSelector({super.key});
static const _languages = [
('en', 'English', '🇺🇸'),
('es', 'Español', '🇪🇸'),
('fr', 'Français', '🇫🇷'),
('de', 'Deutsch', '🇩🇪'),
('ja', '日本語', '🇯🇵'),
('ar', 'العربية', '🇸🇦'),
];
@override
Widget build(BuildContext context) {
final localeProvider = context.watch<LocaleProvider>();
final currentLocale = localeProvider.locale ??
Localizations.localeOf(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Language',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
// System default option
ListTile(
leading: const Text('🌐', style: TextStyle(fontSize: 24)),
title: const Text('System Default'),
trailing: localeProvider.locale == null
? const Icon(Icons.check, color: Colors.green)
: null,
onTap: () => localeProvider.clearLocale(),
),
const Divider(),
// Language options
...languages.map((lang) {
final (code, name, flag) = lang;
final isSelected = currentLocale.languageCode == code &&
localeProvider.locale != null;
return ListTile(
leading: Text(flag, style: const TextStyle(fontSize: 24)),
title: Text(name),
trailing: isSelected
? const Icon(Icons.check, color: Colors.green)
: null,
onTap: () => localeProvider.setLocale(Locale(code)),
);
}),
],
);
}
}
Advanced Patterns
With Country Variants
class LocaleProviderAdvanced extends ChangeNotifier {
static const _localeKey = 'app_locale';
// Supported locales with country variants
static const supportedLocales = [
Locale('en', 'US'),
Locale('en', 'GB'),
Locale('es', 'ES'),
Locale('es', 'MX'),
Locale('pt', 'BR'),
Locale('pt', 'PT'),
Locale('zh', 'CN'),
Locale('zh', 'TW'),
];
Locale? _locale;
Locale? get locale => _locale;
Future<void> setLocale(Locale newLocale) async {
// Validate it's a supported locale
final isSupported = supportedLocales.any((l) =>
l.languageCode == newLocale.languageCode &&
l.countryCode == newLocale.countryCode);
if (!isSupported) {
// Fall back to language-only match
final fallback = supportedLocales.firstWhere(
(l) => l.languageCode == newLocale.languageCode,
orElse: () => supportedLocales.first,
);
newLocale = fallback;
}
_locale = newLocale;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_localeKey,
'${newLocale.languageCode}_${newLocale.countryCode}',
);
}
}
Locale Resolution Callback
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final localeProvider = context.watch<LocaleProvider>();
return MaterialApp(
locale: localeProvider.locale,
supportedLocales: LocaleProviderAdvanced.supportedLocales,
// Custom resolution logic
localeResolutionCallback: (deviceLocale, supportedLocales) {
// If user has set a preference, use it
if (localeProvider.locale != null) {
return localeProvider.locale;
}
// Try exact match with device locale
for (final locale in supportedLocales) {
if (locale.languageCode == deviceLocale?.languageCode &&
locale.countryCode == deviceLocale?.countryCode) {
return locale;
}
}
// Try language-only match
for (final locale in supportedLocales) {
if (locale.languageCode == deviceLocale?.languageCode) {
return locale;
}
}
// Default to first supported
return supportedLocales.first;
},
// ... rest of app
);
}
}
Migration from Old Storage
class LocaleProvider extends ChangeNotifier {
static const _currentKey = 'app_locale_v2';
static const _legacyKey = 'language_code'; // Old key
Future<void> loadLocale() async {
final prefs = await SharedPreferences.getInstance();
// Try current key first
var localeCode = prefs.getString(_currentKey);
// Migrate from legacy key if needed
if (localeCode == null) {
final legacyCode = prefs.getString(_legacyKey);
if (legacyCode != null) {
localeCode = legacyCode;
// Migrate to new key
await prefs.setString(_currentKey, localeCode);
await prefs.remove(_legacyKey);
debugPrint('Migrated locale from legacy key: $localeCode');
}
}
if (localeCode != null) {
_locale = _parseLocale(localeCode);
notifyListeners();
}
}
}
With Riverpod
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
// SharedPreferences provider
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
throw UnimplementedError('Initialize in main()');
});
// Locale notifier
class LocaleNotifier extends StateNotifier<Locale?> {
final SharedPreferences _prefs;
static const _key = 'app_locale';
LocaleNotifier(this._prefs) : super(null) {
_loadLocale();
}
void _loadLocale() {
final code = _prefs.getString(_key);
if (code != null) {
state = Locale(code);
}
}
Future<void> setLocale(Locale? locale) async {
state = locale;
if (locale != null) {
await _prefs.setString(_key, locale.languageCode);
} else {
await _prefs.remove(_key);
}
}
}
final localeProvider = StateNotifierProvider<LocaleNotifier, Locale?>((ref) {
final prefs = ref.watch(sharedPreferencesProvider);
return LocaleNotifier(prefs);
});
// main.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,
// ...
);
}
}
With BLoC
// locale_state.dart
abstract class LocaleState {}
class LocaleInitial extends LocaleState {}
class LocaleLoaded extends LocaleState {
final Locale? locale;
LocaleLoaded(this.locale);
}
// locale_cubit.dart
class LocaleCubit extends Cubit<LocaleState> {
static const _key = 'app_locale';
LocaleCubit() : super(LocaleInitial()) {
_loadLocale();
}
Future<void> _loadLocale() async {
final prefs = await SharedPreferences.getInstance();
final code = prefs.getString(_key);
emit(LocaleLoaded(code != null ? Locale(code) : null));
}
Future<void> setLocale(Locale? locale) async {
final prefs = await SharedPreferences.getInstance();
if (locale != null) {
await prefs.setString(_key, locale.languageCode);
} else {
await prefs.remove(_key);
}
emit(LocaleLoaded(locale));
}
}
// main.dart
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => LocaleCubit(),
child: BlocBuilder<LocaleCubit, LocaleState>(
builder: (context, state) {
final locale = state is LocaleLoaded ? state.locale : null;
return MaterialApp(
locale: locale,
// ...
);
},
),
);
}
}
Handling Edge Cases
First Launch Detection
class LocaleProvider extends ChangeNotifier {
static const _localeKey = 'app_locale';
static const _firstLaunchKey = 'first_launch_done';
bool _isFirstLaunch = false;
bool get isFirstLaunch => _isFirstLaunch;
Future<void> loadLocale() async {
final prefs = await SharedPreferences.getInstance();
// Check if first launch
_isFirstLaunch = !prefs.containsKey(_firstLaunchKey);
if (_isFirstLaunch) {
await prefs.setBool(_firstLaunchKey, true);
// Don't load locale - show language picker
notifyListeners();
return;
}
// Load saved locale
final code = prefs.getString(_localeKey);
if (code != null) {
_locale = Locale(code);
}
notifyListeners();
}
}
// Show language picker on first launch
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final localeProvider = context.watch<LocaleProvider>();
if (localeProvider.isFirstLaunch && localeProvider.locale == null) {
return const LanguagePickerScreen();
}
return const MainScreen();
}
}
Validate Stored Locale
Future<void> loadLocale() async {
final prefs = await SharedPreferences.getInstance();
final code = prefs.getString(_localeKey);
if (code != null) {
final locale = _parseLocale(code);
// Validate against supported locales
if (_isSupported(locale)) {
_locale = locale;
} else {
// Clear invalid locale
await prefs.remove(_localeKey);
debugPrint('Cleared unsupported locale: $code');
}
}
notifyListeners();
}
bool _isSupported(Locale locale) {
return supportedLocales.any((l) =>
l.languageCode == locale.languageCode);
}
Testing
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('loads null locale on first launch', () async {
final provider = LocaleProvider();
await provider.loadLocale();
expect(provider.locale, isNull);
});
test('saves and loads locale', () async {
final provider = LocaleProvider();
await provider.setLocale(const Locale('es'));
expect(provider.locale, const Locale('es'));
// Simulate app restart
final provider2 = LocaleProvider();
await provider2.loadLocale();
expect(provider2.locale?.languageCode, 'es');
});
test('clears locale correctly', () async {
SharedPreferences.setMockInitialValues({
'app_locale': 'fr',
});
final provider = LocaleProvider();
await provider.loadLocale();
expect(provider.locale?.languageCode, 'fr');
await provider.clearLocale();
expect(provider.locale, isNull);
// Verify removed from storage
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('app_locale'), isNull);
});
test('handles locale with country code', () async {
final provider = LocaleProvider();
await provider.setLocale(const Locale('pt', 'BR'));
final provider2 = LocaleProvider();
await provider2.loadLocale();
expect(provider2.locale?.languageCode, 'pt');
expect(provider2.locale?.countryCode, 'BR');
});
}
Best Practices
1. Load Before App Starts
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Load locale BEFORE runApp
final provider = LocaleProvider();
await provider.loadLocale();
runApp(MyApp(localeProvider: provider));
}
2. Provide System Default Option
Always let users revert to system language:
ListTile(
title: Text('Use System Language'),
onTap: () => localeProvider.clearLocale(),
)
3. Show Current Selection
final currentLocale = localeProvider.locale ??
Localizations.localeOf(context);
Text('Current: ${currentLocale.languageCode}');
Conclusion
SharedPreferences is the simplest solution for persisting locale in Flutter:
- Fast: Synchronous reads after initial load
- Simple: No complex setup required
- Reliable: Works offline, survives reinstalls on Android
For managing translations across your app, check out FlutterLocalisation.
Related Articles:
- Flutter Change Language at Runtime
- Flutter Provider Localization
- Free ARB Editor - Edit your translation files