Persistence changes product behavior
Preferences, tokens, catalogs, and offline orders do not have the same requirements.
| Need | Typical option |
|---|---|
| Theme, language, simple flag | SharedPreferences |
| Object and key-value cache | Hive |
| Relations, filters, transactions | Drift/SQLite |
| Small secret | Platform 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
| Symptom | Likely cause |
|---|---|
| database is empty after every start | in-memory connection or changed path/name |
| UI flashes empty during refresh | replacement is not transactional |
| TTL test depends on time of day | real clock was not injected |
| migration passes but loses data | test started from an empty database |
| logout retains private data | cleanup 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.