Flutter BLoC Localization: Complete Guide to i18n with flutter_bloc
BLoC (Business Logic Component) is one of the most popular state management solutions for Flutter, known for its separation of concerns and testability. When building multilingual apps, integrating localization with BLoC requires careful architecture decisions. This guide covers everything from basic setup to advanced patterns.
Why BLoC for Localization?
BLoC offers several advantages for managing localization:
- Predictable state changes - Language switches follow a clear event → state flow
- Testability - Easy to unit test locale switching logic
- Separation of concerns - Locale management stays out of UI code
- Stream-based - Reactive updates when locale changes
- Scalability - Works well in large applications with multiple features
Basic Setup
First, add the required dependencies:
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
flutter_bloc: ^8.1.3
hydrated_bloc: ^9.1.2 # For persistence
equatable: ^2.0.5
intl: ^0.18.1
Enable localization generation:
# pubspec.yaml
flutter:
generate: true
Configure l10n.yaml:
# l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
nullable-getter: false
Creating the Locale BLoC
Events
// lib/bloc/locale/locale_event.dart
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
abstract class LocaleEvent extends Equatable {
const LocaleEvent();
@override
List<Object?> get props => [];
}
class LocaleChanged extends LocaleEvent {
final Locale locale;
const LocaleChanged(this.locale);
@override
List<Object> get props => [locale];
}
class LocaleSystemRequested extends LocaleEvent {
const LocaleSystemRequested();
}
class LocaleInitialized extends LocaleEvent {
const LocaleInitialized();
}
State
// lib/bloc/locale/locale_state.dart
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
enum LocaleStatus { initial, loading, loaded, error }
class LocaleState extends Equatable {
final Locale locale;
final LocaleStatus status;
final bool isSystemLocale;
const LocaleState({
required this.locale,
this.status = LocaleStatus.initial,
this.isSystemLocale = false,
});
factory LocaleState.initial() {
return const LocaleState(
locale: Locale('en'),
status: LocaleStatus.initial,
isSystemLocale: true,
);
}
LocaleState copyWith({
Locale? locale,
LocaleStatus? status,
bool? isSystemLocale,
}) {
return LocaleState(
locale: locale ?? this.locale,
status: status ?? this.status,
isSystemLocale: isSystemLocale ?? this.isSystemLocale,
);
}
@override
List<Object> get props => [locale, status, isSystemLocale];
}
BLoC Implementation
// lib/bloc/locale/locale_bloc.dart
import 'dart:ui';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'locale_event.dart';
import 'locale_state.dart';
class LocaleBloc extends HydratedBloc<LocaleEvent, LocaleState> {
static const supportedLocales = [
Locale('en'),
Locale('es'),
Locale('fr'),
Locale('de'),
Locale('ar'),
Locale('ja'),
Locale('zh'),
];
LocaleBloc() : super(LocaleState.initial()) {
on<LocaleInitialized>(_onInitialized);
on<LocaleChanged>(_onChanged);
on<LocaleSystemRequested>(_onSystemRequested);
}
Future<void> _onInitialized(
LocaleInitialized event,
Emitter<LocaleState> emit,
) async {
emit(state.copyWith(status: LocaleStatus.loading));
// If using system locale, detect it
if (state.isSystemLocale) {
final systemLocale = _getSystemLocale();
emit(state.copyWith(
locale: systemLocale,
status: LocaleStatus.loaded,
));
} else {
emit(state.copyWith(status: LocaleStatus.loaded));
}
}
void _onChanged(
LocaleChanged event,
Emitter<LocaleState> emit,
) {
if (!_isSupported(event.locale)) {
emit(state.copyWith(status: LocaleStatus.error));
return;
}
emit(state.copyWith(
locale: event.locale,
status: LocaleStatus.loaded,
isSystemLocale: false,
));
}
void _onSystemRequested(
LocaleSystemRequested event,
Emitter<LocaleState> emit,
) {
final systemLocale = _getSystemLocale();
emit(state.copyWith(
locale: systemLocale,
status: LocaleStatus.loaded,
isSystemLocale: true,
));
}
Locale _getSystemLocale() {
final systemLocale = PlatformDispatcher.instance.locale;
return _findBestMatch(systemLocale);
}
Locale _findBestMatch(Locale locale) {
// Exact match
if (_isSupported(locale)) return locale;
// Language-only match
final languageMatch = supportedLocales.firstWhere(
(supported) => supported.languageCode == locale.languageCode,
orElse: () => supportedLocales.first,
);
return languageMatch;
}
bool _isSupported(Locale locale) {
return supportedLocales.any(
(supported) =>
supported.languageCode == locale.languageCode &&
(supported.countryCode == null ||
supported.countryCode == locale.countryCode),
);
}
// HydratedBloc persistence
@override
LocaleState? fromJson(Map<String, dynamic> json) {
try {
return LocaleState(
locale: Locale(
json['languageCode'] as String,
json['countryCode'] as String?,
),
status: LocaleStatus.loaded,
isSystemLocale: json['isSystemLocale'] as bool? ?? false,
);
} catch (_) {
return null;
}
}
@override
Map<String, dynamic>? toJson(LocaleState state) {
return {
'languageCode': state.locale.languageCode,
'countryCode': state.locale.countryCode,
'isSystemLocale': state.isSystemLocale,
};
}
}
App Integration
Main App Setup
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:path_provider/path_provider.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'bloc/locale/locale_bloc.dart';
import 'bloc/locale/locale_event.dart';
import 'bloc/locale/locale_state.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize HydratedBloc storage
HydratedBloc.storage = await HydratedStorage.build(
storageDirectory: await getApplicationDocumentsDirectory(),
);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => LocaleBloc()..add(const LocaleInitialized()),
child: BlocBuilder<LocaleBloc, LocaleState>(
buildWhen: (previous, current) => previous.locale != current.locale,
builder: (context, state) {
return MaterialApp(
title: 'BLoC Localization Demo',
locale: state.locale,
supportedLocales: LocaleBloc.supportedLocales,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: const HomePage(),
);
},
),
);
}
}
Language Selector Widget
// lib/widgets/language_selector.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../bloc/locale/locale_bloc.dart';
import '../bloc/locale/locale_event.dart';
import '../bloc/locale/locale_state.dart';
class LanguageSelector extends StatelessWidget {
const LanguageSelector({super.key});
static const _languageNames = {
'en': 'English',
'es': 'Espanol',
'fr': 'Francais',
'de': 'Deutsch',
'ar': 'العربية',
'ja': '日本語',
'zh': '中文',
};
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return BlocBuilder<LocaleBloc, LocaleState>(
builder: (context, state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.selectLanguage,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
// System locale option
RadioListTile<bool>(
title: Text(l10n.systemLanguage),
subtitle: state.isSystemLocale
? Text(_languageNames[state.locale.languageCode] ?? '')
: null,
value: true,
groupValue: state.isSystemLocale,
onChanged: (_) {
context.read<LocaleBloc>().add(const LocaleSystemRequested());
},
),
const Divider(),
// Manual locale options
...LocaleBloc.supportedLocales.map((locale) {
final isSelected = !state.isSystemLocale &&
state.locale.languageCode == locale.languageCode;
return RadioListTile<String>(
title: Text(_languageNames[locale.languageCode] ?? ''),
value: locale.languageCode,
groupValue:
state.isSystemLocale ? null : state.locale.languageCode,
onChanged: (_) {
context.read<LocaleBloc>().add(LocaleChanged(locale));
},
);
}),
],
);
},
);
}
}
Using Cubit for Simpler Cases
For simpler locale management, Cubit offers less boilerplate:
// lib/cubit/locale_cubit.dart
import 'dart:ui';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
class LocaleCubit extends HydratedCubit<Locale> {
static const supportedLocales = [
Locale('en'),
Locale('es'),
Locale('fr'),
];
LocaleCubit() : super(const Locale('en'));
void changeLocale(Locale locale) {
if (supportedLocales.contains(locale)) {
emit(locale);
}
}
void useSystemLocale() {
final systemLocale = PlatformDispatcher.instance.locale;
final bestMatch = supportedLocales.firstWhere(
(l) => l.languageCode == systemLocale.languageCode,
orElse: () => supportedLocales.first,
);
emit(bestMatch);
}
@override
Locale? fromJson(Map<String, dynamic> json) {
return Locale(
json['languageCode'] as String,
json['countryCode'] as String?,
);
}
@override
Map<String, dynamic>? toJson(Locale state) {
return {
'languageCode': state.languageCode,
'countryCode': state.countryCode,
};
}
}
Accessing Translations in BLoC
One challenge is accessing translations inside BLoC classes. Here are several approaches:
Approach 1: Pass Localized Strings as Event Parameters
// Events carry pre-localized strings
class ShowErrorEvent extends AppEvent {
final String errorMessage;
const ShowErrorEvent(this.errorMessage);
}
// In UI
context.read<AppBloc>().add(
ShowErrorEvent(AppLocalizations.of(context).networkError),
);
Approach 2: Use Error Codes with UI Translation
// BLoC emits error codes
enum AppError { network, validation, unauthorized }
class AppState {
final AppError? error;
// ...
}
// UI translates based on code
BlocBuilder<AppBloc, AppState>(
builder: (context, state) {
if (state.error != null) {
final message = switch (state.error!) {
AppError.network => AppLocalizations.of(context).networkError,
AppError.validation => AppLocalizations.of(context).validationError,
AppError.unauthorized => AppLocalizations.of(context).unauthorizedError,
};
return ErrorWidget(message: message);
}
return const SizedBox.shrink();
},
)
Approach 3: Translation Service with GetIt
// lib/services/translation_service.dart
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class TranslationService {
AppLocalizations? _localizations;
void update(BuildContext context) {
_localizations = AppLocalizations.of(context);
}
String get welcomeMessage =>
_localizations?.welcomeMessage ?? 'Welcome';
String itemCount(int count) =>
_localizations?.itemCount(count) ?? '$count items';
}
// Setup in GetIt
final getIt = GetIt.instance;
void setupServices() {
getIt.registerLazySingleton<TranslationService>(() => TranslationService());
}
// Update in main widget
class _AppState extends State<App> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
getIt<TranslationService>().update(context);
}
}
// Use in BLoC
class MyBloc extends Bloc<MyEvent, MyState> {
final TranslationService _translations;
MyBloc({TranslationService? translations})
: _translations = translations ?? getIt<TranslationService>(),
super(MyState.initial());
}
Multi-Feature Architecture
For large apps with multiple features, each feature can have its own localized BLoC:
// lib/features/auth/bloc/auth_bloc.dart
class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc() : super(AuthState.initial()) {
on<LoginRequested>(_onLoginRequested);
}
Future<void> _onLoginRequested(
LoginRequested event,
Emitter<AuthState> emit,
) async {
emit(state.copyWith(status: AuthStatus.loading));
try {
await _authRepository.login(event.email, event.password);
emit(state.copyWith(status: AuthStatus.authenticated));
} catch (e) {
// Emit error code, not translated string
emit(state.copyWith(
status: AuthStatus.error,
errorCode: AuthErrorCode.invalidCredentials,
));
}
}
}
// lib/features/auth/view/login_page.dart
class LoginPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocConsumer<AuthBloc, AuthState>(
listener: (context, state) {
if (state.errorCode != null) {
final l10n = AppLocalizations.of(context);
final message = switch (state.errorCode!) {
AuthErrorCode.invalidCredentials => l10n.invalidCredentials,
AuthErrorCode.networkError => l10n.networkError,
AuthErrorCode.serverError => l10n.serverError,
};
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
},
builder: (context, state) {
// Build UI
},
);
}
}
Testing Localization with BLoC
Unit Testing the LocaleBloc
// test/bloc/locale_bloc_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:mocktail/mocktail.dart';
class MockStorage extends Mock implements Storage {}
void main() {
late Storage storage;
setUp(() {
storage = MockStorage();
when(() => storage.read(any())).thenReturn(null);
when(() => storage.write(any(), any<dynamic>()))
.thenAnswer((_) async {});
when(() => storage.delete(any())).thenAnswer((_) async {});
when(() => storage.clear()).thenAnswer((_) async {});
HydratedBloc.storage = storage;
});
group('LocaleBloc', () {
blocTest<LocaleBloc, LocaleState>(
'emits loaded state when initialized',
build: () => LocaleBloc(),
act: (bloc) => bloc.add(const LocaleInitialized()),
expect: () => [
isA<LocaleState>()
.having((s) => s.status, 'status', LocaleStatus.loading),
isA<LocaleState>()
.having((s) => s.status, 'status', LocaleStatus.loaded),
],
);
blocTest<LocaleBloc, LocaleState>(
'changes locale when LocaleChanged is added',
build: () => LocaleBloc(),
act: (bloc) => bloc.add(const LocaleChanged(Locale('es'))),
expect: () => [
isA<LocaleState>()
.having((s) => s.locale.languageCode, 'languageCode', 'es')
.having((s) => s.isSystemLocale, 'isSystemLocale', false),
],
);
blocTest<LocaleBloc, LocaleState>(
'emits error for unsupported locale',
build: () => LocaleBloc(),
act: (bloc) => bloc.add(const LocaleChanged(Locale('xx'))),
expect: () => [
isA<LocaleState>()
.having((s) => s.status, 'status', LocaleStatus.error),
],
);
});
}
Widget Testing with Mocked Locale
// test/widgets/home_page_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockLocaleBloc extends MockBloc<LocaleEvent, LocaleState>
implements LocaleBloc {}
void main() {
late MockLocaleBloc mockLocaleBloc;
setUp(() {
mockLocaleBloc = MockLocaleBloc();
});
Widget createTestWidget({required Locale locale}) {
when(() => mockLocaleBloc.state).thenReturn(
LocaleState(locale: locale, status: LocaleStatus.loaded),
);
return BlocProvider<LocaleBloc>.value(
value: mockLocaleBloc,
child: MaterialApp(
locale: locale,
supportedLocales: LocaleBloc.supportedLocales,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: const HomePage(),
),
);
}
testWidgets('displays English text when locale is en', (tester) async {
await tester.pumpWidget(createTestWidget(locale: const Locale('en')));
await tester.pumpAndSettle();
expect(find.text('Welcome'), findsOneWidget);
});
testWidgets('displays Spanish text when locale is es', (tester) async {
await tester.pumpWidget(createTestWidget(locale: const Locale('es')));
await tester.pumpAndSettle();
expect(find.text('Bienvenido'), findsOneWidget);
});
}
BlocObserver for Locale Debugging
// lib/bloc/app_bloc_observer.dart
import 'package:flutter_bloc/flutter_bloc.dart';
class AppBlocObserver extends BlocObserver {
@override
void onChange(BlocBase bloc, Change change) {
super.onChange(bloc, change);
if (bloc is LocaleBloc) {
final previousLocale = (change.currentState as LocaleState).locale;
final newLocale = (change.nextState as LocaleState).locale;
if (previousLocale != newLocale) {
print('Locale changed: ${previousLocale.languageCode} → ${newLocale.languageCode}');
}
}
}
@override
void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
super.onError(bloc, error, stackTrace);
print('Bloc error in ${bloc.runtimeType}: $error');
}
}
// In main.dart
void main() {
Bloc.observer = AppBlocObserver();
runApp(const MyApp());
}
Best Practices
1. Keep Locale BLoC Separate
Don't mix locale management with other business logic:
// Good - dedicated locale bloc
class LocaleBloc extends Bloc<LocaleEvent, LocaleState> { ... }
class AuthBloc extends Bloc<AuthEvent, AuthState> { ... }
// Bad - mixing concerns
class AppBloc extends Bloc<AppEvent, AppState> {
// Handles locale AND auth AND everything else
}
2. Use MultiBlocProvider for Clean Setup
MultiBlocProvider(
providers: [
BlocProvider(create: (_) => LocaleBloc()..add(const LocaleInitialized())),
BlocProvider(create: (_) => AuthBloc()),
BlocProvider(create: (_) => ThemeBloc()),
],
child: const App(),
)
3. Emit Error Codes, Not Translated Strings
BLoC should remain framework-agnostic. Keep translations in the UI layer:
// Good
emit(state.copyWith(errorCode: ErrorCode.networkFailed));
// Bad
emit(state.copyWith(errorMessage: 'Network connection failed'));
4. Use buildWhen for Performance
Only rebuild when locale actually changes:
BlocBuilder<LocaleBloc, LocaleState>(
buildWhen: (previous, current) =>
previous.locale != current.locale,
builder: (context, state) {
return MaterialApp(
locale: state.locale,
// ...
);
},
)
Conclusion
BLoC provides a robust, testable approach to Flutter localization. By keeping locale state in a dedicated BLoC, using events for state changes, and leveraging HydratedBloc for persistence, you get a clean architecture that scales well.
Key takeaways:
- Create a dedicated LocaleBloc for managing app locale
- Use HydratedBloc for automatic persistence
- Keep translations in the UI layer, emit error codes from BLoCs
- Use buildWhen to optimize rebuilds
- Test thoroughly with bloc_test and mocktail
For managing your ARB translation files across multiple languages, FlutterLocalisation provides a visual editor with AI-powered translations that integrates seamlessly with your BLoC-based Flutter app.