Add local data store adapter layer and Hive scaffold
This commit is contained in:
@@ -22,6 +22,20 @@ Use fake gateway for local/offline development:
|
|||||||
flutter run --dart-define=USE_FAKE_BACKEND=true
|
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
|
## Run Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -2,6 +2,29 @@
|
|||||||
|
|
||||||
Updated: 2026-02-15
|
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
|
## 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:
|
Audited and adjusted the main product views for mobile-first behavior while preserving desktop/web UX:
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ class AppConfig {
|
|||||||
static bool get useFakeBackend =>
|
static bool get useFakeBackend =>
|
||||||
const bool.fromEnvironment('USE_FAKE_BACKEND', defaultValue: true);
|
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).
|
/// Runtime override for backend URL (e.g. local settings screen).
|
||||||
static void overrideBackendBaseUrl(String? baseUrl) {
|
static void overrideBackendBaseUrl(String? baseUrl) {
|
||||||
_baseUrlOverride = baseUrl;
|
_baseUrlOverride = baseUrl;
|
||||||
|
|||||||
@@ -2,15 +2,14 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:relationship_saver/features/local/local_models.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/features/sync/sync_queue_repository.dart';
|
||||||
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
/// Persisted local data source for offline-first product state.
|
/// Persisted local data source for offline-first product state.
|
||||||
class LocalRepository extends AsyncNotifier<LocalDataState> {
|
class LocalRepository extends AsyncNotifier<LocalDataState> {
|
||||||
static const String _storageKey = 'local_data_state_v1';
|
|
||||||
static const String _schemaVersionKey = 'local_data_schema_version';
|
|
||||||
static const int _schemaVersion = 2;
|
static const int _schemaVersion = 2;
|
||||||
static const int _syncSchemaVersion = 1;
|
static const int _syncSchemaVersion = 1;
|
||||||
|
|
||||||
@@ -18,18 +17,18 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<LocalDataState> build() async {
|
Future<LocalDataState> build() async {
|
||||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
final LocalDataStore store = ref.read(localDataStoreProvider);
|
||||||
await _migrateIfNeeded(prefs);
|
final LocalDataRecord? record = await store.read();
|
||||||
|
if (record == null || record.rawState.isEmpty) {
|
||||||
final String? raw = prefs.getString(_storageKey);
|
|
||||||
if (raw == null || raw.isEmpty) {
|
|
||||||
final LocalDataState seeded = LocalDataState.seed();
|
final LocalDataState seeded = LocalDataState.seed();
|
||||||
await _persist(seeded);
|
await _persist(seeded);
|
||||||
return seeded;
|
return seeded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final LocalDataRecord migrated = await _migrateIfNeeded(record);
|
||||||
try {
|
try {
|
||||||
final Map<String, dynamic> json = jsonDecode(raw) as Map<String, dynamic>;
|
final Map<String, dynamic> json =
|
||||||
|
jsonDecode(migrated.rawState) as Map<String, dynamic>;
|
||||||
return LocalDataState.fromJson(json);
|
return LocalDataState.fromJson(json);
|
||||||
} on FormatException {
|
} on FormatException {
|
||||||
final LocalDataState fallback = LocalDataState.seed();
|
final LocalDataState fallback = LocalDataState.seed();
|
||||||
@@ -882,25 +881,37 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _persist(LocalDataState state) async {
|
Future<void> _persist(LocalDataState state) async {
|
||||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
await ref
|
||||||
await prefs.setString(_storageKey, jsonEncode(state.toJson()));
|
.read(localDataStoreProvider)
|
||||||
await prefs.setInt(_schemaVersionKey, _schemaVersion);
|
.write(
|
||||||
|
rawState: jsonEncode(state.toJson()),
|
||||||
|
schemaVersion: _schemaVersion,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _migrateIfNeeded(SharedPreferences prefs) async {
|
Future<LocalDataRecord> _migrateIfNeeded(LocalDataRecord record) async {
|
||||||
final int currentVersion = prefs.getInt(_schemaVersionKey) ?? 0;
|
final int currentVersion = record.schemaVersion;
|
||||||
if (currentVersion == _schemaVersion) {
|
if (currentVersion == _schemaVersion) {
|
||||||
return;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final LocalDataStore store = ref.read(localDataStoreProvider);
|
||||||
if (currentVersion <= 0) {
|
if (currentVersion <= 0) {
|
||||||
await prefs.remove(_storageKey);
|
final LocalDataState seeded = LocalDataState.seed();
|
||||||
await prefs.setInt(_schemaVersionKey, _schemaVersion);
|
await store.clear();
|
||||||
return;
|
await _persist(seeded);
|
||||||
|
return LocalDataRecord(
|
||||||
|
rawState: jsonEncode(seeded.toJson()),
|
||||||
|
schemaVersion: _schemaVersion,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migration placeholder for future schema versions.
|
// 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) {
|
String _titleFromSummary(String summary) {
|
||||||
|
|||||||
@@ -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<LocalDataRecord?> read();
|
||||||
|
|
||||||
|
/// Writes state payload and schema version.
|
||||||
|
Future<void> write({required String rawState, required int schemaVersion});
|
||||||
|
|
||||||
|
/// Clears persisted state payload.
|
||||||
|
Future<void> clear();
|
||||||
|
}
|
||||||
@@ -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<void> clear() async {
|
||||||
|
final Box<dynamic> box = await _openBox();
|
||||||
|
await box.delete(_stateKey);
|
||||||
|
await box.delete(_schemaVersionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LocalDataRecord?> read() async {
|
||||||
|
final Box<dynamic> 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<void> write({
|
||||||
|
required String rawState,
|
||||||
|
required int schemaVersion,
|
||||||
|
}) async {
|
||||||
|
final Box<dynamic> box = await _openBox();
|
||||||
|
await box.put(_stateKey, rawState);
|
||||||
|
await box.put(_schemaVersionKey, schemaVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Box<dynamic>> _openBox() async {
|
||||||
|
if (!_initialized) {
|
||||||
|
await Hive.initFlutter();
|
||||||
|
_initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Hive.isBoxOpen(_boxName)) {
|
||||||
|
return Hive.openBox<dynamic>(_boxName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Hive.box<dynamic>(_boxName);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LocalDataStore> localDataStoreProvider =
|
||||||
|
Provider<LocalDataStore>((Ref ref) {
|
||||||
|
if (AppConfig.useHiveLocalDb) {
|
||||||
|
return HiveLocalDataStore();
|
||||||
|
}
|
||||||
|
return SharedPrefsLocalDataStore();
|
||||||
|
});
|
||||||
@@ -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<void> clear() async {
|
||||||
|
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(stateKey);
|
||||||
|
await prefs.remove(schemaVersionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<LocalDataRecord?> 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<void> write({
|
||||||
|
required String rawState,
|
||||||
|
required int schemaVersion,
|
||||||
|
}) async {
|
||||||
|
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(stateKey, rawState);
|
||||||
|
await prefs.setInt(schemaVersionKey, schemaVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -352,6 +352,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
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:
|
hooks:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ dependencies:
|
|||||||
clock: ^1.1.2
|
clock: ^1.1.2
|
||||||
flutter_riverpod: ^3.2.1
|
flutter_riverpod: ^3.2.1
|
||||||
shared_preferences: ^2.5.4
|
shared_preferences: ^2.5.4
|
||||||
|
hive_flutter: ^1.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -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(<String, Object>{});
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 <String>['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<SyncState> build() async => SyncState.empty;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> enqueue(ChangeEnvelope change) async {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> enqueueAll(Iterable<ChangeEnvelope> changes) async {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user