Flutter Offline-First Localization: Build Apps That Work Without Internet
Mobile apps need to work reliably regardless of network conditions. This is especially true for localization - users expect their app to display in their preferred language even when offline. This guide covers strategies for building offline-first localization in Flutter, from bundled translations to smart caching and sync mechanisms.
Why Offline-First Localization Matters
Consider these scenarios:
- Travel apps used in areas with poor connectivity
- Field service apps used in remote locations
- Enterprise apps in secure facilities with limited internet
- Emerging markets with unreliable mobile data
- Subway commuters passing through tunnels
If your app requires network access just to display text, you'll frustrate users and lose engagement.
Architecture Overview
An offline-first localization system needs:
- Bundled translations: Default translations that ship with the app
- Local storage: Persistent cache for downloaded translations
- Sync mechanism: Background updates when online
- Fallback chain: Graceful degradation when translations are missing
- Version management: Track translation versions for efficient updates
Project Setup
# pubspec.yaml
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
hive: ^2.2.3
hive_flutter: ^1.1.0
connectivity_plus: ^5.0.2
path_provider: ^2.1.2
dev_dependencies:
hive_generator: ^2.0.1
build_runner: ^2.4.8
flutter:
generate: true
assets:
- assets/translations/
Bundled Translations (The Foundation)
Always ship default translations with your app:
// lib/l10n/bundled_translations.dart
class BundledTranslations {
static const Map<String, Map<String, String>> translations = {
'en': {
'app_name': 'My App',
'welcome': 'Welcome',
'settings': 'Settings',
'language': 'Language',
'offline_mode': 'Offline Mode',
'last_updated': 'Last updated: {date}',
'error_loading': 'Error loading content',
'retry': 'Retry',
// Add all essential translations
},
'es': {
'app_name': 'Mi App',
'welcome': 'Bienvenido',
'settings': 'Configuracion',
'language': 'Idioma',
'offline_mode': 'Modo sin conexion',
'last_updated': 'Ultima actualizacion: {date}',
'error_loading': 'Error al cargar contenido',
'retry': 'Reintentar',
},
'fr': {
'app_name': 'Mon App',
'welcome': 'Bienvenue',
'settings': 'Parametres',
'language': 'Langue',
'offline_mode': 'Mode hors ligne',
'last_updated': 'Derniere mise a jour: {date}',
'error_loading': 'Erreur de chargement',
'retry': 'Reessayer',
},
};
static Map<String, String> getTranslations(String languageCode) {
return translations[languageCode] ?? translations['en']!;
}
static bool hasLanguage(String languageCode) {
return translations.containsKey(languageCode);
}
static List<String> get supportedLanguages => translations.keys.toList();
}
Local Storage with Hive
Create a persistent cache for downloaded translations:
// lib/l10n/translation_cache.dart
import 'package:hive_flutter/hive_flutter.dart';
part 'translation_cache.g.dart';
@HiveType(typeId: 0)
class CachedTranslation extends HiveObject {
@HiveField(0)
final String languageCode;
@HiveField(1)
final Map<String, String> translations;
@HiveField(2)
final int version;
@HiveField(3)
final DateTime cachedAt;
@HiveField(4)
final DateTime? expiresAt;
CachedTranslation({
required this.languageCode,
required this.translations,
required this.version,
required this.cachedAt,
this.expiresAt,
});
bool get isExpired {
if (expiresAt == null) return false;
return DateTime.now().isAfter(expiresAt!);
}
bool get isStale {
// Consider translations stale after 24 hours
return DateTime.now().difference(cachedAt).inHours > 24;
}
}
class TranslationCacheService {
static const String _boxName = 'translations';
late Box<CachedTranslation> _box;
Future<void> init() async {
await Hive.initFlutter();
Hive.registerAdapter(CachedTranslationAdapter());
_box = await Hive.openBox<CachedTranslation>(_boxName);
}
Future<void> cacheTranslations({
required String languageCode,
required Map<String, String> translations,
required int version,
Duration? ttl,
}) async {
final cached = CachedTranslation(
languageCode: languageCode,
translations: translations,
version: version,
cachedAt: DateTime.now(),
expiresAt: ttl != null ? DateTime.now().add(ttl) : null,
);
await _box.put(languageCode, cached);
}
CachedTranslation? getTranslations(String languageCode) {
return _box.get(languageCode);
}
bool hasTranslations(String languageCode) {
final cached = _box.get(languageCode);
return cached != null && !cached.isExpired;
}
int? getVersion(String languageCode) {
return _box.get(languageCode)?.version;
}
Future<void> clearCache() async {
await _box.clear();
}
Future<void> removeLanguage(String languageCode) async {
await _box.delete(languageCode);
}
List<String> getCachedLanguages() {
return _box.keys.cast<String>().toList();
}
}
Run code generation:
flutter pub run build_runner build
Translation Sync Service
Handle background synchronization:
// lib/l10n/translation_sync_service.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:connectivity_plus/connectivity_plus.dart';
import 'translation_cache.dart';
import 'bundled_translations.dart';
class TranslationSyncService {
final TranslationCacheService _cache;
final String _apiBaseUrl;
final Connectivity _connectivity = Connectivity();
TranslationSyncService({
required TranslationCacheService cache,
required String apiBaseUrl,
}) : _cache = cache,
_apiBaseUrl = apiBaseUrl;
/// Check if device is online
Future<bool> get isOnline async {
final result = await _connectivity.checkConnectivity();
return result != ConnectivityResult.none;
}
/// Get translations with offline-first strategy
Future<Map<String, String>> getTranslations(String languageCode) async {
// 1. Try to get from cache first
final cached = _cache.getTranslations(languageCode);
if (cached != null && !cached.isExpired) {
// If cache is stale but not expired, trigger background refresh
if (cached.isStale) {
_refreshInBackground(languageCode, cached.version);
}
return cached.translations;
}
// 2. Try to fetch from network
if (await isOnline) {
try {
final remote = await _fetchFromApi(languageCode);
if (remote != null) {
return remote;
}
} catch (e) {
print('Failed to fetch translations: $e');
}
}
// 3. Fall back to expired cache if available
if (cached != null) {
return cached.translations;
}
// 4. Fall back to bundled translations
return BundledTranslations.getTranslations(languageCode);
}
/// Fetch translations from API
Future<Map<String, String>?> _fetchFromApi(String languageCode) async {
final currentVersion = _cache.getVersion(languageCode);
final response = await http.get(
Uri.parse('$_apiBaseUrl/translations/$languageCode'),
headers: {
if (currentVersion != null) 'If-None-Match': currentVersion.toString(),
},
);
if (response.statusCode == 304) {
// Not modified, cache is still valid
return null;
}
if (response.statusCode == 200) {
final data = json.decode(response.body);
final translations = Map<String, String>.from(data['translations']);
final version = data['version'] as int;
// Cache the new translations
await _cache.cacheTranslations(
languageCode: languageCode,
translations: translations,
version: version,
ttl: const Duration(days: 7),
);
return translations;
}
return null;
}
/// Background refresh without blocking UI
void _refreshInBackground(String languageCode, int currentVersion) async {
if (!await isOnline) return;
try {
await _fetchFromApi(languageCode);
} catch (e) {
// Silently fail - we have cached data
print('Background refresh failed: $e');
}
}
/// Pre-download translations for offline use
Future<void> preloadLanguages(List<String> languageCodes) async {
if (!await isOnline) return;
for (final code in languageCodes) {
try {
await _fetchFromApi(code);
} catch (e) {
print('Failed to preload $code: $e');
}
}
}
/// Force refresh all cached translations
Future<void> refreshAll() async {
if (!await isOnline) return;
final cachedLanguages = _cache.getCachedLanguages();
for (final code in cachedLanguages) {
try {
await _fetchFromApi(code);
} catch (e) {
print('Failed to refresh $code: $e');
}
}
}
/// Listen to connectivity changes
Stream<ConnectivityResult> get connectivityStream =>
_connectivity.onConnectivityChanged;
}
Offline-First Localization Provider
Create a provider that manages the entire localization state:
// lib/l10n/offline_localization_provider.dart
import 'package:flutter/material.dart';
import 'translation_cache.dart';
import 'translation_sync_service.dart';
import 'bundled_translations.dart';
class OfflineLocalizationProvider extends ChangeNotifier {
final TranslationCacheService _cache;
final TranslationSyncService _syncService;
Locale _locale = const Locale('en');
Map<String, String> _translations = {};
bool _isLoading = false;
bool _isOffline = false;
DateTime? _lastUpdated;
OfflineLocalizationProvider({
required TranslationCacheService cache,
required TranslationSyncService syncService,
}) : _cache = cache,
_syncService = syncService {
_listenToConnectivity();
}
Locale get locale => _locale;
bool get isLoading => _isLoading;
bool get isOffline => _isOffline;
DateTime? get lastUpdated => _lastUpdated;
String translate(String key, {Map<String, String>? params}) {
var text = _translations[key] ?? key;
if (params != null) {
params.forEach((paramKey, value) {
text = text.replaceAll('{$paramKey}', value);
});
}
return text;
}
Future<void> init() async {
await _cache.init();
await loadTranslations(_locale.languageCode);
}
Future<void> setLocale(Locale newLocale) async {
if (newLocale == _locale) return;
_locale = newLocale;
await loadTranslations(newLocale.languageCode);
notifyListeners();
}
Future<void> loadTranslations(String languageCode) async {
_isLoading = true;
notifyListeners();
try {
_translations = await _syncService.getTranslations(languageCode);
final cached = _cache.getTranslations(languageCode);
_lastUpdated = cached?.cachedAt;
} catch (e) {
// Fall back to bundled
_translations = BundledTranslations.getTranslations(languageCode);
_lastUpdated = null;
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> refresh() async {
await loadTranslations(_locale.languageCode);
}
Future<void> preloadLanguages(List<String> languageCodes) async {
await _syncService.preloadLanguages(languageCodes);
}
void _listenToConnectivity() {
_syncService.connectivityStream.listen((result) {
final wasOffline = _isOffline;
_isOffline = result == ConnectivityResult.none;
if (wasOffline && !_isOffline) {
// Just came online - refresh translations
refresh();
}
notifyListeners();
});
}
}
Using the Provider in Your App
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'l10n/offline_localization_provider.dart';
import 'l10n/translation_cache.dart';
import 'l10n/translation_sync_service.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final cache = TranslationCacheService();
await cache.init();
final syncService = TranslationSyncService(
cache: cache,
apiBaseUrl: 'https://api.example.com',
);
final localizationProvider = OfflineLocalizationProvider(
cache: cache,
syncService: syncService,
);
await localizationProvider.init();
runApp(
ChangeNotifierProvider.value(
value: localizationProvider,
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return Consumer<OfflineLocalizationProvider>(
builder: (context, l10n, child) {
return MaterialApp(
locale: l10n.locale,
home: const HomePage(),
);
},
);
}
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
final l10n = context.watch<OfflineLocalizationProvider>();
return Scaffold(
appBar: AppBar(
title: Text(l10n.translate('app_name')),
actions: [
if (l10n.isOffline)
const Padding(
padding: EdgeInsets.all(8.0),
child: Icon(Icons.cloud_off, color: Colors.orange),
),
],
),
body: Column(
children: [
if (l10n.isOffline)
Container(
color: Colors.orange.shade100,
padding: const EdgeInsets.all(8),
child: Row(
children: [
const Icon(Icons.info_outline),
const SizedBox(width: 8),
Expanded(
child: Text(l10n.translate('offline_mode')),
),
],
),
),
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
l10n.translate('welcome'),
style: Theme.of(context).textTheme.headlineMedium,
),
if (l10n.lastUpdated != null)
Text(
l10n.translate(
'last_updated',
params: {'date': l10n.lastUpdated!.toString()},
),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: l10n.refresh,
child: l10n.isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Icon(Icons.refresh),
),
);
}
}
Extension Method for Clean Access
// lib/l10n/extensions.dart
import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
import 'offline_localization_provider.dart';
extension LocalizationExtension on BuildContext {
OfflineLocalizationProvider get l10n =>
read<OfflineLocalizationProvider>();
String tr(String key, {Map<String, String>? params}) =>
read<OfflineLocalizationProvider>().translate(key, params: params);
}
Usage becomes simple:
Text(context.tr('welcome'));
Text(context.tr('last_updated', params: {'date': '2025-01-01'}));
Handling Large Translation Files
For apps with thousands of strings, implement chunked loading:
// lib/l10n/chunked_translation_service.dart
class ChunkedTranslationService {
final TranslationCacheService _cache;
ChunkedTranslationService(this._cache);
/// Load translations by module/feature
Future<Map<String, String>> loadModule({
required String languageCode,
required String module,
}) async {
final cacheKey = '${languageCode}_$module';
final cached = _cache.getTranslations(cacheKey);
if (cached != null && !cached.isExpired) {
return cached.translations;
}
// Fetch module-specific translations
// ...
return {};
}
/// Preload critical modules for offline use
Future<void> preloadCriticalModules(String languageCode) async {
const criticalModules = ['common', 'auth', 'errors'];
for (final module in criticalModules) {
await loadModule(languageCode: languageCode, module: module);
}
}
}
Delta Updates for Efficiency
Only download changed translations:
// lib/l10n/delta_sync_service.dart
class DeltaSyncService {
Future<Map<String, String>?> fetchDelta({
required String languageCode,
required int fromVersion,
}) async {
final response = await http.get(
Uri.parse('$_apiBaseUrl/translations/$languageCode/delta'),
headers: {
'X-From-Version': fromVersion.toString(),
},
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
return {
'added': data['added'],
'modified': data['modified'],
'removed': data['removed'],
'version': data['version'],
};
}
return null;
}
Future<void> applyDelta({
required String languageCode,
required Map<String, dynamic> delta,
required Map<String, String> currentTranslations,
}) async {
final updated = Map<String, String>.from(currentTranslations);
// Apply changes
if (delta['added'] != null) {
updated.addAll(Map<String, String>.from(delta['added']));
}
if (delta['modified'] != null) {
updated.addAll(Map<String, String>.from(delta['modified']));
}
if (delta['removed'] != null) {
for (final key in delta['removed'] as List) {
updated.remove(key);
}
}
// Save to cache with new version
await _cache.cacheTranslations(
languageCode: languageCode,
translations: updated,
version: delta['version'] as int,
);
}
}
Background Sync with WorkManager
For Android and iOS background sync:
# pubspec.yaml
dependencies:
workmanager: ^0.5.2
// lib/l10n/background_sync.dart
import 'package:workmanager/workmanager.dart';
const translationSyncTask = 'translationSyncTask';
@pragma('vm:entry-point')
void callbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
if (task == translationSyncTask) {
// Initialize services
final cache = TranslationCacheService();
await cache.init();
final syncService = TranslationSyncService(
cache: cache,
apiBaseUrl: 'https://api.example.com',
);
// Sync all cached languages
await syncService.refreshAll();
return true;
}
return false;
});
}
class BackgroundSyncManager {
static Future<void> init() async {
await Workmanager().initialize(callbackDispatcher);
}
static Future<void> schedulePeriodicSync() async {
await Workmanager().registerPeriodicTask(
'translation-sync',
translationSyncTask,
frequency: const Duration(hours: 12),
constraints: Constraints(
networkType: NetworkType.connected,
requiresBatteryNotLow: true,
),
);
}
static Future<void> triggerImmediateSync() async {
await Workmanager().registerOneOffTask(
'translation-sync-now',
translationSyncTask,
constraints: Constraints(
networkType: NetworkType.connected,
),
);
}
}
Testing Offline Scenarios
// test/offline_localization_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
class MockTranslationCacheService extends Mock
implements TranslationCacheService {}
class MockTranslationSyncService extends Mock
implements TranslationSyncService {}
void main() {
group('Offline Localization', () {
late MockTranslationCacheService mockCache;
late MockTranslationSyncService mockSync;
late OfflineLocalizationProvider provider;
setUp(() {
mockCache = MockTranslationCacheService();
mockSync = MockTranslationSyncService();
provider = OfflineLocalizationProvider(
cache: mockCache,
syncService: mockSync,
);
});
test('should use cached translations when offline', () async {
// Arrange
when(mockSync.isOnline).thenAnswer((_) async => false);
when(mockSync.getTranslations('en')).thenAnswer(
(_) async => {'welcome': 'Cached Welcome'},
);
// Act
await provider.loadTranslations('en');
// Assert
expect(provider.translate('welcome'), 'Cached Welcome');
});
test('should fall back to bundled when cache is empty', () async {
// Arrange
when(mockSync.isOnline).thenAnswer((_) async => false);
when(mockSync.getTranslations('en')).thenThrow(Exception('No cache'));
// Act
await provider.loadTranslations('en');
// Assert
expect(
provider.translate('welcome'),
BundledTranslations.translations['en']!['welcome'],
);
});
test('should refresh when coming back online', () async {
// Test connectivity change triggers refresh
});
});
}
Best Practices
1. Always Bundle Core Translations
Never rely solely on remote translations:
// Essential strings that must always work
const coreStrings = [
'app_name',
'error_generic',
'retry',
'cancel',
'ok',
'loading',
'offline_mode',
];
2. Implement Graceful Degradation
String translate(String key) {
// Try cached translations
var text = _cachedTranslations[key];
// Fall back to bundled
text ??= BundledTranslations.get(_locale, key);
// Fall back to English bundled
text ??= BundledTranslations.get('en', key);
// Last resort: return the key itself
return text ?? key;
}
3. Show Sync Status to Users
Widget buildSyncIndicator(OfflineLocalizationProvider l10n) {
if (l10n.isLoading) {
return const CircularProgressIndicator();
}
if (l10n.isOffline) {
return const Icon(Icons.cloud_off, color: Colors.orange);
}
if (l10n.lastUpdated != null) {
final age = DateTime.now().difference(l10n.lastUpdated!);
if (age.inDays > 7) {
return const Icon(Icons.warning, color: Colors.yellow);
}
}
return const Icon(Icons.cloud_done, color: Colors.green);
}
4. Compress Translation Data
// Use gzip compression for API responses
Future<Map<String, String>> fetchCompressed(String languageCode) async {
final response = await http.get(
Uri.parse('$_apiBaseUrl/translations/$languageCode'),
headers: {'Accept-Encoding': 'gzip'},
);
// Response is automatically decompressed by http package
return json.decode(response.body);
}
Conclusion
Building offline-first localization in Flutter requires:
- Bundled translations as an always-available fallback
- Persistent local storage with Hive for cached translations
- Smart sync mechanisms that respect network conditions
- Delta updates for bandwidth efficiency
- Background sync to keep translations fresh
- Clear user feedback about sync status
With these patterns, your app will work reliably in any network condition while still benefiting from remote translation updates.
For managing your translation files and deploying updates, FlutterLocalisation provides API endpoints, version management, and delta sync support that integrates with this offline-first architecture.