From 1502360051333f9dc7c5bf4da70162fe55ab114d Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sun, 15 Feb 2026 19:17:25 +0100 Subject: [PATCH] Default local storage to Hive with legacy migration --- docs/SETUP.md | 9 ++- docs/progress.md | 8 ++ lib/core/config/app_config.dart | 2 +- lib/features/local/local_repository.dart | 21 ++++- .../features/local/local_repository_test.dart | 20 +++-- .../local_repository_hive_migration_test.dart | 81 +++++++++++++++++++ .../responsive_views_smoke_test.dart | 3 + test/features/sync/sync_coordinator_test.dart | 8 +- 8 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 test/features/local/storage/local_repository_hive_migration_test.dart diff --git a/docs/SETUP.md b/docs/SETUP.md index 72cecd7..a9cd594 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -26,7 +26,8 @@ flutter run --dart-define=USE_FAKE_BACKEND=true Default local store: -- `shared_preferences` (stable baseline) +- `Hive` (`USE_HIVE_LOCAL_DB=true` by default) +- first-run migration imports legacy `shared_preferences` payload when present Optional migration path: @@ -34,7 +35,11 @@ Optional migration path: flutter run --dart-define=USE_HIVE_LOCAL_DB=true ``` -When enabled, local app state is stored in Hive via the `LocalDataStore` adapter layer. +Use legacy mode explicitly if needed: + +```bash +flutter run --dart-define=USE_HIVE_LOCAL_DB=false +``` ## Run Tests diff --git a/docs/progress.md b/docs/progress.md index 154c975..af9b482 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -20,8 +20,16 @@ Implemented a persistence boundary so local app state is no longer tightly coupl - 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` + - default now set to Hive (`true`) +- Added one-time migration path from legacy shared prefs into Hive: + - `lib/features/local/local_repository.dart` + - when Hive is active and empty, legacy payload is imported then cleared - Added setup docs for switching local persistence backend: - `docs/SETUP.md` +- Added migration/storage tests: + - `test/features/local/storage/local_data_store_shared_prefs_test.dart` + - `test/features/local/storage/local_repository_store_override_test.dart` + - `test/features/local/storage/local_repository_hive_migration_test.dart` This prepares the app for a true DB migration path while keeping existing UX and tests stable. diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index e099f7a..c900263 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -24,7 +24,7 @@ class AppConfig { /// Enables Hive local persistence store instead of shared_preferences. static bool get useHiveLocalDb => - const bool.fromEnvironment('USE_HIVE_LOCAL_DB', defaultValue: false); + const bool.fromEnvironment('USE_HIVE_LOCAL_DB', defaultValue: true); /// Runtime override for backend URL (e.g. local settings screen). static void overrideBackendBaseUrl(String? baseUrl) { diff --git a/lib/features/local/local_repository.dart b/lib/features/local/local_repository.dart index 480e6ba..6a89ed3 100644 --- a/lib/features/local/local_repository.dart +++ b/lib/features/local/local_repository.dart @@ -1,9 +1,12 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:relationship_saver/core/config/app_config.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_hive.dart'; import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart'; +import 'package:relationship_saver/features/local/storage/local_data_store_shared_prefs.dart'; import 'package:relationship_saver/features/sync/sync_queue_repository.dart'; import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; import 'package:uuid/uuid.dart'; @@ -18,7 +21,23 @@ class LocalRepository extends AsyncNotifier { @override Future build() async { final LocalDataStore store = ref.read(localDataStoreProvider); - final LocalDataRecord? record = await store.read(); + LocalDataRecord? record = await store.read(); + + if ((record == null || record.rawState.isEmpty) && + AppConfig.useHiveLocalDb && + store is HiveLocalDataStore) { + final SharedPrefsLocalDataStore legacyStore = SharedPrefsLocalDataStore(); + final LocalDataRecord? legacy = await legacyStore.read(); + if (legacy != null && legacy.rawState.isNotEmpty) { + await store.write( + rawState: legacy.rawState, + schemaVersion: legacy.schemaVersion, + ); + await legacyStore.clear(); + record = await store.read(); + } + } + if (record == null || record.rawState.isEmpty) { final LocalDataState seeded = LocalDataState.seed(); await _persist(seeded); diff --git a/test/features/local/local_repository_test.dart b/test/features/local/local_repository_test.dart index 50ba9f7..960f8a0 100644 --- a/test/features/local/local_repository_test.dart +++ b/test/features/local/local_repository_test.dart @@ -2,6 +2,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:relationship_saver/features/local/local_models.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:shared_preferences/shared_preferences.dart'; void main() { @@ -10,7 +12,7 @@ void main() { }); test('seeds local data on first load', () async { - final ProviderContainer container = ProviderContainer(); + final ProviderContainer container = _createContainer(); addTearDown(container.dispose); final state = await container.read(localRepositoryProvider.future); @@ -21,7 +23,7 @@ void main() { }); test('adds and removes person with persistence state update', () async { - final ProviderContainer container = ProviderContainer(); + final ProviderContainer container = _createContainer(); addTearDown(container.dispose); final initial = await container.read(localRepositoryProvider.future); @@ -49,7 +51,7 @@ void main() { }); test('adds and removes capture moments', () async { - final ProviderContainer container = ProviderContainer(); + final ProviderContainer container = _createContainer(); addTearDown(container.dispose); final state = await container.read(localRepositoryProvider.future); @@ -73,7 +75,7 @@ void main() { }); test('adds, archives, edits, and deletes ideas', () async { - final ProviderContainer container = ProviderContainer(); + final ProviderContainer container = _createContainer(); addTearDown(container.dispose); final LocalDataState state = await container.read( @@ -124,7 +126,7 @@ void main() { }); test('adds, updates, toggles, and deletes reminders', () async { - final ProviderContainer container = ProviderContainer(); + final ProviderContainer container = _createContainer(); addTearDown(container.dispose); final LocalDataState state = await container.read( @@ -172,3 +174,11 @@ void main() { expect(afterDelete.reminders.length, beforeCount); }); } + +ProviderContainer _createContainer() { + return ProviderContainer( + overrides: [ + localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()), + ], + ); +} diff --git a/test/features/local/storage/local_repository_hive_migration_test.dart b/test/features/local/storage/local_repository_hive_migration_test.dart new file mode 100644 index 0000000..500caf0 --- /dev/null +++ b/test/features/local/storage/local_repository_hive_migration_test.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/local/local_repository.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_provider.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( + 'migrates legacy shared prefs payload into hive store on first load', + () async { + final LocalDataState legacyState = LocalDataState.seed().copyWith( + people: [ + PersonProfile( + id: 'legacy-person-1', + name: 'Legacy Person', + relationship: 'Friend', + affinityScore: 82, + nextMoment: DateTime(2026, 2, 18, 19), + tags: ['legacy'], + notes: 'from shared prefs', + ), + ], + ); + + final SharedPrefsLocalDataStore legacyStore = SharedPrefsLocalDataStore(); + await legacyStore.write( + rawState: jsonEncode(legacyState.toJson()), + schemaVersion: 2, + ); + + final _FakeHiveStore hiveStore = _FakeHiveStore(); + final ProviderContainer container = ProviderContainer( + overrides: [localDataStoreProvider.overrideWithValue(hiveStore)], + ); + addTearDown(container.dispose); + + final LocalDataState loaded = await container.read( + localRepositoryProvider.future, + ); + + expect(loaded.people.first.name, 'Legacy Person'); + + final LocalDataRecord? migrated = await hiveStore.read(); + expect(migrated, isNotNull); + expect(migrated!.schemaVersion, 2); + + final LocalDataRecord? legacyAfter = await legacyStore.read(); + expect(legacyAfter, isNull); + }, + ); +} + +class _FakeHiveStore extends HiveLocalDataStore { + 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/test/features/responsive/responsive_views_smoke_test.dart b/test/features/responsive/responsive_views_smoke_test.dart index 4905386..83dea43 100644 --- a/test/features/responsive/responsive_views_smoke_test.dart +++ b/test/features/responsive/responsive_views_smoke_test.dart @@ -7,6 +7,8 @@ import 'package:relationship_saver/features/auth/sign_in_view.dart'; import 'package:relationship_saver/features/dashboard/dashboard_view.dart'; import 'package:relationship_saver/features/home/app_shell.dart'; import 'package:relationship_saver/features/ideas/ideas_view.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/moments/moments_view.dart'; import 'package:relationship_saver/features/people/people_view.dart'; import 'package:relationship_saver/features/reminders/reminders_view.dart'; @@ -99,6 +101,7 @@ Future pumpResponsive( overrides: [ tokenStoreProvider.overrideWithValue(InMemoryTokenStore()), backendGatewayProvider.overrideWithValue(BackendGatewayFake()), + localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()), ], child: MaterialApp(theme: AppTheme.light(), home: home), ), diff --git a/test/features/sync/sync_coordinator_test.dart b/test/features/sync/sync_coordinator_test.dart index 281555d..522a1f3 100644 --- a/test/features/sync/sync_coordinator_test.dart +++ b/test/features/sync/sync_coordinator_test.dart @@ -1,6 +1,8 @@ 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_coordinator.dart'; import 'package:relationship_saver/features/sync/sync_queue_repository.dart'; import 'package:relationship_saver/features/sync/sync_state.dart'; @@ -16,7 +18,10 @@ void main() { test('pushPending flushes queued local mutations', () async { final ProviderContainer container = ProviderContainer( - overrides: [backendGatewayProvider.overrideWithValue(_TestGateway())], + overrides: [ + backendGatewayProvider.overrideWithValue(_TestGateway()), + localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()), + ], ); addTearDown(container.dispose); @@ -77,6 +82,7 @@ void main() { backendGatewayProvider.overrideWithValue( _TestGateway(pullResult: pullResult), ), + localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()), ], ); addTearDown(container.dispose);