Persistence changes product behavior

Preferences, tokens, catalogs, and offline orders do not have the same requirements.

NeedTypical option
Theme, language, simple flagSharedPreferences
Object and key-value cacheHive
Relations, filters, transactionsDrift/SQLite
Small secretPlatform secure storage

Do not place sensitive tokens in regular preferences. Do not treat a cache as the only source of truth without an expiration policy.

SharedPreferences for configuration

final class PreferencesDataSource {
  PreferencesDataSource(this._preferences);
  final SharedPreferences _preferences;

  bool get darkMode => _preferences.getBool('darkMode') ?? false;
  Future<void> setDarkMode(bool enabled) {
    return _preferences.setBool('darkMode', enabled);
  }
}

Centralize keys and defaults. UI should talk to a controller or use case, not directly to a storage package.

Hive for object-oriented cache

Hive works well for fast key lookup. Define adapters, initialization, versioning, and expiration. Cache entries should carry metadata:

final class CacheEntry<T> {
  const CacheEntry({required this.value, required this.savedAt});
  final T value;
  final DateTime savedAt;

  bool isFresh(Duration ttl, DateTime now) => now.difference(savedAt) <= ttl;
}

Inject time in tests instead of depending on the system clock.

Drift when queries matter

Drift combines SQLite with typed queries and explicit migrations.

class Products extends Table {
  TextColumn get id => text()();
  TextColumn get name => text()();
  RealColumn get price => real()();
  DateTimeColumn get updatedAt => dateTime()();

  @override
  Set<Column<Object>> get primaryKey => {id};
}

Test migrations against data from older versions. Dropping and rebuilding may be acceptable for disposable cache, but not for unsynchronized user work.

Offline-first is a strategy

A common read flow observes the local database, checks freshness, fetches remote data when required, and updates local data in a transaction. UI then reacts to the local stream.

For offline writes, use a local outbox containing an identifier, operation, minimal payload, attempts, and state. Define the conflict policy: server wins, client wins, or domain-specific merge. Do not hide data loss behind last-write-wins.

Security and privacy

Minimize local data, remove what should not survive logout, and keep payloads out of logs. Encryption reduces exposure on compromised devices, but it never replaces server-side authorization.

Guided practice: make the catalog survive without network

Continue in Sales Management. Preferences and catalog use different stores; do not put product JSON in preferences just because it is easy.

Checkpoint 1 — persist one preference asynchronously

flutter pub add shared_preferences

Create:

final class ThemePreferences {
  ThemePreferences(this._preferences);
  final SharedPreferencesAsync _preferences;
  static const _darkModeKey = 'settings.dark_mode';

  Future<bool> readDarkMode() async =>
      await _preferences.getBool(_darkModeKey) ?? false;

  Future<void> writeDarkMode(bool enabled) =>
      _preferences.setBool(_darkModeKey, enabled);
}

Compose with SharedPreferencesAsync(). Hide the plugin behind your interface so domain tests use an in-memory fake.

Checkpoint 2 — choose a database by query needs

Use Drift because the catalog needs observation, ordering, and migrations:

flutter pub add drift drift_flutter
flutter pub add dev:drift_dev dev:build_runner

Create core/database/app_database.dart with a Products table containing ID, name, price, and updatedAt; make ID the primary key. Open it with driftDatabase(name: 'sales_management'), use schema version 1, and add a watched query ordered by name.

dart run build_runner build --delete-conflicting-outputs

Never edit .g.dart. Fix the first generator error and verify the part directive, annotations, and names.

Checkpoint 3 — map local rows at the boundary

Define ProductLocalDataSource with watchProducts() and replaceProducts(). Its Drift adapter maps generated rows to domain types. Replace the list in a transaction:

Future<void> replaceProducts(List<ProductsCompanion> values) {
  return transaction(() async {
    await delete(products).go();
    await batch((batch) => batch.insertAll(products, values));
  });
}

Without a transaction, observers can see a temporary empty catalog.

Checkpoint 4 — make freshness testable

typedef Clock = DateTime Function();

final class CachePolicy {
  const CachePolicy({required this.ttl, required this.clock});
  final Duration ttl;
  final Clock clock;

  bool isFresh(DateTime savedAt) => clock().difference(savedAt) <= ttl;
}

Repository policy: emit local data immediately; skip automatic network while fresh; refresh when stale; replace the database on success; retain stale data with a warning after remote failure; block only when neither cache nor network exists. TTL decides refresh, not deletion.

Checkpoint 5 — evaluate Hive instead of adopting it automatically

In a separate spike, install hive and hive_flutter, open a test box, write one map by ID, and compare key access, querying, generated code, migrations, maintenance, and current Dart compatibility with Drift. A library named in older material is not automatically the best new-product choice.

Checkpoint 6 — test upgrade and offline behavior

Test empty, fresh, and expired cache; remote failure with and without cache; and migration from a database created under the previous schema. A new empty database does not test an upgrade.

Manually load online, fully stop, disable network, reopen, verify stale indication and safe actions, reconnect, and synchronize. Run generator, formatting, analysis, and tests.

Common errors

SymptomLikely cause
database is empty after every startin-memory connection or changed path/name
UI flashes empty during refreshreplacement is not transactional
TTL test depends on time of dayreal clock was not injected
migration passes but loses datatest started from an empty database
logout retains private datacleanup policy is missing

Independent practice: model an outbox with stable ID, minimal payload, attempts, next run, and terminal error. Define a price-conflict policy.

Deeper understanding: validity, migration, and conflict

Persisting a value requires answers about source of truth, validity period, and how older versions will be read. TTL is a freshness policy, not a deletion mechanism. Expired data can remain useful offline when the interface communicates staleness.

Test migrations with a database created by the previous version, not only an empty database. Create a logical backup when information cannot be reconstructed and keep transactions short. Changing schema without strategy turns a store update into data-loss risk.

For offline writes, an outbox needs a stable identifier, attempt count, next time, last failure reason, and terminal state. Conflict is not a generic “technical problem”: inventory, pricing, and registration can require different policies.

Terms to review: source of truth, TTL, cache, durable data, migration, transaction, outbox, and conflict resolution. See the course reference.