Default local storage to Hive with legacy migration

This commit is contained in:
Rijad Zuzo
2026-02-15 19:17:25 +01:00
parent 21c911ad4c
commit 1502360051
8 changed files with 142 additions and 10 deletions
+7 -2
View File
@@ -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
+8
View File
@@ -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.
+1 -1
View File
@@ -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) {
+20 -1
View File
@@ -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<LocalDataState> {
@override
Future<LocalDataState> 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);
+15 -5
View File
@@ -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()),
],
);
}
@@ -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(<String, Object>{});
});
test(
'migrates legacy shared prefs payload into hive store on first load',
() async {
final LocalDataState legacyState = LocalDataState.seed().copyWith(
people: <PersonProfile>[
PersonProfile(
id: 'legacy-person-1',
name: 'Legacy Person',
relationship: 'Friend',
affinityScore: 82,
nextMoment: DateTime(2026, 2, 18, 19),
tags: <String>['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<void> clear() async {
_record = null;
}
@override
Future<LocalDataRecord?> read() async => _record;
@override
Future<void> write({
required String rawState,
required int schemaVersion,
}) async {
_record = LocalDataRecord(rawState: rawState, schemaVersion: schemaVersion);
}
}
@@ -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<void> pumpResponsive(
overrides: [
tokenStoreProvider.overrideWithValue(InMemoryTokenStore()),
backendGatewayProvider.overrideWithValue(BackendGatewayFake()),
localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()),
],
child: MaterialApp(theme: AppTheme.light(), home: home),
),
@@ -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);