Flutter Localization with Isar Database: Offline-First Translation Storage
Need to store translations locally in your Flutter app? Isar is a lightning-fast NoSQL database perfect for caching translations offline. This guide shows you how to build a robust offline-first localization system with Isar.
Why Use Isar for Translations?
Isar offers significant advantages for translation storage:
| Feature | Benefit for Localization |
|---|---|
| Blazing Fast | Sub-millisecond queries for instant translations |
| Offline-First | Works without internet connection |
| Type-Safe | Compile-time checks for translation models |
| Small Footprint | Minimal impact on app size |
| Cross-Platform | Works on iOS, Android, Desktop, Web |
Setting Up Isar for Translations
Step 1: Add Dependencies
# pubspec.yaml
dependencies:
isar: ^3.1.0
isar_flutter_libs: ^3.1.0
path_provider: ^2.1.0
dev_dependencies:
isar_generator: ^3.1.0
build_runner: ^2.4.0
Step 2: Create Translation Models
import 'package:isar/isar.dart';
part 'translation.g.dart';
@collection
class Translation {
Id id = Isar.autoIncrement;
@Index(unique: true, composite: [CompositeIndex('locale')])
late String key;
@Index()
late String locale;
late String value;
String? description;
DateTime? updatedAt;
@Index()
late int version;
}
@collection
class LocaleMetadata {
Id id = Isar.autoIncrement;
@Index(unique: true)
late String locale;
late String displayName;
late String nativeName;
late int totalKeys;
late int translatedKeys;
DateTime? lastSyncedAt;
late int version;
}
Step 3: Generate Isar Code
flutter pub run build_runner build
Step 4: Initialize Isar
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
class IsarTranslationService {
static Isar? _isar;
static Future<Isar> get instance async {
if (_isar != null) return _isar!;
final dir = await getApplicationDocumentsDirectory();
_isar = await Isar.open(
[TranslationSchema, LocaleMetadataSchema],
directory: dir.path,
name: 'translations',
);
return _isar!;
}
}
Building the Translation Repository
Create a repository to manage translations:
class TranslationRepository {
final Isar _isar;
TranslationRepository(this._isar);
/// Get a single translation
Future<String?> getTranslation(String key, String locale) async {
final translation = await _isar.translations
.filter()
.keyEqualTo(key)
.and()
.localeEqualTo(locale)
.findFirst();
return translation?.value;
}
/// Get all translations for a locale
Future<Map<String, String>> getAllTranslations(String locale) async {
final translations = await _isar.translations
.filter()
.localeEqualTo(locale)
.findAll();
return Map.fromEntries(
translations.map((t) => MapEntry(t.key, t.value)),
);
}
/// Save translations (batch insert)
Future<void> saveTranslations(
String locale,
Map<String, String> translations,
int version,
) async {
await _isar.writeTxn(() async {
// Clear old translations for this locale
await _isar.translations
.filter()
.localeEqualTo(locale)
.deleteAll();
// Insert new translations
final entries = translations.entries.map((e) {
return Translation()
..key = e.key
..locale = locale
..value = e.value
..version = version
..updatedAt = DateTime.now();
}).toList();
await _isar.translations.putAll(entries);
// Update locale metadata
final metadata = LocaleMetadata()
..locale = locale
..totalKeys = translations.length
..translatedKeys = translations.length
..lastSyncedAt = DateTime.now()
..version = version;
await _isar.localeMetadatas.put(metadata);
});
}
/// Check if locale needs update
Future<bool> needsUpdate(String locale, int serverVersion) async {
final metadata = await _isar.localeMetadatas
.filter()
.localeEqualTo(locale)
.findFirst();
if (metadata == null) return true;
return metadata.version < serverVersion;
}
/// Get available locales
Future<List<LocaleMetadata>> getAvailableLocales() async {
return await _isar.localeMetadatas.where().findAll();
}
/// Search translations
Future<List<Translation>> searchTranslations(
String query,
String locale,
) async {
return await _isar.translations
.filter()
.localeEqualTo(locale)
.and()
.group((q) => q
.keyContains(query, caseSensitive: false)
.or()
.valueContains(query, caseSensitive: false))
.findAll();
}
}
Integrating with Flutter Localization
Custom Localization Delegate
class IsarLocalizationsDelegate
extends LocalizationsDelegate<IsarLocalizations> {
final TranslationRepository repository;
const IsarLocalizationsDelegate(this.repository);
@override
bool isSupported(Locale locale) => true;
@override
Future<IsarLocalizations> load(Locale locale) async {
final translations = await repository.getAllTranslations(
locale.languageCode,
);
return IsarLocalizations(translations, locale);
}
@override
bool shouldReload(IsarLocalizationsDelegate old) => false;
}
class IsarLocalizations {
final Map<String, String> _translations;
final Locale locale;
IsarLocalizations(this._translations, this.locale);
static IsarLocalizations of(BuildContext context) {
return Localizations.of<IsarLocalizations>(
context,
IsarLocalizations,
)!;
}
String translate(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;
}
// Convenience getter
String get appTitle => translate('appTitle');
String welcomeMessage(String name) =>
translate('welcomeMessage', {'name': name});
}
Setup in MaterialApp
class MyApp extends StatelessWidget {
final TranslationRepository repository;
const MyApp({required this.repository});
@override
Widget build(BuildContext context) {
return MaterialApp(
localizationsDelegates: [
IsarLocalizationsDelegate(repository),
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [
Locale('en'),
Locale('es'),
Locale('fr'),
Locale('de'),
],
home: const HomePage(),
);
}
}
Syncing with Remote Server
Translation Sync Service
class TranslationSyncService {
final TranslationRepository repository;
final ApiClient apiClient;
TranslationSyncService(this.repository, this.apiClient);
/// Sync all locales
Future<void> syncAll() async {
final serverLocales = await apiClient.getAvailableLocales();
for (final locale in serverLocales) {
await syncLocale(locale.code, locale.version);
}
}
/// Sync single locale if needed
Future<bool> syncLocale(String locale, int serverVersion) async {
final needsUpdate = await repository.needsUpdate(locale, serverVersion);
if (!needsUpdate) {
print('Locale $locale is up to date');
return false;
}
print('Syncing locale $locale...');
final translations = await apiClient.getTranslations(locale);
await repository.saveTranslations(locale, translations, serverVersion);
print('Synced ${translations.length} translations for $locale');
return true;
}
/// Delta sync - only get changed translations
Future<void> deltaSync(String locale) async {
final metadata = await repository.getLocaleMetadata(locale);
final lastSync = metadata?.lastSyncedAt;
final changes = await apiClient.getTranslationChanges(
locale,
since: lastSync,
);
if (changes.isEmpty) return;
await repository.applyChanges(locale, changes);
}
}
Background Sync with WorkManager
import 'package:workmanager/workmanager.dart';
@pragma('vm:entry-point')
void callbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
if (task == 'syncTranslations') {
final isar = await IsarTranslationService.instance;
final repository = TranslationRepository(isar);
final apiClient = ApiClient();
final syncService = TranslationSyncService(repository, apiClient);
await syncService.syncAll();
}
return true;
});
}
// Schedule periodic sync
void setupBackgroundSync() {
Workmanager().registerPeriodicTask(
'translation-sync',
'syncTranslations',
frequency: const Duration(hours: 6),
constraints: Constraints(
networkType: NetworkType.connected,
),
);
}
Optimizing Performance
Indexing Strategy
@collection
class Translation {
Id id = Isar.autoIncrement;
// Composite index for fast key+locale lookups
@Index(unique: true, composite: [CompositeIndex('locale')])
late String key;
// Separate index for locale filtering
@Index()
late String locale;
// Index for version-based queries
@Index()
late int version;
// Full-text search index
@Index(type: IndexType.value, caseSensitive: false)
late String value;
// ... rest of fields
}
Lazy Loading Translations
class LazyTranslationLoader {
final TranslationRepository repository;
final Map<String, String> _cache = {};
String _currentLocale = 'en';
LazyTranslationLoader(this.repository);
Future<void> setLocale(String locale) async {
if (_currentLocale == locale) return;
_currentLocale = locale;
_cache.clear();
// Preload common keys
await _preloadCommonKeys();
}
Future<void> _preloadCommonKeys() async {
final commonKeys = [
'appTitle',
'ok',
'cancel',
'save',
'delete',
'error',
'loading',
];
for (final key in commonKeys) {
final value = await repository.getTranslation(key, _currentLocale);
if (value != null) {
_cache[key] = value;
}
}
}
Future<String> get(String key) async {
if (_cache.containsKey(key)) {
return _cache[key]!;
}
final value = await repository.getTranslation(key, _currentLocale);
if (value != null) {
_cache[key] = value;
return value;
}
return key; // Fallback to key
}
}
Batch Operations
extension TranslationBatchOps on TranslationRepository {
/// Efficient batch lookup
Future<Map<String, String>> getTranslationsBatch(
List<String> keys,
String locale,
) async {
final translations = await _isar.translations
.filter()
.localeEqualTo(locale)
.and()
.anyOf(keys, (q, key) => q.keyEqualTo(key))
.findAll();
return Map.fromEntries(
translations.map((t) => MapEntry(t.key, t.value)),
);
}
/// Stream translations for reactive UI
Stream<List<Translation>> watchTranslations(String locale) {
return _isar.translations
.filter()
.localeEqualTo(locale)
.watch(fireImmediately: true);
}
}
Handling Plurals and ICU Messages
Plural Storage
@collection
class PluralTranslation {
Id id = Isar.autoIncrement;
@Index(unique: true, composite: [CompositeIndex('locale')])
late String key;
@Index()
late String locale;
String? zero;
String? one;
String? two;
String? few;
String? many;
late String other;
String format(int count) {
// Simplified plural logic - use intl package for full support
if (count == 0 && zero != null) return zero!;
if (count == 1 && one != null) return one!;
if (count == 2 && two != null) return two!;
return other.replaceAll('{count}', count.toString());
}
}
ICU Message Parsing
class IcuMessageParser {
static String parse(String template, Map<String, dynamic> params) {
String result = template;
params.forEach((key, value) {
// Handle simple placeholders
result = result.replaceAll('{$key}', value.toString());
// Handle plurals
final pluralRegex = RegExp(
r'\{' + key + r',\s*plural\s*,([^}]+)\}',
);
if (pluralRegex.hasMatch(result) && value is int) {
result = _handlePlural(result, key, value, pluralRegex);
}
});
return result;
}
static String _handlePlural(
String template,
String key,
int count,
RegExp regex,
) {
// Parse plural categories and select appropriate one
// Implementation depends on your ICU message complexity
return template;
}
}
Migration from SharedPreferences
If you're migrating from SharedPreferences:
class TranslationMigration {
static Future<void> migrateFromSharedPrefs(
TranslationRepository repository,
) async {
final prefs = await SharedPreferences.getInstance();
// Get all stored translations
final keys = prefs.getKeys().where((k) => k.startsWith('translation_'));
final translations = <String, Map<String, String>>{};
for (final key in keys) {
// Parse key format: translation_en_welcomeMessage
final parts = key.split('_');
if (parts.length >= 3) {
final locale = parts[1];
final translationKey = parts.sublist(2).join('_');
final value = prefs.getString(key);
if (value != null) {
translations.putIfAbsent(locale, () => {});
translations[locale]![translationKey] = value;
}
}
}
// Save to Isar
for (final entry in translations.entries) {
await repository.saveTranslations(entry.key, entry.value, 1);
}
// Clear old data
for (final key in keys) {
await prefs.remove(key);
}
print('Migrated ${translations.length} locales to Isar');
}
}
Testing Isar Translations
import 'package:flutter_test/flutter_test.dart';
import 'package:isar/isar.dart';
void main() {
late Isar isar;
late TranslationRepository repository;
setUp(() async {
await Isar.initializeIsarCore(download: true);
isar = await Isar.open(
[TranslationSchema, LocaleMetadataSchema],
directory: '',
name: 'test_${DateTime.now().millisecondsSinceEpoch}',
);
repository = TranslationRepository(isar);
});
tearDown(() async {
await isar.close(deleteFromDisk: true);
});
test('saves and retrieves translations', () async {
await repository.saveTranslations('en', {
'hello': 'Hello',
'goodbye': 'Goodbye',
}, 1);
final hello = await repository.getTranslation('hello', 'en');
expect(hello, 'Hello');
final all = await repository.getAllTranslations('en');
expect(all.length, 2);
});
test('handles multiple locales', () async {
await repository.saveTranslations('en', {'hello': 'Hello'}, 1);
await repository.saveTranslations('es', {'hello': 'Hola'}, 1);
expect(await repository.getTranslation('hello', 'en'), 'Hello');
expect(await repository.getTranslation('hello', 'es'), 'Hola');
});
test('detects when update needed', () async {
await repository.saveTranslations('en', {'hello': 'Hello'}, 1);
expect(await repository.needsUpdate('en', 1), false);
expect(await repository.needsUpdate('en', 2), true);
expect(await repository.needsUpdate('fr', 1), true); // New locale
});
}
Best Practices
1. Initialize Early
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize Isar before runApp
final isar = await IsarTranslationService.instance;
final repository = TranslationRepository(isar);
// Load initial translations
await ensureDefaultTranslations(repository);
runApp(MyApp(repository: repository));
}
2. Handle Missing Translations
String translate(String key) {
final value = _translations[key];
if (value == null) {
// Log missing translation
debugPrint('Missing translation: $key for locale $_locale');
// Report to analytics
analytics.logMissingTranslation(key, _locale);
return key; // Return key as fallback
}
return value;
}
3. Provide Fallback Chain
Future<String> getWithFallback(String key, String locale) async {
// Try exact locale
var value = await repository.getTranslation(key, locale);
if (value != null) return value;
// Try language only (e.g., 'en' for 'en_US')
if (locale.contains('_')) {
final lang = locale.split('_').first;
value = await repository.getTranslation(key, lang);
if (value != null) return value;
}
// Fall back to English
value = await repository.getTranslation(key, 'en');
if (value != null) return value;
return key;
}
Conclusion
Isar provides excellent performance for offline-first translation storage in Flutter. Key benefits:
- Fast queries for instant translations
- Type-safe models with code generation
- Efficient syncing with versioning support
- Full-text search for translation management
For cloud-based translation management with automatic syncing, check out FlutterLocalisation.
Related Articles:
- Flutter Offline-First Localization
- Flutter Localization Caching Strategies
- Free ARB Editor - Edit and validate your ARB files