From 21c911ad4cf8daafd6dade04707d8ac00f9a4d26 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sun, 15 Feb 2026 19:05:20 +0100 Subject: [PATCH] Add local data store adapter layer and Hive scaffold --- docs/SETUP.md | 14 +++++ docs/progress.md | 23 ++++++++ lib/core/config/app_config.dart | 4 ++ lib/features/local/local_repository.dart | 49 ++++++++++------ .../local/storage/local_data_store.dart | 19 ++++++ .../local/storage/local_data_store_hive.dart | 54 +++++++++++++++++ .../storage/local_data_store_in_memory.dart | 22 +++++++ .../storage/local_data_store_provider.dart | 14 +++++ .../local_data_store_shared_prefs.dart | 44 ++++++++++++++ pubspec.lock | 16 +++++ pubspec.yaml | 1 + .../local_data_store_shared_prefs_test.dart | 29 ++++++++++ .../local_repository_store_override_test.dart | 58 +++++++++++++++++++ 13 files changed, 328 insertions(+), 19 deletions(-) create mode 100644 lib/features/local/storage/local_data_store.dart create mode 100644 lib/features/local/storage/local_data_store_hive.dart create mode 100644 lib/features/local/storage/local_data_store_in_memory.dart create mode 100644 lib/features/local/storage/local_data_store_provider.dart create mode 100644 lib/features/local/storage/local_data_store_shared_prefs.dart create mode 100644 test/features/local/storage/local_data_store_shared_prefs_test.dart create mode 100644 test/features/local/storage/local_repository_store_override_test.dart diff --git a/docs/SETUP.md b/docs/SETUP.md index 14a1cd0..72cecd7 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -22,6 +22,20 @@ Use fake gateway for local/offline development: flutter run --dart-define=USE_FAKE_BACKEND=true ``` +## Local Persistence Backend + +Default local store: + +- `shared_preferences` (stable baseline) + +Optional migration path: + +```bash +flutter run --dart-define=USE_HIVE_LOCAL_DB=true +``` + +When enabled, local app state is stored in Hive via the `LocalDataStore` adapter layer. + ## Run Tests ```bash diff --git a/docs/progress.md b/docs/progress.md index 7c5be23..154c975 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -2,6 +2,29 @@ Updated: 2026-02-15 +## Latest Milestone (2026-02-15): Local Store Adapter Layer (DB Migration Scaffold) + +Implemented a persistence boundary so local app state is no longer tightly coupled to `shared_preferences` internals: + +- Added local persistence abstraction: + - `lib/features/local/storage/local_data_store.dart` + - `LocalDataStore` + `LocalDataRecord` +- Added concrete store implementations: + - `lib/features/local/storage/local_data_store_shared_prefs.dart` + - `lib/features/local/storage/local_data_store_hive.dart` + - `lib/features/local/storage/local_data_store_in_memory.dart` + - provider wiring: `lib/features/local/storage/local_data_store_provider.dart` +- Refactored local repository to use store adapter: + - `lib/features/local/local_repository.dart` + - build/read/write/migration paths now go through `LocalDataStore` + - schema migration hook retained and now store-agnostic +- Added config flag for local backend selection: + - `lib/core/config/app_config.dart` -> `USE_HIVE_LOCAL_DB` +- Added setup docs for switching local persistence backend: + - `docs/SETUP.md` + +This prepares the app for a true DB migration path while keeping existing UX and tests stable. + ## Latest Milestone (2026-02-15): Cross-View Responsive Pass Audited and adjusted the main product views for mobile-first behavior while preserving desktop/web UX: diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 5f2b43d..e099f7a 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -22,6 +22,10 @@ class AppConfig { static bool get useFakeBackend => const bool.fromEnvironment('USE_FAKE_BACKEND', defaultValue: true); + /// Enables Hive local persistence store instead of shared_preferences. + static bool get useHiveLocalDb => + const bool.fromEnvironment('USE_HIVE_LOCAL_DB', defaultValue: false); + /// Runtime override for backend URL (e.g. local settings screen). static void overrideBackendBaseUrl(String? baseUrl) { _baseUrlOverride = baseUrl; diff --git a/lib/features/local/local_repository.dart b/lib/features/local/local_repository.dart index 80107b0..480e6ba 100644 --- a/lib/features/local/local_repository.dart +++ b/lib/features/local/local_repository.dart @@ -2,15 +2,14 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/local/storage/local_data_store.dart'; +import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart'; import 'package:relationship_saver/features/sync/sync_queue_repository.dart'; import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; /// Persisted local data source for offline-first product state. class LocalRepository extends AsyncNotifier { - static const String _storageKey = 'local_data_state_v1'; - static const String _schemaVersionKey = 'local_data_schema_version'; static const int _schemaVersion = 2; static const int _syncSchemaVersion = 1; @@ -18,18 +17,18 @@ class LocalRepository extends AsyncNotifier { @override Future build() async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await _migrateIfNeeded(prefs); - - final String? raw = prefs.getString(_storageKey); - if (raw == null || raw.isEmpty) { + final LocalDataStore store = ref.read(localDataStoreProvider); + final LocalDataRecord? record = await store.read(); + if (record == null || record.rawState.isEmpty) { final LocalDataState seeded = LocalDataState.seed(); await _persist(seeded); return seeded; } + final LocalDataRecord migrated = await _migrateIfNeeded(record); try { - final Map json = jsonDecode(raw) as Map; + final Map json = + jsonDecode(migrated.rawState) as Map; return LocalDataState.fromJson(json); } on FormatException { final LocalDataState fallback = LocalDataState.seed(); @@ -882,25 +881,37 @@ class LocalRepository extends AsyncNotifier { } Future _persist(LocalDataState state) async { - final SharedPreferences prefs = await SharedPreferences.getInstance(); - await prefs.setString(_storageKey, jsonEncode(state.toJson())); - await prefs.setInt(_schemaVersionKey, _schemaVersion); + await ref + .read(localDataStoreProvider) + .write( + rawState: jsonEncode(state.toJson()), + schemaVersion: _schemaVersion, + ); } - Future _migrateIfNeeded(SharedPreferences prefs) async { - final int currentVersion = prefs.getInt(_schemaVersionKey) ?? 0; + Future _migrateIfNeeded(LocalDataRecord record) async { + final int currentVersion = record.schemaVersion; if (currentVersion == _schemaVersion) { - return; + return record; } + final LocalDataStore store = ref.read(localDataStoreProvider); if (currentVersion <= 0) { - await prefs.remove(_storageKey); - await prefs.setInt(_schemaVersionKey, _schemaVersion); - return; + final LocalDataState seeded = LocalDataState.seed(); + await store.clear(); + await _persist(seeded); + return LocalDataRecord( + rawState: jsonEncode(seeded.toJson()), + schemaVersion: _schemaVersion, + ); } // Migration placeholder for future schema versions. - await prefs.setInt(_schemaVersionKey, _schemaVersion); + await store.write(rawState: record.rawState, schemaVersion: _schemaVersion); + return LocalDataRecord( + rawState: record.rawState, + schemaVersion: _schemaVersion, + ); } String _titleFromSummary(String summary) { diff --git a/lib/features/local/storage/local_data_store.dart b/lib/features/local/storage/local_data_store.dart new file mode 100644 index 0000000..3835104 --- /dev/null +++ b/lib/features/local/storage/local_data_store.dart @@ -0,0 +1,19 @@ +/// Raw local state payload and associated schema version. +class LocalDataRecord { + const LocalDataRecord({required this.rawState, required this.schemaVersion}); + + final String rawState; + final int schemaVersion; +} + +/// Persistence boundary for local app state. +abstract interface class LocalDataStore { + /// Reads latest persisted state payload. + Future read(); + + /// Writes state payload and schema version. + Future write({required String rawState, required int schemaVersion}); + + /// Clears persisted state payload. + Future clear(); +} diff --git a/lib/features/local/storage/local_data_store_hive.dart b/lib/features/local/storage/local_data_store_hive.dart new file mode 100644 index 0000000..dc35ef5 --- /dev/null +++ b/lib/features/local/storage/local_data_store_hive.dart @@ -0,0 +1,54 @@ +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:relationship_saver/features/local/storage/local_data_store.dart'; + +/// Hive-backed local store for future DB migration path. +class HiveLocalDataStore implements LocalDataStore { + static const String _boxName = 'relationship_saver_local'; + static const String _stateKey = 'state_json'; + static const String _schemaVersionKey = 'schema_version'; + + static bool _initialized = false; + + @override + Future clear() async { + final Box box = await _openBox(); + await box.delete(_stateKey); + await box.delete(_schemaVersionKey); + } + + @override + Future read() async { + final Box box = await _openBox(); + final String? rawState = box.get(_stateKey) as String?; + if (rawState == null || rawState.isEmpty) { + return null; + } + + final int schemaVersion = + (box.get(_schemaVersionKey) as num?)?.toInt() ?? 0; + return LocalDataRecord(rawState: rawState, schemaVersion: schemaVersion); + } + + @override + Future write({ + required String rawState, + required int schemaVersion, + }) async { + final Box box = await _openBox(); + await box.put(_stateKey, rawState); + await box.put(_schemaVersionKey, schemaVersion); + } + + Future> _openBox() async { + if (!_initialized) { + await Hive.initFlutter(); + _initialized = true; + } + + if (!Hive.isBoxOpen(_boxName)) { + return Hive.openBox(_boxName); + } + + return Hive.box(_boxName); + } +} diff --git a/lib/features/local/storage/local_data_store_in_memory.dart b/lib/features/local/storage/local_data_store_in_memory.dart new file mode 100644 index 0000000..ab7e1cd --- /dev/null +++ b/lib/features/local/storage/local_data_store_in_memory.dart @@ -0,0 +1,22 @@ +import 'package:relationship_saver/features/local/storage/local_data_store.dart'; + +/// In-memory local store for deterministic tests. +class InMemoryLocalDataStore implements LocalDataStore { + LocalDataRecord? _record; + + @override + Future clear() async { + _record = null; + } + + @override + Future read() async => _record; + + @override + Future write({ + required String rawState, + required int schemaVersion, + }) async { + _record = LocalDataRecord(rawState: rawState, schemaVersion: schemaVersion); + } +} diff --git a/lib/features/local/storage/local_data_store_provider.dart b/lib/features/local/storage/local_data_store_provider.dart new file mode 100644 index 0000000..f7966c4 --- /dev/null +++ b/lib/features/local/storage/local_data_store_provider.dart @@ -0,0 +1,14 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_config.dart'; +import 'package:relationship_saver/features/local/storage/local_data_store.dart'; +import 'package:relationship_saver/features/local/storage/local_data_store_hive.dart'; +import 'package:relationship_saver/features/local/storage/local_data_store_shared_prefs.dart'; + +/// Chooses local persistence backend for app state. +final Provider localDataStoreProvider = + Provider((Ref ref) { + if (AppConfig.useHiveLocalDb) { + return HiveLocalDataStore(); + } + return SharedPrefsLocalDataStore(); + }); diff --git a/lib/features/local/storage/local_data_store_shared_prefs.dart b/lib/features/local/storage/local_data_store_shared_prefs.dart new file mode 100644 index 0000000..6cd4a54 --- /dev/null +++ b/lib/features/local/storage/local_data_store_shared_prefs.dart @@ -0,0 +1,44 @@ +import 'package:relationship_saver/features/local/storage/local_data_store.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// shared_preferences-backed local store used as stable default. +class SharedPrefsLocalDataStore implements LocalDataStore { + SharedPrefsLocalDataStore({ + this.stateKey = 'local_data_state_v1', + this.schemaVersionKey = 'local_data_schema_version', + }); + + final String stateKey; + final String schemaVersionKey; + + @override + Future clear() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.remove(stateKey); + await prefs.remove(schemaVersionKey); + } + + @override + Future read() async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + final String? rawState = prefs.getString(stateKey); + if (rawState == null || rawState.isEmpty) { + return null; + } + + return LocalDataRecord( + rawState: rawState, + schemaVersion: prefs.getInt(schemaVersionKey) ?? 0, + ); + } + + @override + Future write({ + required String rawState, + required int schemaVersion, + }) async { + final SharedPreferences prefs = await SharedPreferences.getInstance(); + await prefs.setString(stateKey, rawState); + await prefs.setInt(schemaVersionKey, schemaVersion); + } +} diff --git a/pubspec.lock b/pubspec.lock index 9efeae3..acb58ff 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -352,6 +352,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hive: + dependency: transitive + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + url: "https://pub.dev" + source: hosted + version: "1.1.0" hooks: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ba5a1f7..2ff530a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: clock: ^1.1.2 flutter_riverpod: ^3.2.1 shared_preferences: ^2.5.4 + hive_flutter: ^1.1.0 dev_dependencies: flutter_test: diff --git a/test/features/local/storage/local_data_store_shared_prefs_test.dart b/test/features/local/storage/local_data_store_shared_prefs_test.dart new file mode 100644 index 0000000..735dbd3 --- /dev/null +++ b/test/features/local/storage/local_data_store_shared_prefs_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/features/local/storage/local_data_store_shared_prefs.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('write and read roundtrip', () async { + final SharedPrefsLocalDataStore store = SharedPrefsLocalDataStore(); + + await store.write(rawState: '{"people":[]}', schemaVersion: 7); + + final record = await store.read(); + expect(record, isNotNull); + expect(record!.rawState, '{"people":[]}'); + expect(record.schemaVersion, 7); + }); + + test('clear removes stored payload', () async { + final SharedPrefsLocalDataStore store = SharedPrefsLocalDataStore(); + + await store.write(rawState: '{"x":1}', schemaVersion: 1); + await store.clear(); + + expect(await store.read(), isNull); + }); +} diff --git a/test/features/local/storage/local_repository_store_override_test.dart b/test/features/local/storage/local_repository_store_override_test.dart new file mode 100644 index 0000000..699e794 --- /dev/null +++ b/test/features/local/storage/local_repository_store_override_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/features/local/local_repository.dart'; +import 'package:relationship_saver/features/local/storage/local_data_store_in_memory.dart'; +import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart'; +import 'package:relationship_saver/features/sync/sync_queue_repository.dart'; +import 'package:relationship_saver/features/sync/sync_state.dart'; +import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; + +void main() { + test('local repository works with injected in-memory store', () async { + final InMemoryLocalDataStore store = InMemoryLocalDataStore(); + final ProviderContainer container = ProviderContainer( + overrides: [ + localDataStoreProvider.overrideWithValue(store), + syncQueueRepositoryProvider.overrideWith(_NoopSyncQueueRepository.new), + ], + ); + addTearDown(container.dispose); + + final initial = await container.read(localRepositoryProvider.future); + expect(initial.people, isNotEmpty); + + await container + .read(localRepositoryProvider.notifier) + .addPerson( + name: 'Injected Store Person', + relationship: 'Friend', + notes: 'stored in-memory', + tags: const ['in-memory'], + ); + + final updated = await container.read(localRepositoryProvider.future); + expect( + updated.people.any((person) => person.name == 'Injected Store Person'), + isTrue, + ); + + final record = await store.read(); + expect(record, isNotNull); + expect(record!.rawState.contains('Injected Store Person'), isTrue); + }); +} + +class _NoopSyncQueueRepository extends SyncQueueRepository { + @override + Future build() async => SyncState.empty; + + @override + Future enqueue(ChangeEnvelope change) async { + return; + } + + @override + Future enqueueAll(Iterable changes) async { + return; + } +}