← Back to Blog

Flutter SharedPreferences Locale: Save and Restore User Language Preference

fluttersharedpreferenceslocalepersistencelocalizationsettings

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: