← Back to Blog

Flutter Localization with Isar Database: Offline-First Translation Storage

flutterisardatabaseofflinelocalizationcachingnosql

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: