Every app that goes global faces the same challenge: how do you update translations without forcing users to download a new version? Traditional ARB file-based localization in Flutter works well for small projects, but the moment you need to fix a typo in your Spanish translation or add support for a new market, you're stuck waiting for app store approval.
This implementation solves that problem by moving translations to a backend API. Your marketing team can update copy in real-time, you can A/B test different phrasings, and users always see the latest content without any app updates. The system includes offline support through intelligent caching, bandwidth optimization with HTTP 304 handling, and full RTL language support for markets like Arabic.
By the end of this guide, you'll have a production-ready localization system built with Clean Architecture principles and the BLoC pattern. The complete source code is available in this repository.
What We're Building
The localization system supports six languages out of the box: English, Spanish, French, German, Hindi, and Arabic. Each translation set includes over 50 keys covering common UI elements like buttons, form labels, error messages, and navigation items.
The architecture follows a specific flow: when the app launches, it checks for cached translations. If they exist, it sends a conditional request to the server with the cached version number. The server either returns new translations (HTTP 200) or confirms the cache is still valid (HTTP 304). This approach minimizes bandwidth usage while ensuring users always have current content.
For offline scenarios, the app falls back to cached translations automatically. Users can switch languages instantly because translations are pre-fetched and stored locally.
Project Structure
The codebase follows Clean Architecture with three distinct layers, each with a specific responsibility:
lib/
├── core/
│ ├── config/
│ │ ├── api_config.dart
│ │ └── app_strings.dart
│ ├── constants/
│ │ └── supported_locales.dart
│ ├── di/
│ │ └── injection_container.dart
│ ├── error/
│ │ ├── exceptions.dart
│ │ └── failures.dart
│ ├── network/
│ │ └── network_info.dart
│ └── theme/
│ └── app_theme.dart
│
└── features/
└── localization/
├── data/
│ ├── datasources/
│ │ ├── localization_remote_datasource.dart
│ │ └── localization_local_datasource.dart
│ ├── models/
│ │ └── translation_model.dart
│ └── repositories/
│ └── localization_repository_impl.dart
│
├── domain/
│ ├── entities/
│ │ └── translation_entity.dart
│ ├── repositories/
│ │ └── localization_repository.dart
│ └── usecases/
│ ├── get_translations_usecase.dart
│ ├── change_locale_usecase.dart
│ └── get_supported_locales_usecase.dart
│
└── presentation/
├── bloc/
│ ├── localization_bloc.dart
│ ├── localization_event.dart
│ └── localization_state.dart
├── pages/
│ ├── home_page.dart
│ └── settings_page.dart
└── widgets/
└── language_selector.dart
The domain layer contains pure business logic with no external dependencies. It defines what the app needs through abstract interfaces. The data layer implements those interfaces using Dio for API calls and Hive for local storage. The presentation layer uses BLoC to manage UI state and respond to user interactions.
Dependencies
Add these packages to your pubspec.yaml:
dependencies:
connectivity_plus: ^6.1.0
dartz: ^0.10.1
dio: ^5.7.0
equatable: ^2.0.5
flutter:
sdk: flutter
flutter_bloc: ^9.1.1
flutter_localizations:
sdk: flutter
get_it: ^7.7.0
hive_flutter: ^1.1.0
intl: ^0.20.2
shared_preferences: ^2.3.3
Each package serves a specific purpose: dio handles HTTP requests with built-in interceptor support, hive_flutter provides fast local storage for cached translations, flutter_bloc manages state predictably, get_it serves as a lightweight dependency injection container, and dartz enables functional error handling with the Either type.
Android Configuration
Enable network access in android/app/src/main/AndroidManifest.xml:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="flutter_blog"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<!-- Activity configuration -->
</application>
</manifest>
The usesCleartextTraffic="true" attribute allows HTTP connections during development. Remove this in production where all traffic should use HTTPS.
Core Layer: Building the Foundation
API Configuration
The API config centralizes all endpoint definitions and provides different base URLs for various development environments:
/// lib/core/config/api_config.dart
class ApiConfig {
ApiConfig._();
/// Android emulator maps 10.0.2.2 to host's localhost
static const String androidEmulatorUrl = 'http://10.0.2.2:3000/api/v1';
/// Direct localhost for iOS Simulator and desktop
static const String localhostUrl = 'http://localhost:3000/api/v1';
/// Your machine's WiFi IP for physical device testing
static const String physicalDeviceUrl = 'http://192.168.0.103:3000/api/v1';
/// Switch this based on your testing environment
static const String baseUrl = physicalDeviceUrl;
static const String translations = '/translations';
static const String translationsByLocale = '/translations/{locale}';
static const String supportedLocales = '/translations/supported-locales';
static const Duration connectTimeout = Duration(seconds: 30);
static const Duration receiveTimeout = Duration(seconds: 30);
static const Duration sendTimeout = Duration(seconds: 30);
static Map<String, String> get defaultHeaders => {
'Content-Type': 'application/json',
'Accept': 'application/json',
};
}
A common issue when testing on Android emulators: requests to localhost:3000 fail because the emulator's localhost points to itself, not your development machine. The special IP 10.0.2.2 solves this by mapping to the host's loopback interface.
Exception and Failure Types
The error handling system uses two levels: exceptions for immediate errors and failures for domain-level error representation.
/// lib/core/error/exceptions.dart
class ServerException implements Exception {
final String message;
final int? statusCode;
final dynamic responseBody;
const ServerException({
required this.message,
this.statusCode,
this.responseBody,
});
}
class CacheException implements Exception {
final String message;
const CacheException({this.message = 'Cache operation failed'});
}
/// Special exception for HTTP 304 Not Modified responses
class CacheValidException implements Exception {
const CacheValidException();
}
The CacheValidException deserves attention. When the server returns HTTP 304, it means our cached data is still current. This isn't an error condition - it's actually good news. We throw this exception to signal that the repository should use cached data instead of treating it as a failure.
/// lib/core/error/failures.dart
abstract class Failure extends Equatable {
final String message;
final String? code;
const Failure({required this.message, this.code});
@override
List<Object?> get props => [message, code];
}
class ServerFailure extends Failure {
final int? statusCode;
const ServerFailure({
required super.message,
super.code,
this.statusCode,
});
}
class NetworkFailure extends Failure {
const NetworkFailure({
super.message = 'No internet connection. Please check your network.',
super.code = 'NETWORK_ERROR',
});
}
class CacheFailure extends Failure {
const CacheFailure({
super.message = 'Failed to access local cache.',
super.code = 'CACHE_ERROR',
});
}
class LocalizationFailure extends Failure {
final String? locale;
const LocalizationFailure({
required super.message,
super.code = 'LOCALIZATION_ERROR',
this.locale,
});
}
Network Connectivity
The network info service wraps connectivity_plus to provide a clean interface for checking network status:
/// lib/core/network/network_info.dart
abstract class NetworkInfo {
Future<bool> get isConnected;
Stream<bool> get onConnectivityChanged;
}
class NetworkInfoImpl implements NetworkInfo {
final Connectivity _connectivity;
NetworkInfoImpl(this._connectivity);
@override
Future<bool> get isConnected async {
final result = await _connectivity.checkConnectivity();
return !result.contains(ConnectivityResult.none);
}
@override
Stream<bool> get onConnectivityChanged {
return _connectivity.onConnectivityChanged.map(
(result) => !result.contains(ConnectivityResult.none),
);
}
}
Domain Layer: Pure Business Logic
Translation Entity
The core entity represents a complete translation set for a locale:
/// lib/features/localization/domain/entities/translation_entity.dart
class TranslationEntity extends Equatable {
final String locale;
final String version;
final DateTime updatedAt;
final Map<String, String> translations;
const TranslationEntity({
required this.locale,
required this.version,
required this.updatedAt,
required this.translations,
});
factory TranslationEntity.empty(String locale) {
return TranslationEntity(
locale: locale,
version: '0.0.0',
updatedAt: DateTime.now(),
translations: const {},
);
}
String get(String key, [Map<String, dynamic>? params]) {
String value = translations[key] ?? key;
if (params != null) {
params.forEach((paramKey, paramValue) {
value = value.replaceAll('{$paramKey}', paramValue.toString());
});
}
return value;
}
@override
List<Object?> get props => [locale, version, updatedAt, translations];
}
The version field is crucial for cache invalidation. When fetching translations, the app sends its current cached version to the server. If the server has a newer version, it returns the full translation set. If not, it returns HTTP 304.
Repository Interface
The repository interface defines what operations the app needs without specifying how they're implemented:
/// lib/features/localization/domain/repositories/localization_repository.dart
abstract class LocalizationRepository {
Future<Either<Failure, TranslationEntity>> getTranslations(
String locale, {
bool forceRefresh = false,
});
Future<Either<Failure, SupportedLocalesEntity>> getSupportedLocales();
Future<Either<Failure, bool>> cacheTranslations(TranslationEntity translations);
Future<Either<Failure, TranslationEntity>> getCachedTranslations(String locale);
Future<String?> getCachedVersion(String locale);
Future<Either<Failure, bool>> clearCache();
Future<Either<Failure, bool>> savePreferredLocale(String locale);
Future<String?> getPreferredLocale();
}
Using Either<Failure, T> from dartz forces explicit error handling. Every caller must handle both success and failure cases - you can't accidentally ignore errors.
Use Cases
Each use case encapsulates a single business operation:
/// lib/features/localization/domain/usecases/get_translations_usecase.dart
class GetTranslationsUseCase {
final LocalizationRepository _repository;
const GetTranslationsUseCase(this._repository);
Future<Either<Failure, TranslationEntity>> call(
GetTranslationsParams params,
) async {
return _repository.getTranslations(
params.locale,
forceRefresh: params.forceRefresh,
);
}
}
class GetTranslationsParams extends Equatable {
final String locale;
final bool forceRefresh;
const GetTranslationsParams({
required this.locale,
this.forceRefresh = false,
});
@override
List<Object?> get props => [locale, forceRefresh];
}
/// lib/features/localization/domain/usecases/change_locale_usecase.dart
class ChangeLocaleUseCase {
final LocalizationRepository _repository;
const ChangeLocaleUseCase(this._repository);
Future<Either<Failure, TranslationEntity>> call(
ChangeLocaleParams params,
) async {
final translationsResult = await _repository.getTranslations(params.locale);
return translationsResult.fold(
(failure) => Left(failure),
(translations) async {
await _repository.savePreferredLocale(params.locale);
await _repository.cacheTranslations(translations);
return Right(translations);
},
);
}
}
class ChangeLocaleParams extends Equatable {
final String locale;
const ChangeLocaleParams({required this.locale});
@override
List<Object?> get props => [locale];
}
Data Layer: API and Cache Implementation
Remote Data Source
The remote data source handles all API communication. Pay close attention to the HTTP 304 handling:
/// lib/features/localization/data/datasources/localization_remote_datasource.dart
class LocalizationRemoteDataSourceImpl implements LocalizationRemoteDataSource {
final Dio _dio;
const LocalizationRemoteDataSourceImpl(this._dio);
@override
Future<TranslationModel> getTranslations(
String locale, {
String? currentVersion,
}) async {
try {
final queryParams = <String, dynamic>{};
if (currentVersion != null) {
queryParams['current_version'] = currentVersion;
}
final response = await _dio.get(
'/translations/$locale',
queryParameters: queryParams.isNotEmpty ? queryParams : null,
options: Options(
// This is the key: accept 304 as a valid status
validateStatus: (status) => status != null && (status < 400 || status == 304),
),
);
// Handle 304 Not Modified
if (response.statusCode == 304) {
throw const CacheValidException();
}
return TranslationModel.fromJson(response.data as Map<String, dynamic>);
} on CacheValidException {
rethrow;
} on DioException catch (e) {
if (e.response?.statusCode == 304) {
throw const CacheValidException();
}
throw ServerException(
message: e.message ?? 'Failed to fetch translations',
statusCode: e.response?.statusCode,
responseBody: e.response?.data,
);
}
}
}
By default, Dio treats any non-2xx status code as an error. The validateStatus option tells Dio to accept 304 as a valid response. Without this, Dio would throw an exception before we could check the status code.
Local Data Source
The local data source uses Hive for caching and SharedPreferences for simple key-value storage:
/// lib/features/localization/data/datasources/localization_local_datasource.dart
class LocalizationLocalDataSourceImpl implements LocalizationLocalDataSource {
static const String _translationsBoxName = 'translations_cache';
static const String _preferredLocaleKey = 'preferred_locale';
final SharedPreferences _sharedPreferences;
Box<String>? _translationsBox;
LocalizationLocalDataSourceImpl(this._sharedPreferences);
Future<Box<String>> get _box async {
if (_translationsBox != null && _translationsBox!.isOpen) {
return _translationsBox!;
}
_translationsBox = await Hive.openBox<String>(_translationsBoxName);
return _translationsBox!;
}
@override
Future<void> cacheTranslations(TranslationModel translations) async {
try {
final box = await _box;
final jsonString = jsonEncode(translations.toJson());
await box.put(translations.locale, jsonString);
} catch (e) {
throw CacheException(message: 'Failed to cache translations: $e');
}
}
@override
Future<TranslationModel> getCachedTranslations(String locale) async {
try {
final box = await _box;
final jsonString = box.get(locale);
if (jsonString == null) {
throw CacheException(
message: 'No cached translations found for locale: $locale',
);
}
final json = jsonDecode(jsonString) as Map<String, dynamic>;
return TranslationModel.fromJson(json);
} catch (e) {
if (e is CacheException) rethrow;
throw CacheException(message: 'Failed to read cached translations: $e');
}
}
@override
Future<String?> getCachedVersion(String locale) async {
try {
final box = await _box;
final jsonString = box.get(locale);
if (jsonString == null) return null;
final json = jsonDecode(jsonString) as Map<String, dynamic>;
return json['version'] as String?;
} catch (e) {
return null;
}
}
@override
Future<void> savePreferredLocale(String locale) async {
await _sharedPreferences.setString(_preferredLocaleKey, locale);
}
@override
Future<String?> getPreferredLocale() async {
return _sharedPreferences.getString(_preferredLocaleKey);
}
}
Repository Implementation
The repository coordinates between remote and local data sources, implementing the offline-first strategy:
/// lib/features/localization/data/repositories/localization_repository_impl.dart
class LocalizationRepositoryImpl implements LocalizationRepository {
final LocalizationRemoteDataSource _remoteDataSource;
final LocalizationLocalDataSource _localDataSource;
final NetworkInfo _networkInfo;
const LocalizationRepositoryImpl({
required LocalizationRemoteDataSource remoteDataSource,
required LocalizationLocalDataSource localDataSource,
required NetworkInfo networkInfo,
}) : _remoteDataSource = remoteDataSource,
_localDataSource = localDataSource,
_networkInfo = networkInfo;
@override
Future<Either<Failure, TranslationEntity>> getTranslations(
String locale, {
bool forceRefresh = false,
}) async {
final isConnected = await _networkInfo.isConnected;
if (isConnected) {
return _getRemoteTranslations(locale, forceRefresh: forceRefresh);
} else {
return _getCachedOrFail(locale);
}
}
Future<Either<Failure, TranslationEntity>> _getRemoteTranslations(
String locale, {
bool forceRefresh = false,
}) async {
try {
String? currentVersion;
if (!forceRefresh) {
currentVersion = await _localDataSource.getCachedVersion(locale);
}
final remoteTranslations = await _remoteDataSource.getTranslations(
locale,
currentVersion: currentVersion,
);
await _localDataSource.cacheTranslations(remoteTranslations);
return Right(remoteTranslations);
} on CacheValidException {
// Server returned 304 - cached data is still valid
return _getCachedOrFail(locale);
} on DioException catch (e) {
if (e.type == DioExceptionType.connectionTimeout ||
e.type == DioExceptionType.receiveTimeout ||
e.type == DioExceptionType.connectionError) {
return _getCachedOrFail(locale);
}
return Left(
ServerFailure(
message: e.message ?? 'Failed to fetch translations',
statusCode: e.response?.statusCode,
),
);
} on ServerException catch (e) {
return Left(
ServerFailure(
message: e.message,
statusCode: e.statusCode,
),
);
} catch (e) {
return _getCachedOrFail(locale);
}
}
Future<Either<Failure, TranslationEntity>> _getCachedOrFail(
String locale,
) async {
try {
final cachedTranslations =
await _localDataSource.getCachedTranslations(locale);
return Right(cachedTranslations);
} on CacheException catch (e) {
return Left(
LocalizationFailure(
message: e.message,
locale: locale,
),
);
}
}
}
The key insight here is the CacheValidException catch block. When the server returns 304, we don't treat it as an error - we simply return the cached data. This is both efficient (no unnecessary data transfer) and correct (the user gets valid translations).
Presentation Layer: BLoC State Management
Events and States
The BLoC uses sealed classes for type-safe event and state handling:
/// lib/features/localization/presentation/bloc/localization_event.dart
sealed class LocalizationEvent extends Equatable {
const LocalizationEvent();
@override
List<Object?> get props => [];
}
final class InitializeLocalizationEvent extends LocalizationEvent {
const InitializeLocalizationEvent();
}
final class LoadTranslationsEvent extends LocalizationEvent {
final String locale;
final bool forceRefresh;
const LoadTranslationsEvent({
required this.locale,
this.forceRefresh = false,
});
@override
List<Object?> get props => [locale, forceRefresh];
}
final class ChangeLocaleEvent extends LocalizationEvent {
final String locale;
const ChangeLocaleEvent({required this.locale});
@override
List<Object?> get props => [locale];
}
final class RefreshTranslationsEvent extends LocalizationEvent {
const RefreshTranslationsEvent();
}
/// lib/features/localization/presentation/bloc/localization_state.dart
sealed class LocalizationState extends Equatable {
const LocalizationState();
@override
List<Object?> get props => [];
}
final class LocalizationInitial extends LocalizationState {
const LocalizationInitial();
}
final class LocalizationLoading extends LocalizationState {
final String? message;
const LocalizationLoading({this.message});
@override
List<Object?> get props => [message];
}
final class LocalizationLoaded extends LocalizationState {
final Locale locale;
final TranslationEntity translations;
final List<String> supportedLocales;
const LocalizationLoaded({
required this.locale,
required this.translations,
this.supportedLocales = const ['en', 'es', 'fr', 'de', 'hi', 'ar'],
});
LocalizationLoaded copyWith({
Locale? locale,
TranslationEntity? translations,
List<String>? supportedLocales,
}) {
return LocalizationLoaded(
locale: locale ?? this.locale,
translations: translations ?? this.translations,
supportedLocales: supportedLocales ?? this.supportedLocales,
);
}
@override
List<Object?> get props => [locale, translations, supportedLocales];
}
final class LocalizationError extends LocalizationState {
final String message;
final String? code;
final TranslationEntity? fallbackTranslations;
final Locale? fallbackLocale;
const LocalizationError({
required this.message,
this.code,
this.fallbackTranslations,
this.fallbackLocale,
});
@override
List<Object?> get props => [message, code, fallbackTranslations, fallbackLocale];
}
final class LocaleChanging extends LocalizationState {
final Locale fromLocale;
final Locale toLocale;
const LocaleChanging({
required this.fromLocale,
required this.toLocale,
});
@override
List<Object?> get props => [fromLocale, toLocale];
}
The BLoC
The BLoC handles all localization logic and coordinates between use cases:
/// lib/features/localization/presentation/bloc/localization_bloc.dart
class LocalizationBloc extends Bloc<LocalizationEvent, LocalizationState> {
final GetTranslationsUseCase _getTranslationsUseCase;
final ChangeLocaleUseCase _changeLocaleUseCase;
final GetSupportedLocalesUseCase _getSupportedLocalesUseCase;
final LocalizationRepository _repository;
LocalizationBloc({
required GetTranslationsUseCase getTranslationsUseCase,
required ChangeLocaleUseCase changeLocaleUseCase,
required GetSupportedLocalesUseCase getSupportedLocalesUseCase,
required LocalizationRepository repository,
}) : _getTranslationsUseCase = getTranslationsUseCase,
_changeLocaleUseCase = changeLocaleUseCase,
_getSupportedLocalesUseCase = getSupportedLocalesUseCase,
_repository = repository,
super(const LocalizationInitial()) {
on<InitializeLocalizationEvent>(_onInitialize);
on<LoadTranslationsEvent>(_onLoadTranslations);
on<ChangeLocaleEvent>(_onChangeLocale);
on<RefreshTranslationsEvent>(_onRefreshTranslations);
}
Future<void> _onInitialize(
InitializeLocalizationEvent event,
Emitter<LocalizationState> emit,
) async {
emit(const LocalizationLoading(message: 'Initializing...'));
final savedLocale = await _repository.getPreferredLocale();
final localeToLoad = savedLocale ?? 'en';
final result = await _getTranslationsUseCase(
GetTranslationsParams(locale: localeToLoad),
);
result.fold(
(failure) {
emit(LocalizationError(
message: failure.message,
code: failure.code,
));
},
(translations) {
AppStrings.updateTranslations(
translations.translations.map((k, v) => MapEntry(k, v.toString())),
translations.locale,
);
emit(LocalizationLoaded(
locale: Locale(localeToLoad),
translations: translations,
));
},
);
}
Future<void> _onChangeLocale(
ChangeLocaleEvent event,
Emitter<LocalizationState> emit,
) async {
final currentState = state;
final currentLocale = currentState is LocalizationLoaded
? currentState.locale
: const Locale('en');
if (currentLocale.languageCode == event.locale) return;
emit(LocaleChanging(
fromLocale: currentLocale,
toLocale: Locale(event.locale),
));
final result = await _changeLocaleUseCase(
ChangeLocaleParams(locale: event.locale),
);
result.fold(
(failure) {
if (currentState is LocalizationLoaded) {
emit(currentState);
}
emit(LocalizationError(
message: failure.message,
code: failure.code,
fallbackLocale: currentLocale,
));
},
(translations) {
AppStrings.updateTranslations(
translations.translations.map((k, v) => MapEntry(k, v.toString())),
translations.locale,
);
final supportedLocales = currentState is LocalizationLoaded
? currentState.supportedLocales
: const ['en', 'es', 'fr', 'de', 'hi', 'ar'];
emit(LocalizationLoaded(
locale: Locale(event.locale),
translations: translations,
supportedLocales: supportedLocales,
));
},
);
}
}
Dependency Injection
The injection container wires everything together:
/// lib/core/di/injection_container.dart
final GetIt sl = GetIt.instance;
Future<void> initDependencies() async {
await Hive.initFlutter();
final sharedPreferences = await SharedPreferences.getInstance();
sl.registerLazySingleton<SharedPreferences>(() => sharedPreferences);
sl.registerLazySingleton<Connectivity>(() => Connectivity());
sl.registerLazySingleton<Dio>(() => _createDio());
sl.registerLazySingleton<NetworkInfo>(
() => NetworkInfoImpl(sl<Connectivity>()),
);
sl.registerLazySingleton<LocalizationRemoteDataSource>(
() => LocalizationRemoteDataSourceImpl(sl<Dio>()),
);
sl.registerLazySingleton<LocalizationLocalDataSource>(
() => LocalizationLocalDataSourceImpl(sl<SharedPreferences>()),
);
sl.registerLazySingleton<LocalizationRepository>(
() => LocalizationRepositoryImpl(
remoteDataSource: sl<LocalizationRemoteDataSource>(),
localDataSource: sl<LocalizationLocalDataSource>(),
networkInfo: sl<NetworkInfo>(),
),
);
sl.registerLazySingleton<GetTranslationsUseCase>(
() => GetTranslationsUseCase(sl<LocalizationRepository>()),
);
sl.registerLazySingleton<ChangeLocaleUseCase>(
() => ChangeLocaleUseCase(sl<LocalizationRepository>()),
);
sl.registerLazySingleton<GetSupportedLocalesUseCase>(
() => GetSupportedLocalesUseCase(sl<LocalizationRepository>()),
);
sl.registerLazySingleton<LocalizationBloc>(
() => LocalizationBloc(
getTranslationsUseCase: sl<GetTranslationsUseCase>(),
changeLocaleUseCase: sl<ChangeLocaleUseCase>(),
getSupportedLocalesUseCase: sl<GetSupportedLocalesUseCase>(),
repository: sl<LocalizationRepository>(),
),
);
}
Dio _createDio() {
final dio = Dio(
BaseOptions(
baseUrl: ApiConfig.baseUrl,
connectTimeout: ApiConfig.connectTimeout,
receiveTimeout: ApiConfig.receiveTimeout,
sendTimeout: ApiConfig.sendTimeout,
headers: ApiConfig.defaultHeaders,
),
);
dio.interceptors.add(
LogInterceptor(
request: true,
requestHeader: true,
requestBody: true,
responseHeader: true,
responseBody: true,
error: true,
),
);
return dio;
}
Backend Setup
The backend runs on Node.js with Express and MongoDB Atlas. Here's how to set it up:
- Create a MongoDB Atlas account at mongodb.com and create a free cluster
- Get your connection string from the Atlas dashboard
- Create the backend project with Express and Mongoose
The API exposes three endpoints:
-
GET /api/v1/translations/:locale- Returns translations for a specific locale -
GET /api/v1/translations/supported-locales- Returns list of available locales -
GET /api/v1/translations/:locale?current_version=X- Conditional request that returns 304 if version matches
The translation documents in MongoDB follow this schema:
{
locale: "en",
version: "1.0.0",
updated_at: ISODate("2024-01-15T10:30:00Z"),
translations: {
"app_title": "Localization Demo",
"welcome_message": "Welcome, {name}!",
"login": "Login",
// ... more keys
}
}
When the client sends current_version as a query parameter, the server compares it with the database version. If they match, it returns HTTP 304 with no body. Otherwise, it returns the full translation document with HTTP 200.
Running the App
Initialize dependencies in main.dart:
/// lib/main.dart
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await initDependencies();
runApp(const LocalizationApp());
}
class LocalizationApp extends StatelessWidget {
const LocalizationApp({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider<LocalizationBloc>(
create: (_) => sl<LocalizationBloc>()
..add(const InitializeLocalizationEvent()),
child: BlocBuilder<LocalizationBloc, LocalizationState>(
buildWhen: (previous, current) {
if (previous is LocalizationLoaded && current is LocalizationLoaded) {
return previous.locale != current.locale;
}
return true;
},
builder: (context, state) {
Locale currentLocale = const Locale('en', 'US');
if (state is LocalizationLoaded) {
currentLocale = state.locale;
}
final isRtl = SupportedLocales.findByCode(
currentLocale.languageCode,
).isRtl;
return MaterialApp(
title: AppStrings.getValue(AppStrings.appTitle),
debugShowCheckedModeBanner: false,
theme: AppTheme.light,
locale: currentLocale,
supportedLocales: SupportedLocales.flutterLocales,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
home: Directionality(
textDirection: isRtl ? TextDirection.rtl : TextDirection.ltr,
child: const HomePage(),
),
);
},
),
);
}
}
Common Issues and Solutions
Server connection refused on Android emulator:
Use 10.0.2.2 instead of localhost in your base URL. The emulator's localhost refers to itself, not your development machine.
HTTP 304 treated as an error:
Configure Dio's validateStatus to accept 304: validateStatus: (status) => status != null && (status < 400 || status == 304)
Translations not updating:
Check the version number in your MongoDB document. The server only returns 304 if versions match. Increment the version to force a refresh.
Cleartext traffic blocked:
Add android:usesCleartextTraffic="true" to your AndroidManifest.xml during development. Remove it before production.
MongoDB Atlas connection issues:
Ensure your IP address is whitelisted in Atlas Network Access settings. The error "ECONNREFUSED" usually means network restrictions.
How It All Connects: The Complete Flow
Understanding how data flows through the application helps you debug issues and extend the system. Here's the complete journey of a translation request:
┌─────────────────────────────────────────────────────────────────────────────┐
│ APPLICATION STARTUP │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 1. main.dart initializes dependencies via GetIt │
│ └── Registers: Dio, SharedPreferences, Hive, BLoC, UseCases │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 2. LocalizationBloc receives InitializeLocalizationEvent │
│ └── Checks SharedPreferences for saved locale preference │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 3. GetTranslationsUseCase calls LocalizationRepository │
│ └── Repository checks network connectivity │
└─────────────────────────────────────────────────────────────────────────────┘
│
┌─────────────────┴─────────────────┐
│ │
[ONLINE] [OFFLINE]
│ │
▼ ▼
┌───────────────────────────────┐ ┌───────────────────────────────────────┐
│ 4a. RemoteDataSource calls │ │ 4b. LocalDataSource reads from │
│ backend API via Dio │ │ Hive cache │
│ GET /translations/{locale}│ │ │
│ ?current_version=X │ │ │
└───────────────────────────────┘ └───────────────────────────────────────┘
│ │
┌─────────┴─────────┐ │
│ │ │
[HTTP 200] [HTTP 304] │
│ │ │
▼ ▼ │
┌─────────────────┐ ┌─────────────────┐ │
│ New translations│ │ CacheValidExcep │ │
│ returned │ │ tion thrown │ │
└─────────────────┘ └─────────────────┘ │
│ │ │
▼ └────────────┬────────────┘
┌─────────────────┐ │
│ Cache new data │ │
│ in Hive │ │
└─────────────────┘ │
│ │
└───────────────┬────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 5. Repository returns Either<Failure, TranslationEntity> │
│ └── BLoC emits LocalizationLoaded state with translations │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 6. AppStrings.updateTranslations() stores translations in memory │
│ └── UI rebuilds with BlocBuilder, displaying localized content │
└─────────────────────────────────────────────────────────────────────────────┘
Language Change Flow
When a user selects a new language:
User taps language ──► ChangeLocaleEvent dispatched
│
▼
BLoC emits LocaleChanging state (shows loading indicator)
│
▼
ChangeLocaleUseCase fetches new translations
│
▼
On success: Save preference + Cache translations + Emit LocalizationLoaded
│
▼
MaterialApp rebuilds with new locale
│
▼
Directionality widget updates for RTL languages (Arabic)
Cache Validation Strategy
The HTTP 304 mechanism saves bandwidth and improves performance:
First Request (no cache):
Client: GET /translations/en
Server: 200 OK + Full translation data (version: 1.0.0)
Client: Stores in Hive with version
Subsequent Request (with cache):
Client: GET /translations/en?current_version=1.0.0
Server: Compares versions
└── Same version → 304 Not Modified (no body)
└── New version → 200 OK + Updated translations
Result: Only downloads data when translations actually change
Performance Optimization Tips
Building on this foundation, here are strategies to maximize performance:
Lazy Loading Translations
Instead of loading all translations at startup, consider loading only the current locale and prefetching others in the background:
// In your BLoC, after loading primary locale
Future<void> _prefetchOtherLocales() async {
final otherLocales = ['es', 'fr', 'de'].where((l) => l != currentLocale);
for (final locale in otherLocales) {
await _repository.getTranslations(locale); // Caches silently
}
}
Memory-Efficient String Access
The AppStrings.getValue() method avoids creating new string objects for cached values:
// Efficient: Retrieves from in-memory map
Text(AppStrings.getValue(AppStrings.login))
// Less efficient: Creates interpolated string each time
Text('${translations['login']}')
Minimize Rebuilds
Use buildWhen in BlocBuilder to prevent unnecessary widget rebuilds:
BlocBuilder<LocalizationBloc, LocalizationState>(
buildWhen: (previous, current) {
// Only rebuild when locale actually changes
if (previous is LocalizationLoaded && current is LocalizationLoaded) {
return previous.locale != current.locale;
}
return true;
},
builder: (context, state) => /* your widget */,
)
Testing Your Implementation
A robust localization system needs thorough testing. Here are the key test scenarios:
Unit Tests for Repository
test('should return cached translations when server returns 304', () async {
// Arrange
when(mockRemoteDataSource.getTranslations('en', currentVersion: '1.0.0'))
.thenThrow(const CacheValidException());
when(mockLocalDataSource.getCachedTranslations('en'))
.thenAnswer((_) async => tTranslationModel);
// Act
final result = await repository.getTranslations('en');
// Assert
expect(result, Right(tTranslationModel));
verify(mockLocalDataSource.getCachedTranslations('en'));
});
Integration Test for Language Switching
testWidgets('should switch language and update UI', (tester) async {
await tester.pumpWidget(const LocalizationApp());
await tester.pumpAndSettle();
// Verify initial English text
expect(find.text('Login'), findsOneWidget);
// Navigate to settings and change language
await tester.tap(find.byIcon(Icons.settings));
await tester.pumpAndSettle();
await tester.tap(find.text('Español'));
await tester.pumpAndSettle();
// Verify Spanish text appears
expect(find.text('Iniciar sesión'), findsOneWidget);
});
Offline Mode Test
test('should use cached translations when offline', () async {
// Arrange
when(mockNetworkInfo.isConnected).thenAnswer((_) async => false);
when(mockLocalDataSource.getCachedTranslations('en'))
.thenAnswer((_) async => tTranslationModel);
// Act
final result = await repository.getTranslations('en');
// Assert
expect(result.isRight(), true);
verifyNever(mockRemoteDataSource.getTranslations(any));
});
Security Considerations
When implementing backend-driven localization in production:
API Security
- Use HTTPS in production (remove
usesCleartextTrafficfrom AndroidManifest) - Implement rate limiting on translation endpoints to prevent abuse
- Consider adding API key authentication for translation requests
Content Validation
- Sanitize translation strings on the backend before storing
- Validate that translation keys match expected patterns
- Implement content moderation if translations are user-generated
Cache Security
- Hive encrypts data at rest when configured properly
- Consider encrypting sensitive translation keys
- Clear cached translations on user logout if they contain personalized content
Conclusion
Building a backend-driven localization system requires thoughtful architecture, but the benefits far outweigh the initial complexity. You now have a system where translations live independently from your app binary, updates happen instantly without app store delays, and users always see the most current content regardless of their app version.
The Clean Architecture approach pays dividends as your app grows. Need to switch from Hive to SQLite for caching? Change only the local data source. Want to add WebSocket support for real-time updates? Modify the remote data source and repository. The domain layer with its use cases and entities remains untouched, protecting your business logic from infrastructure changes.
The HTTP 304 mechanism might seem like a small optimization, but at scale it significantly reduces bandwidth costs and improves app responsiveness. Users with slow connections appreciate faster language switches when translations are already cached.
Consider this implementation a starting point. The patterns established here extend naturally to other dynamic content: feature flags, remote configuration, A/B testing variants, and more. Once you have the infrastructure for fetching, caching, and displaying dynamic content, applying it to new use cases becomes straightforward.
The combination of offline-first caching, intelligent cache validation, and robust error handling creates an experience that feels native while providing the flexibility of server-driven content. Users get instant language switching with cached data, automatic updates when new translations are available, and graceful fallbacks when network conditions are poor.
Source Code
The complete implementation is available on GitHub. Clone the repository, configure your backend URL, and you'll have a working localization system ready for customization.
GitHub Repository: https://github.com/Anurag-Dubey12/backend-based-Localization/tree/main
The repository includes:
- Complete Flutter application with all layers implemented
- Node.js backend with Express and MongoDB integration
- Sample translation data for all six supported languages
Top comments (0)