Add sync queue orchestration and local mutation envelopes
This commit is contained in:
+32
-4
@@ -2,6 +2,34 @@
|
|||||||
|
|
||||||
Updated: 2026-02-15
|
Updated: 2026-02-15
|
||||||
|
|
||||||
|
## Latest Milestone (2026-02-15): Sync Queue + Orchestrator
|
||||||
|
|
||||||
|
Implemented the next sync phase so local mutations are now queued and synced through a real coordinator flow:
|
||||||
|
|
||||||
|
- Added persisted sync queue state:
|
||||||
|
- `lib/features/sync/sync_state.dart`
|
||||||
|
- `lib/features/sync/sync_queue_repository.dart`
|
||||||
|
- stores pending `ChangeEnvelope`s, cursor, last attempt/sync, and rejection info
|
||||||
|
- Added sync orchestration service:
|
||||||
|
- `lib/features/sync/sync_coordinator.dart`
|
||||||
|
- supports `pushPending`, `pullRemote`, and `syncNow`
|
||||||
|
- keeps offline-first behavior by recording failures without blocking local usage
|
||||||
|
- Wired local CRUD to sync envelopes:
|
||||||
|
- `lib/features/local/local_repository.dart`
|
||||||
|
- all person/moment/idea/reminder mutations now enqueue typed change envelopes
|
||||||
|
- added `applyRemoteChanges(...)` to apply pulled backend envelopes into local state
|
||||||
|
- Upgraded Sync UI:
|
||||||
|
- `lib/features/sync/sync_view.dart`
|
||||||
|
- now shows queue/cursor/error status and provides real sync actions (sync/push/pull/clear)
|
||||||
|
- Added sync tests:
|
||||||
|
- `test/features/sync/sync_queue_repository_test.dart`
|
||||||
|
- `test/features/sync/sync_coordinator_test.dart`
|
||||||
|
|
||||||
|
Validation for this milestone:
|
||||||
|
|
||||||
|
- `flutter analyze` -> pass
|
||||||
|
- `flutter test` -> pass
|
||||||
|
|
||||||
## Latest Milestone (2026-02-15): Auth Session UX
|
## Latest Milestone (2026-02-15): Auth Session UX
|
||||||
|
|
||||||
Implemented app-level authentication/session flow so navigation is now session-aware:
|
Implemented app-level authentication/session flow so navigation is now session-aware:
|
||||||
@@ -149,7 +177,7 @@ Validation status after UI milestone:
|
|||||||
- Local database persistence and repository wiring (currently demo in-memory data).
|
- Local database persistence and repository wiring (currently demo in-memory data).
|
||||||
- Full CRUD flows (create/edit/delete) for people, captures, reminder rules, gift/event ideas.
|
- Full CRUD flows (create/edit/delete) for people, captures, reminder rules, gift/event ideas.
|
||||||
- Auth UX flows (sign-in, session-expired recovery, sign-out confirmation).
|
- Auth UX flows (sign-in, session-expired recovery, sign-out confirmation).
|
||||||
- Real sync orchestration layer (queue, cursor lifecycle, conflict policy UI).
|
- Conflict policy UI and merge/resolution UX for rejected remote/local mutations.
|
||||||
- Notifications/reminders delivery and scheduling integration.
|
- Notifications/reminders delivery and scheduling integration.
|
||||||
- End-to-end and integration tests for app flows beyond unit/widget basics.
|
- End-to-end and integration tests for app flows beyond unit/widget basics.
|
||||||
|
|
||||||
@@ -169,9 +197,9 @@ Validation status after UI milestone:
|
|||||||
|
|
||||||
### Phase 3: Sync + Auth UX
|
### Phase 3: Sync + Auth UX
|
||||||
|
|
||||||
1. Add sync queue service and cursor management use cases.
|
1. Add background sync scheduling/triggers (app start, manual retry, connectivity hooks).
|
||||||
2. Add conflict handling strategy and UI states.
|
2. Add conflict handling strategy and user-facing UI states for rejected mutations.
|
||||||
3. Implement sign-in/session lifecycle screens and provider state.
|
3. Add session-expired recovery UX and guarded routes for protected actions.
|
||||||
|
|
||||||
### Phase 4: Quality + Release Readiness
|
### Phase 4: Quality + Release Readiness
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ 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/sync/sync_queue_repository.dart';
|
||||||
|
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
@@ -10,6 +12,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
static const String _storageKey = 'local_data_state_v1';
|
static const String _storageKey = 'local_data_state_v1';
|
||||||
static const String _schemaVersionKey = 'local_data_schema_version';
|
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 Uuid _uuid = Uuid();
|
static const Uuid _uuid = Uuid();
|
||||||
|
|
||||||
@@ -64,6 +67,14 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
...current.people,
|
...current.people,
|
||||||
];
|
];
|
||||||
await _setState(current.copyWith(people: people));
|
await _setState(current.copyWith(people: people));
|
||||||
|
await _enqueueChange(
|
||||||
|
_buildEnvelope(
|
||||||
|
entityType: 'person',
|
||||||
|
entityId: person.id,
|
||||||
|
op: ChangeOperation.upsert,
|
||||||
|
payload: _personPayload(person),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updatePerson(PersonProfile person) async {
|
Future<void> updatePerson(PersonProfile person) async {
|
||||||
@@ -76,10 +87,28 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
.map((PersonProfile item) => item.id == person.id ? person : item)
|
.map((PersonProfile item) => item.id == person.id ? person : item)
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
await _setState(current.copyWith(people: updated));
|
await _setState(current.copyWith(people: updated));
|
||||||
|
await _enqueueChange(
|
||||||
|
_buildEnvelope(
|
||||||
|
entityType: 'person',
|
||||||
|
entityId: person.id,
|
||||||
|
op: ChangeOperation.upsert,
|
||||||
|
payload: _personPayload(person),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deletePerson(String personId) async {
|
Future<void> deletePerson(String personId) async {
|
||||||
final LocalDataState current = _requireState();
|
final LocalDataState current = _requireState();
|
||||||
|
final List<RelationshipMoment> removedMoments = current.moments
|
||||||
|
.where((RelationshipMoment moment) => moment.personId == personId)
|
||||||
|
.toList(growable: false);
|
||||||
|
final List<RelationshipIdea> removedIdeas = current.ideas
|
||||||
|
.where((RelationshipIdea idea) => idea.personId == personId)
|
||||||
|
.toList(growable: false);
|
||||||
|
final List<ReminderRule> removedReminders = current.reminders
|
||||||
|
.where((ReminderRule reminder) => reminder.personId == personId)
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
final List<PersonProfile> people = current.people
|
final List<PersonProfile> people = current.people
|
||||||
.where((PersonProfile person) => person.id != personId)
|
.where((PersonProfile person) => person.id != personId)
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
@@ -101,6 +130,35 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
reminders: reminders,
|
reminders: reminders,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await _enqueueChanges(<ChangeEnvelope>[
|
||||||
|
_buildEnvelope(
|
||||||
|
entityType: 'person',
|
||||||
|
entityId: personId,
|
||||||
|
op: ChangeOperation.delete,
|
||||||
|
),
|
||||||
|
...removedMoments.map(
|
||||||
|
(RelationshipMoment moment) => _buildEnvelope(
|
||||||
|
entityType: 'capture',
|
||||||
|
entityId: moment.id,
|
||||||
|
op: ChangeOperation.delete,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...removedIdeas.map(
|
||||||
|
(RelationshipIdea idea) => _buildEnvelope(
|
||||||
|
entityType: _ideaEntityType(idea.type),
|
||||||
|
entityId: idea.id,
|
||||||
|
op: ChangeOperation.delete,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...removedReminders.map(
|
||||||
|
(ReminderRule reminder) => _buildEnvelope(
|
||||||
|
entityType: 'reminderRule',
|
||||||
|
entityId: reminder.id,
|
||||||
|
op: ChangeOperation.delete,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> addMoment({
|
Future<void> addMoment({
|
||||||
@@ -132,6 +190,14 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
...current.moments,
|
...current.moments,
|
||||||
];
|
];
|
||||||
await _setState(current.copyWith(moments: moments));
|
await _setState(current.copyWith(moments: moments));
|
||||||
|
await _enqueueChange(
|
||||||
|
_buildEnvelope(
|
||||||
|
entityType: 'capture',
|
||||||
|
entityId: moment.id,
|
||||||
|
op: ChangeOperation.upsert,
|
||||||
|
payload: _momentPayload(moment),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateMoment(RelationshipMoment moment) async {
|
Future<void> updateMoment(RelationshipMoment moment) async {
|
||||||
@@ -144,6 +210,14 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
.map((RelationshipMoment item) => item.id == moment.id ? moment : item)
|
.map((RelationshipMoment item) => item.id == moment.id ? moment : item)
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
await _setState(current.copyWith(moments: updated));
|
await _setState(current.copyWith(moments: updated));
|
||||||
|
await _enqueueChange(
|
||||||
|
_buildEnvelope(
|
||||||
|
entityType: 'capture',
|
||||||
|
entityId: moment.id,
|
||||||
|
op: ChangeOperation.upsert,
|
||||||
|
payload: _momentPayload(moment),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteMoment(String momentId) async {
|
Future<void> deleteMoment(String momentId) async {
|
||||||
@@ -152,6 +226,13 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
.where((RelationshipMoment moment) => moment.id != momentId)
|
.where((RelationshipMoment moment) => moment.id != momentId)
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
await _setState(current.copyWith(moments: moments));
|
await _setState(current.copyWith(moments: moments));
|
||||||
|
await _enqueueChange(
|
||||||
|
_buildEnvelope(
|
||||||
|
entityType: 'capture',
|
||||||
|
entityId: momentId,
|
||||||
|
op: ChangeOperation.delete,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> addIdea({
|
Future<void> addIdea({
|
||||||
@@ -180,6 +261,14 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
...current.ideas,
|
...current.ideas,
|
||||||
];
|
];
|
||||||
await _setState(current.copyWith(ideas: ideas));
|
await _setState(current.copyWith(ideas: ideas));
|
||||||
|
await _enqueueChange(
|
||||||
|
_buildEnvelope(
|
||||||
|
entityType: _ideaEntityType(idea.type),
|
||||||
|
entityId: idea.id,
|
||||||
|
op: ChangeOperation.upsert,
|
||||||
|
payload: _ideaPayload(idea),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateIdea(RelationshipIdea idea) async {
|
Future<void> updateIdea(RelationshipIdea idea) async {
|
||||||
@@ -192,18 +281,42 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
.map((RelationshipIdea item) => item.id == idea.id ? idea : item)
|
.map((RelationshipIdea item) => item.id == idea.id ? idea : item)
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
await _setState(current.copyWith(ideas: ideas));
|
await _setState(current.copyWith(ideas: ideas));
|
||||||
|
await _enqueueChange(
|
||||||
|
_buildEnvelope(
|
||||||
|
entityType: _ideaEntityType(idea.type),
|
||||||
|
entityId: idea.id,
|
||||||
|
op: ChangeOperation.upsert,
|
||||||
|
payload: _ideaPayload(idea),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> toggleIdeaArchived(String ideaId) async {
|
Future<void> toggleIdeaArchived(String ideaId) async {
|
||||||
final LocalDataState current = _requireState();
|
final LocalDataState current = _requireState();
|
||||||
|
RelationshipIdea? updatedIdea;
|
||||||
final List<RelationshipIdea> ideas = current.ideas
|
final List<RelationshipIdea> ideas = current.ideas
|
||||||
.map(
|
.map((RelationshipIdea item) {
|
||||||
(RelationshipIdea item) => item.id == ideaId
|
if (item.id != ideaId) {
|
||||||
? item.copyWith(isArchived: !item.isArchived)
|
return item;
|
||||||
: item,
|
}
|
||||||
)
|
final RelationshipIdea toggled = item.copyWith(
|
||||||
|
isArchived: !item.isArchived,
|
||||||
|
);
|
||||||
|
updatedIdea = toggled;
|
||||||
|
return toggled;
|
||||||
|
})
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
await _setState(current.copyWith(ideas: ideas));
|
await _setState(current.copyWith(ideas: ideas));
|
||||||
|
if (updatedIdea != null) {
|
||||||
|
await _enqueueChange(
|
||||||
|
_buildEnvelope(
|
||||||
|
entityType: _ideaEntityType(updatedIdea!.type),
|
||||||
|
entityId: updatedIdea!.id,
|
||||||
|
op: ChangeOperation.upsert,
|
||||||
|
payload: _ideaPayload(updatedIdea!),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteIdea(String ideaId) async {
|
Future<void> deleteIdea(String ideaId) async {
|
||||||
@@ -212,6 +325,13 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
.where((RelationshipIdea idea) => idea.id != ideaId)
|
.where((RelationshipIdea idea) => idea.id != ideaId)
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
await _setState(current.copyWith(ideas: ideas));
|
await _setState(current.copyWith(ideas: ideas));
|
||||||
|
await _enqueueChange(
|
||||||
|
_buildEnvelope(
|
||||||
|
entityType: _ideaEntityTypeFromId(current.ideas, ideaId),
|
||||||
|
entityId: ideaId,
|
||||||
|
op: ChangeOperation.delete,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> addReminder({
|
Future<void> addReminder({
|
||||||
@@ -240,6 +360,14 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
...current.reminders,
|
...current.reminders,
|
||||||
];
|
];
|
||||||
await _setState(current.copyWith(reminders: reminders));
|
await _setState(current.copyWith(reminders: reminders));
|
||||||
|
await _enqueueChange(
|
||||||
|
_buildEnvelope(
|
||||||
|
entityType: 'reminderRule',
|
||||||
|
entityId: reminder.id,
|
||||||
|
op: ChangeOperation.upsert,
|
||||||
|
payload: _reminderPayload(reminder),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateReminder(ReminderRule reminder) async {
|
Future<void> updateReminder(ReminderRule reminder) async {
|
||||||
@@ -252,18 +380,40 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
.map((ReminderRule item) => item.id == reminder.id ? reminder : item)
|
.map((ReminderRule item) => item.id == reminder.id ? reminder : item)
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
await _setState(current.copyWith(reminders: reminders));
|
await _setState(current.copyWith(reminders: reminders));
|
||||||
|
await _enqueueChange(
|
||||||
|
_buildEnvelope(
|
||||||
|
entityType: 'reminderRule',
|
||||||
|
entityId: reminder.id,
|
||||||
|
op: ChangeOperation.upsert,
|
||||||
|
payload: _reminderPayload(reminder),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> toggleReminderEnabled(String reminderId) async {
|
Future<void> toggleReminderEnabled(String reminderId) async {
|
||||||
final LocalDataState current = _requireState();
|
final LocalDataState current = _requireState();
|
||||||
|
ReminderRule? updatedReminder;
|
||||||
final List<ReminderRule> reminders = current.reminders
|
final List<ReminderRule> reminders = current.reminders
|
||||||
.map(
|
.map((ReminderRule item) {
|
||||||
(ReminderRule item) => item.id == reminderId
|
if (item.id != reminderId) {
|
||||||
? item.copyWith(enabled: !item.enabled)
|
return item;
|
||||||
: item,
|
}
|
||||||
)
|
final ReminderRule toggled = item.copyWith(enabled: !item.enabled);
|
||||||
|
updatedReminder = toggled;
|
||||||
|
return toggled;
|
||||||
|
})
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
await _setState(current.copyWith(reminders: reminders));
|
await _setState(current.copyWith(reminders: reminders));
|
||||||
|
if (updatedReminder != null) {
|
||||||
|
await _enqueueChange(
|
||||||
|
_buildEnvelope(
|
||||||
|
entityType: 'reminderRule',
|
||||||
|
entityId: updatedReminder!.id,
|
||||||
|
op: ChangeOperation.upsert,
|
||||||
|
payload: _reminderPayload(updatedReminder!),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteReminder(String reminderId) async {
|
Future<void> deleteReminder(String reminderId) async {
|
||||||
@@ -272,6 +422,13 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
.where((ReminderRule reminder) => reminder.id != reminderId)
|
.where((ReminderRule reminder) => reminder.id != reminderId)
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
await _setState(current.copyWith(reminders: reminders));
|
await _setState(current.copyWith(reminders: reminders));
|
||||||
|
await _enqueueChange(
|
||||||
|
_buildEnvelope(
|
||||||
|
entityType: 'reminderRule',
|
||||||
|
entityId: reminderId,
|
||||||
|
op: ChangeOperation.delete,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> toggleTaskDone(String taskId) async {
|
Future<void> toggleTaskDone(String taskId) async {
|
||||||
@@ -286,6 +443,431 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
await _setState(current.copyWith(tasks: tasks));
|
await _setState(current.copyWith(tasks: tasks));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> applyRemoteChanges(List<ChangeEnvelope> changes) async {
|
||||||
|
if (changes.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDataState next = _requireState();
|
||||||
|
for (final ChangeEnvelope change in changes) {
|
||||||
|
next = _applyRemoteChange(next, change);
|
||||||
|
}
|
||||||
|
await _setState(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDataState _applyRemoteChange(
|
||||||
|
LocalDataState current,
|
||||||
|
ChangeEnvelope change,
|
||||||
|
) {
|
||||||
|
switch (change.entityType) {
|
||||||
|
case 'person':
|
||||||
|
return _applyPersonChange(current, change);
|
||||||
|
case 'capture':
|
||||||
|
return _applyMomentChange(current, change);
|
||||||
|
case 'giftIdea':
|
||||||
|
case 'eventIdea':
|
||||||
|
return _applyIdeaChange(current, change);
|
||||||
|
case 'reminderRule':
|
||||||
|
return _applyReminderChange(current, change);
|
||||||
|
default:
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDataState _applyPersonChange(
|
||||||
|
LocalDataState current,
|
||||||
|
ChangeEnvelope change,
|
||||||
|
) {
|
||||||
|
if (change.op == ChangeOperation.delete) {
|
||||||
|
final String personId = change.entityId;
|
||||||
|
return current.copyWith(
|
||||||
|
people: current.people
|
||||||
|
.where((PersonProfile person) => person.id != personId)
|
||||||
|
.toList(growable: false),
|
||||||
|
moments: current.moments
|
||||||
|
.where((RelationshipMoment moment) => moment.personId != personId)
|
||||||
|
.toList(growable: false),
|
||||||
|
ideas: current.ideas
|
||||||
|
.where((RelationshipIdea idea) => idea.personId != personId)
|
||||||
|
.toList(growable: false),
|
||||||
|
reminders: current.reminders
|
||||||
|
.where((ReminderRule reminder) => reminder.personId != personId)
|
||||||
|
.toList(growable: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int existingIndex = current.people.indexWhere(
|
||||||
|
(PersonProfile person) => person.id == change.entityId,
|
||||||
|
);
|
||||||
|
final PersonProfile? existing = existingIndex >= 0
|
||||||
|
? current.people[existingIndex]
|
||||||
|
: null;
|
||||||
|
final Map<String, dynamic> payload = change.payload ?? <String, dynamic>{};
|
||||||
|
final PersonProfile next = PersonProfile(
|
||||||
|
id: change.entityId,
|
||||||
|
name: _stringField(payload, 'name', existing?.name ?? 'Unknown'),
|
||||||
|
relationship: _stringField(
|
||||||
|
payload,
|
||||||
|
'relationship',
|
||||||
|
existing?.relationship ?? 'Relationship',
|
||||||
|
),
|
||||||
|
affinityScore: _intField(
|
||||||
|
payload,
|
||||||
|
'affinityScore',
|
||||||
|
existing?.affinityScore ?? 70,
|
||||||
|
),
|
||||||
|
nextMoment: _dateField(
|
||||||
|
payload,
|
||||||
|
'nextMoment',
|
||||||
|
existing?.nextMoment ?? DateTime.now().add(const Duration(days: 3)),
|
||||||
|
),
|
||||||
|
tags: _stringListField(payload, 'tags', existing?.tags ?? <String>[]),
|
||||||
|
notes: _stringField(payload, 'notes', existing?.notes ?? ''),
|
||||||
|
);
|
||||||
|
final List<PersonProfile> people = _upsertItem(
|
||||||
|
items: current.people,
|
||||||
|
item: next,
|
||||||
|
idOf: (PersonProfile person) => person.id,
|
||||||
|
);
|
||||||
|
return current.copyWith(people: people);
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDataState _applyMomentChange(
|
||||||
|
LocalDataState current,
|
||||||
|
ChangeEnvelope change,
|
||||||
|
) {
|
||||||
|
if (change.op == ChangeOperation.delete) {
|
||||||
|
return current.copyWith(
|
||||||
|
moments: current.moments
|
||||||
|
.where((RelationshipMoment moment) => moment.id != change.entityId)
|
||||||
|
.toList(growable: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int existingIndex = current.moments.indexWhere(
|
||||||
|
(RelationshipMoment moment) => moment.id == change.entityId,
|
||||||
|
);
|
||||||
|
final RelationshipMoment? existing = existingIndex >= 0
|
||||||
|
? current.moments[existingIndex]
|
||||||
|
: null;
|
||||||
|
final Map<String, dynamic> payload = change.payload ?? <String, dynamic>{};
|
||||||
|
final String summary = _stringField(
|
||||||
|
payload,
|
||||||
|
'summary',
|
||||||
|
existing?.summary ?? '',
|
||||||
|
);
|
||||||
|
final RelationshipMoment next = RelationshipMoment(
|
||||||
|
id: change.entityId,
|
||||||
|
personId: _stringField(
|
||||||
|
payload,
|
||||||
|
'personId',
|
||||||
|
existing?.personId ??
|
||||||
|
(current.people.isEmpty
|
||||||
|
? 'person-unknown'
|
||||||
|
: current.people.first.id),
|
||||||
|
),
|
||||||
|
title: _stringField(
|
||||||
|
payload,
|
||||||
|
'title',
|
||||||
|
existing?.title ?? _titleFromSummary(summary),
|
||||||
|
),
|
||||||
|
summary: summary,
|
||||||
|
at: _dateField(payload, 'at', existing?.at ?? DateTime.now()),
|
||||||
|
type: _stringField(payload, 'type', existing?.type ?? 'capture'),
|
||||||
|
);
|
||||||
|
final List<RelationshipMoment> moments = _upsertItem(
|
||||||
|
items: current.moments,
|
||||||
|
item: next,
|
||||||
|
idOf: (RelationshipMoment moment) => moment.id,
|
||||||
|
);
|
||||||
|
return current.copyWith(moments: moments);
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDataState _applyIdeaChange(
|
||||||
|
LocalDataState current,
|
||||||
|
ChangeEnvelope change,
|
||||||
|
) {
|
||||||
|
if (change.op == ChangeOperation.delete) {
|
||||||
|
return current.copyWith(
|
||||||
|
ideas: current.ideas
|
||||||
|
.where((RelationshipIdea idea) => idea.id != change.entityId)
|
||||||
|
.toList(growable: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int existingIndex = current.ideas.indexWhere(
|
||||||
|
(RelationshipIdea idea) => idea.id == change.entityId,
|
||||||
|
);
|
||||||
|
final RelationshipIdea? existing = existingIndex >= 0
|
||||||
|
? current.ideas[existingIndex]
|
||||||
|
: null;
|
||||||
|
final Map<String, dynamic> payload = change.payload ?? <String, dynamic>{};
|
||||||
|
final IdeaType fallbackType = _ideaTypeFromEntityType(
|
||||||
|
change.entityType,
|
||||||
|
fallback: existing?.type ?? IdeaType.gift,
|
||||||
|
);
|
||||||
|
final IdeaType type = _ideaTypeFromRaw(
|
||||||
|
payload['type'],
|
||||||
|
fallback: fallbackType,
|
||||||
|
);
|
||||||
|
final RelationshipIdea next = RelationshipIdea(
|
||||||
|
id: change.entityId,
|
||||||
|
personId: payload['personId'] as String? ?? existing?.personId,
|
||||||
|
type: type,
|
||||||
|
title: _stringField(payload, 'title', existing?.title ?? 'Untitled idea'),
|
||||||
|
details: _stringField(payload, 'details', existing?.details ?? ''),
|
||||||
|
createdAt: _dateField(
|
||||||
|
payload,
|
||||||
|
'createdAt',
|
||||||
|
existing?.createdAt ?? DateTime.now(),
|
||||||
|
),
|
||||||
|
isArchived: _boolField(
|
||||||
|
payload,
|
||||||
|
'isArchived',
|
||||||
|
existing?.isArchived ?? false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final List<RelationshipIdea> ideas = _upsertItem(
|
||||||
|
items: current.ideas,
|
||||||
|
item: next,
|
||||||
|
idOf: (RelationshipIdea idea) => idea.id,
|
||||||
|
);
|
||||||
|
return current.copyWith(ideas: ideas);
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDataState _applyReminderChange(
|
||||||
|
LocalDataState current,
|
||||||
|
ChangeEnvelope change,
|
||||||
|
) {
|
||||||
|
if (change.op == ChangeOperation.delete) {
|
||||||
|
return current.copyWith(
|
||||||
|
reminders: current.reminders
|
||||||
|
.where((ReminderRule reminder) => reminder.id != change.entityId)
|
||||||
|
.toList(growable: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int existingIndex = current.reminders.indexWhere(
|
||||||
|
(ReminderRule reminder) => reminder.id == change.entityId,
|
||||||
|
);
|
||||||
|
final ReminderRule? existing = existingIndex >= 0
|
||||||
|
? current.reminders[existingIndex]
|
||||||
|
: null;
|
||||||
|
final Map<String, dynamic> payload = change.payload ?? <String, dynamic>{};
|
||||||
|
final ReminderRule next = ReminderRule(
|
||||||
|
id: change.entityId,
|
||||||
|
personId: payload['personId'] as String? ?? existing?.personId,
|
||||||
|
title: _stringField(payload, 'title', existing?.title ?? 'Reminder'),
|
||||||
|
cadence: _cadenceFromRaw(
|
||||||
|
payload['cadence'],
|
||||||
|
fallback: existing?.cadence ?? ReminderCadence.weekly,
|
||||||
|
),
|
||||||
|
nextAt: _dateField(payload, 'nextAt', existing?.nextAt ?? DateTime.now()),
|
||||||
|
enabled: _boolField(payload, 'enabled', existing?.enabled ?? true),
|
||||||
|
);
|
||||||
|
final List<ReminderRule> reminders = _upsertItem(
|
||||||
|
items: current.reminders,
|
||||||
|
item: next,
|
||||||
|
idOf: (ReminderRule reminder) => reminder.id,
|
||||||
|
);
|
||||||
|
return current.copyWith(reminders: reminders);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _enqueueChange(ChangeEnvelope change) async {
|
||||||
|
await ref.read(syncQueueRepositoryProvider.notifier).enqueue(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _enqueueChanges(List<ChangeEnvelope> changes) async {
|
||||||
|
await ref.read(syncQueueRepositoryProvider.notifier).enqueueAll(changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
ChangeEnvelope _buildEnvelope({
|
||||||
|
required String entityType,
|
||||||
|
required String entityId,
|
||||||
|
required ChangeOperation op,
|
||||||
|
Map<String, dynamic>? payload,
|
||||||
|
}) {
|
||||||
|
return ChangeEnvelope(
|
||||||
|
schemaVersion: _syncSchemaVersion,
|
||||||
|
entityType: entityType,
|
||||||
|
entityId: entityId,
|
||||||
|
op: op,
|
||||||
|
modifiedAt: DateTime.now().toUtc(),
|
||||||
|
clientMutationId: _uuid.v4(),
|
||||||
|
payload: payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _personPayload(PersonProfile person) {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'name': person.name,
|
||||||
|
'relationship': person.relationship,
|
||||||
|
'affinityScore': person.affinityScore,
|
||||||
|
'nextMoment': person.nextMoment.toUtc().toIso8601String(),
|
||||||
|
'tags': person.tags,
|
||||||
|
'notes': person.notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _momentPayload(RelationshipMoment moment) {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'personId': moment.personId,
|
||||||
|
'title': moment.title,
|
||||||
|
'summary': moment.summary,
|
||||||
|
'at': moment.at.toUtc().toIso8601String(),
|
||||||
|
'type': moment.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _ideaPayload(RelationshipIdea idea) {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'personId': idea.personId,
|
||||||
|
'type': idea.type.name,
|
||||||
|
'title': idea.title,
|
||||||
|
'details': idea.details,
|
||||||
|
'createdAt': idea.createdAt.toUtc().toIso8601String(),
|
||||||
|
'isArchived': idea.isArchived,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _reminderPayload(ReminderRule reminder) {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'personId': reminder.personId,
|
||||||
|
'title': reminder.title,
|
||||||
|
'cadence': reminder.cadence.name,
|
||||||
|
'nextAt': reminder.nextAt.toUtc().toIso8601String(),
|
||||||
|
'enabled': reminder.enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String _ideaEntityType(IdeaType type) {
|
||||||
|
return type == IdeaType.event ? 'eventIdea' : 'giftIdea';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _ideaEntityTypeFromId(List<RelationshipIdea> ideas, String ideaId) {
|
||||||
|
final int index = ideas.indexWhere(
|
||||||
|
(RelationshipIdea idea) => idea.id == ideaId,
|
||||||
|
);
|
||||||
|
final RelationshipIdea? existing = index >= 0 ? ideas[index] : null;
|
||||||
|
if (existing == null) {
|
||||||
|
return 'giftIdea';
|
||||||
|
}
|
||||||
|
return _ideaEntityType(existing.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
IdeaType _ideaTypeFromEntityType(
|
||||||
|
String entityType, {
|
||||||
|
required IdeaType fallback,
|
||||||
|
}) {
|
||||||
|
switch (entityType) {
|
||||||
|
case 'eventIdea':
|
||||||
|
return IdeaType.event;
|
||||||
|
case 'giftIdea':
|
||||||
|
return IdeaType.gift;
|
||||||
|
default:
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IdeaType _ideaTypeFromRaw(dynamic value, {required IdeaType fallback}) {
|
||||||
|
if (value is! String || value.isEmpty) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return IdeaType.values.firstWhere(
|
||||||
|
(IdeaType type) => type.name == value,
|
||||||
|
orElse: () => fallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReminderCadence _cadenceFromRaw(
|
||||||
|
dynamic value, {
|
||||||
|
required ReminderCadence fallback,
|
||||||
|
}) {
|
||||||
|
if (value is! String || value.isEmpty) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ReminderCadence.values.firstWhere(
|
||||||
|
(ReminderCadence cadence) => cadence.name == value,
|
||||||
|
orElse: () => fallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<T> _upsertItem<T>({
|
||||||
|
required List<T> items,
|
||||||
|
required T item,
|
||||||
|
required String Function(T value) idOf,
|
||||||
|
}) {
|
||||||
|
final int index = items.indexWhere((T value) => idOf(value) == idOf(item));
|
||||||
|
if (index < 0) {
|
||||||
|
return <T>[item, ...items];
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<T> copy = items.toList();
|
||||||
|
copy[index] = item;
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _stringField(
|
||||||
|
Map<String, dynamic> payload,
|
||||||
|
String key,
|
||||||
|
String fallback,
|
||||||
|
) {
|
||||||
|
final dynamic value = payload[key];
|
||||||
|
if (value is String && value.trim().isNotEmpty) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
int _intField(Map<String, dynamic> payload, String key, int fallback) {
|
||||||
|
final dynamic value = payload[key];
|
||||||
|
if (value is int) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value is num) {
|
||||||
|
return value.toInt();
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _boolField(Map<String, dynamic> payload, String key, bool fallback) {
|
||||||
|
final dynamic value = payload[key];
|
||||||
|
if (value is bool) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _stringListField(
|
||||||
|
Map<String, dynamic> payload,
|
||||||
|
String key,
|
||||||
|
List<String> fallback,
|
||||||
|
) {
|
||||||
|
final dynamic value = payload[key];
|
||||||
|
if (value is! List<dynamic>) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.map((dynamic item) => '$item').toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime _dateField(
|
||||||
|
Map<String, dynamic> payload,
|
||||||
|
String key,
|
||||||
|
DateTime fallback,
|
||||||
|
) {
|
||||||
|
final dynamic value = payload[key];
|
||||||
|
if (value is String) {
|
||||||
|
final DateTime? parsed = DateTime.tryParse(value);
|
||||||
|
if (parsed != null) {
|
||||||
|
return parsed.toLocal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _setState(LocalDataState next) async {
|
Future<void> _setState(LocalDataState next) async {
|
||||||
state = AsyncData<LocalDataState>(next);
|
state = AsyncData<LocalDataState>(next);
|
||||||
await _persist(next);
|
await _persist(next);
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:relationship_saver/features/local/local_repository.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/backend_gateway_provider.dart';
|
||||||
|
|
||||||
|
/// Result details for one sync action.
|
||||||
|
@immutable
|
||||||
|
class SyncRunResult {
|
||||||
|
const SyncRunResult({
|
||||||
|
required this.success,
|
||||||
|
required this.operation,
|
||||||
|
required this.message,
|
||||||
|
required this.pendingAfter,
|
||||||
|
this.cursor,
|
||||||
|
this.accepted = 0,
|
||||||
|
this.rejected = 0,
|
||||||
|
this.pulled = 0,
|
||||||
|
this.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool success;
|
||||||
|
final String operation;
|
||||||
|
final String message;
|
||||||
|
final int pendingAfter;
|
||||||
|
final String? cursor;
|
||||||
|
final int accepted;
|
||||||
|
final int rejected;
|
||||||
|
final int pulled;
|
||||||
|
final Object? error;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coordinates push/pull sync operations across queue, gateway, and local store.
|
||||||
|
class SyncCoordinator {
|
||||||
|
const SyncCoordinator(this._ref);
|
||||||
|
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
Future<SyncRunResult> pushPending() async {
|
||||||
|
final SyncState queueState = await _ref.read(
|
||||||
|
syncQueueRepositoryProvider.future,
|
||||||
|
);
|
||||||
|
if (queueState.pendingChanges.isEmpty) {
|
||||||
|
return SyncRunResult(
|
||||||
|
success: true,
|
||||||
|
operation: 'push',
|
||||||
|
message: 'No pending local changes to push.',
|
||||||
|
pendingAfter: 0,
|
||||||
|
cursor: queueState.cursor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final DateTime now = DateTime.now();
|
||||||
|
final syncQueue = _ref.read(syncQueueRepositoryProvider.notifier);
|
||||||
|
try {
|
||||||
|
final result = await _ref
|
||||||
|
.read(backendGatewayProvider)
|
||||||
|
.pushChanges(
|
||||||
|
changes: queueState.pendingChanges,
|
||||||
|
cursor: queueState.cursor,
|
||||||
|
);
|
||||||
|
await syncQueue.applyPushResult(result, at: now);
|
||||||
|
final SyncState after = await _ref.read(
|
||||||
|
syncQueueRepositoryProvider.future,
|
||||||
|
);
|
||||||
|
return SyncRunResult(
|
||||||
|
success: true,
|
||||||
|
operation: 'push',
|
||||||
|
message:
|
||||||
|
'Push complete. accepted=${result.accepted.length}, rejected=${result.rejected.length}.',
|
||||||
|
accepted: result.accepted.length,
|
||||||
|
rejected: result.rejected.length,
|
||||||
|
pendingAfter: after.pendingChanges.length,
|
||||||
|
cursor: after.cursor,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
await syncQueue.markFailure(error, at: now);
|
||||||
|
final SyncState after = await _ref.read(
|
||||||
|
syncQueueRepositoryProvider.future,
|
||||||
|
);
|
||||||
|
return SyncRunResult(
|
||||||
|
success: false,
|
||||||
|
operation: 'push',
|
||||||
|
message: 'Push failed. Local changes stay queued.',
|
||||||
|
pendingAfter: after.pendingChanges.length,
|
||||||
|
cursor: after.cursor,
|
||||||
|
error: error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SyncRunResult> pullRemote() async {
|
||||||
|
final SyncState queueState = await _ref.read(
|
||||||
|
syncQueueRepositoryProvider.future,
|
||||||
|
);
|
||||||
|
final DateTime now = DateTime.now();
|
||||||
|
final syncQueue = _ref.read(syncQueueRepositoryProvider.notifier);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _ref
|
||||||
|
.read(backendGatewayProvider)
|
||||||
|
.pullChanges(cursor: queueState.cursor);
|
||||||
|
await _ref
|
||||||
|
.read(localRepositoryProvider.notifier)
|
||||||
|
.applyRemoteChanges(result.changes);
|
||||||
|
await syncQueue.applyPullResult(result, at: now);
|
||||||
|
final SyncState after = await _ref.read(
|
||||||
|
syncQueueRepositoryProvider.future,
|
||||||
|
);
|
||||||
|
return SyncRunResult(
|
||||||
|
success: true,
|
||||||
|
operation: 'pull',
|
||||||
|
message: 'Pulled ${result.changes.length} change(s) from backend.',
|
||||||
|
pulled: result.changes.length,
|
||||||
|
pendingAfter: after.pendingChanges.length,
|
||||||
|
cursor: after.cursor,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
await syncQueue.markFailure(error, at: now);
|
||||||
|
final SyncState after = await _ref.read(
|
||||||
|
syncQueueRepositoryProvider.future,
|
||||||
|
);
|
||||||
|
return SyncRunResult(
|
||||||
|
success: false,
|
||||||
|
operation: 'pull',
|
||||||
|
message: 'Pull failed. Staying in local-first mode.',
|
||||||
|
pendingAfter: after.pendingChanges.length,
|
||||||
|
cursor: after.cursor,
|
||||||
|
error: error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SyncRunResult> syncNow() async {
|
||||||
|
final SyncRunResult pushResult = await pushPending();
|
||||||
|
if (!pushResult.success) {
|
||||||
|
return SyncRunResult(
|
||||||
|
success: false,
|
||||||
|
operation: 'sync',
|
||||||
|
message: 'Sync paused at push step. ${pushResult.message}',
|
||||||
|
pendingAfter: pushResult.pendingAfter,
|
||||||
|
cursor: pushResult.cursor,
|
||||||
|
accepted: pushResult.accepted,
|
||||||
|
rejected: pushResult.rejected,
|
||||||
|
error: pushResult.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final SyncRunResult pullResult = await pullRemote();
|
||||||
|
if (!pullResult.success) {
|
||||||
|
return SyncRunResult(
|
||||||
|
success: false,
|
||||||
|
operation: 'sync',
|
||||||
|
message: 'Sync paused at pull step. ${pullResult.message}',
|
||||||
|
pendingAfter: pullResult.pendingAfter,
|
||||||
|
cursor: pullResult.cursor,
|
||||||
|
accepted: pushResult.accepted,
|
||||||
|
rejected: pushResult.rejected,
|
||||||
|
error: pullResult.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SyncRunResult(
|
||||||
|
success: true,
|
||||||
|
operation: 'sync',
|
||||||
|
message:
|
||||||
|
'Sync complete. accepted=${pushResult.accepted}, rejected=${pushResult.rejected}, pulled=${pullResult.pulled}.',
|
||||||
|
pendingAfter: pullResult.pendingAfter,
|
||||||
|
cursor: pullResult.cursor,
|
||||||
|
accepted: pushResult.accepted,
|
||||||
|
rejected: pushResult.rejected,
|
||||||
|
pulled: pullResult.pulled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final Provider<SyncCoordinator> syncCoordinatorProvider =
|
||||||
|
Provider<SyncCoordinator>(SyncCoordinator.new);
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:relationship_saver/features/sync/sync_state.dart';
|
||||||
|
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
/// Persists queued local mutations and sync cursor metadata.
|
||||||
|
class SyncQueueRepository extends AsyncNotifier<SyncState> {
|
||||||
|
static const String _storageKey = 'sync_queue_state_v1';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SyncState> build() async {
|
||||||
|
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||||
|
final String? raw = prefs.getString(_storageKey);
|
||||||
|
if (raw == null || raw.isEmpty) {
|
||||||
|
return SyncState.empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final Map<String, dynamic> json = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
|
return SyncState.fromJson(json);
|
||||||
|
} on FormatException {
|
||||||
|
await prefs.remove(_storageKey);
|
||||||
|
return SyncState.empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> enqueue(ChangeEnvelope change) async {
|
||||||
|
final SyncState current = await _currentState();
|
||||||
|
final List<ChangeEnvelope> pending = <ChangeEnvelope>[
|
||||||
|
change,
|
||||||
|
...current.pendingChanges,
|
||||||
|
];
|
||||||
|
await _setState(current.copyWith(pendingChanges: pending));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> enqueueAll(Iterable<ChangeEnvelope> changes) async {
|
||||||
|
final List<ChangeEnvelope> values = changes.toList(growable: false);
|
||||||
|
if (values.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final SyncState current = await _currentState();
|
||||||
|
final List<ChangeEnvelope> pending = <ChangeEnvelope>[
|
||||||
|
...values,
|
||||||
|
...current.pendingChanges,
|
||||||
|
];
|
||||||
|
await _setState(current.copyWith(pendingChanges: pending));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> applyPushResult(SyncPushResult result, {DateTime? at}) async {
|
||||||
|
final SyncState current = await _currentState();
|
||||||
|
final DateTime now = at ?? DateTime.now();
|
||||||
|
final Set<String> completedMutationIds = <String>{
|
||||||
|
...result.accepted.map((MutationAck ack) => ack.clientMutationId),
|
||||||
|
...result.rejected.map(
|
||||||
|
(MutationRejection rejection) => rejection.clientMutationId,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
final List<ChangeEnvelope> pending = current.pendingChanges
|
||||||
|
.where(
|
||||||
|
(ChangeEnvelope change) =>
|
||||||
|
!completedMutationIds.contains(change.clientMutationId),
|
||||||
|
)
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
final String? rejectionMessage = result.rejected.isEmpty
|
||||||
|
? null
|
||||||
|
: 'Push rejected ${result.rejected.length} change(s).';
|
||||||
|
|
||||||
|
await _setState(
|
||||||
|
current.copyWith(
|
||||||
|
cursor: result.cursor,
|
||||||
|
pendingChanges: pending,
|
||||||
|
lastAttemptAt: now,
|
||||||
|
lastSyncAt: now,
|
||||||
|
lastError: rejectionMessage,
|
||||||
|
lastRejected: result.rejected,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> applyPullResult(SyncPullResult result, {DateTime? at}) async {
|
||||||
|
final SyncState current = await _currentState();
|
||||||
|
final DateTime now = at ?? DateTime.now();
|
||||||
|
await _setState(
|
||||||
|
current.copyWith(
|
||||||
|
cursor: result.cursor,
|
||||||
|
lastAttemptAt: now,
|
||||||
|
lastSyncAt: now,
|
||||||
|
lastError: null,
|
||||||
|
lastRejected: const <MutationRejection>[],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markFailure(Object error, {DateTime? at}) async {
|
||||||
|
final SyncState current = await _currentState();
|
||||||
|
final DateTime now = at ?? DateTime.now();
|
||||||
|
await _setState(current.copyWith(lastAttemptAt: now, lastError: '$error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearQueue() async {
|
||||||
|
final SyncState current = await _currentState();
|
||||||
|
await _setState(
|
||||||
|
current.copyWith(
|
||||||
|
pendingChanges: const <ChangeEnvelope>[],
|
||||||
|
lastRejected: const <MutationRejection>[],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SyncState> _currentState() async {
|
||||||
|
final SyncState? value = state.asData?.value;
|
||||||
|
if (value != null) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return future;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setState(SyncState next) async {
|
||||||
|
state = AsyncData<SyncState>(next);
|
||||||
|
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_storageKey, jsonEncode(next.toJson()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final AsyncNotifierProvider<SyncQueueRepository, SyncState>
|
||||||
|
syncQueueRepositoryProvider =
|
||||||
|
AsyncNotifierProvider<SyncQueueRepository, SyncState>(
|
||||||
|
SyncQueueRepository.new,
|
||||||
|
);
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||||
|
|
||||||
|
/// Persisted state for sync queue and cursor progression.
|
||||||
|
@immutable
|
||||||
|
class SyncState {
|
||||||
|
const SyncState({
|
||||||
|
this.cursor,
|
||||||
|
required this.pendingChanges,
|
||||||
|
this.lastSyncAt,
|
||||||
|
this.lastAttemptAt,
|
||||||
|
this.lastError,
|
||||||
|
this.lastRejected = const <MutationRejection>[],
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SyncState.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SyncState(
|
||||||
|
cursor: json['cursor'] as String?,
|
||||||
|
pendingChanges: (json['pendingChanges'] as List<dynamic>? ?? <dynamic>[])
|
||||||
|
.map(
|
||||||
|
(dynamic item) =>
|
||||||
|
ChangeEnvelope.fromJson(item as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
.toList(growable: false),
|
||||||
|
lastSyncAt: _tryParseDate(json['lastSyncAt'] as String?),
|
||||||
|
lastAttemptAt: _tryParseDate(json['lastAttemptAt'] as String?),
|
||||||
|
lastError: json['lastError'] as String?,
|
||||||
|
lastRejected: (json['lastRejected'] as List<dynamic>? ?? <dynamic>[])
|
||||||
|
.map(
|
||||||
|
(dynamic item) =>
|
||||||
|
MutationRejection.fromJson(item as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
.toList(growable: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String? cursor;
|
||||||
|
final List<ChangeEnvelope> pendingChanges;
|
||||||
|
final DateTime? lastSyncAt;
|
||||||
|
final DateTime? lastAttemptAt;
|
||||||
|
final String? lastError;
|
||||||
|
final List<MutationRejection> lastRejected;
|
||||||
|
|
||||||
|
static const Object _unset = Object();
|
||||||
|
|
||||||
|
static const SyncState empty = SyncState(
|
||||||
|
pendingChanges: <ChangeEnvelope>[],
|
||||||
|
lastRejected: <MutationRejection>[],
|
||||||
|
);
|
||||||
|
|
||||||
|
SyncState copyWith({
|
||||||
|
Object? cursor = _unset,
|
||||||
|
List<ChangeEnvelope>? pendingChanges,
|
||||||
|
Object? lastSyncAt = _unset,
|
||||||
|
Object? lastAttemptAt = _unset,
|
||||||
|
Object? lastError = _unset,
|
||||||
|
List<MutationRejection>? lastRejected,
|
||||||
|
}) {
|
||||||
|
return SyncState(
|
||||||
|
cursor: identical(cursor, _unset) ? this.cursor : cursor as String?,
|
||||||
|
pendingChanges: pendingChanges ?? this.pendingChanges,
|
||||||
|
lastSyncAt: identical(lastSyncAt, _unset)
|
||||||
|
? this.lastSyncAt
|
||||||
|
: lastSyncAt as DateTime?,
|
||||||
|
lastAttemptAt: identical(lastAttemptAt, _unset)
|
||||||
|
? this.lastAttemptAt
|
||||||
|
: lastAttemptAt as DateTime?,
|
||||||
|
lastError: identical(lastError, _unset)
|
||||||
|
? this.lastError
|
||||||
|
: lastError as String?,
|
||||||
|
lastRejected: lastRejected ?? this.lastRejected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'cursor': cursor,
|
||||||
|
'pendingChanges': pendingChanges
|
||||||
|
.map((ChangeEnvelope change) => change.toJson())
|
||||||
|
.toList(growable: false),
|
||||||
|
'lastSyncAt': lastSyncAt?.toUtc().toIso8601String(),
|
||||||
|
'lastAttemptAt': lastAttemptAt?.toUtc().toIso8601String(),
|
||||||
|
'lastError': lastError,
|
||||||
|
'lastRejected': lastRejected
|
||||||
|
.map((MutationRejection rejection) => rejection.toJson())
|
||||||
|
.toList(growable: false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime? _tryParseDate(String? raw) {
|
||||||
|
if (raw == null || raw.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final DateTime? parsed = DateTime.tryParse(raw);
|
||||||
|
return parsed?.toLocal();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||||
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
||||||
import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart';
|
import 'package:relationship_saver/features/sync/sync_coordinator.dart';
|
||||||
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
import 'package:relationship_saver/features/sync/sync_queue_repository.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:relationship_saver/features/sync/sync_state.dart';
|
||||||
|
|
||||||
class SyncView extends ConsumerStatefulWidget {
|
class SyncView extends ConsumerStatefulWidget {
|
||||||
const SyncView({super.key});
|
const SyncView({super.key});
|
||||||
@@ -14,14 +14,15 @@ class SyncView extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SyncViewState extends ConsumerState<SyncView> {
|
class _SyncViewState extends ConsumerState<SyncView> {
|
||||||
static const Uuid _uuid = Uuid();
|
|
||||||
|
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
String _status = 'Ready. Local-first mode keeps app usable without sync.';
|
String _status = 'Ready. Local-first mode keeps app usable without sync.';
|
||||||
String? _cursor;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final AsyncValue<SyncState> syncState = ref.watch(
|
||||||
|
syncQueueRepositoryProvider,
|
||||||
|
);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
|
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -30,52 +31,26 @@ class _SyncViewState extends ConsumerState<SyncView> {
|
|||||||
Text('Sync', style: Theme.of(context).textTheme.headlineMedium),
|
Text('Sync', style: Theme.of(context).textTheme.headlineMedium),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
'Push local changes and pull remote updates when connectivity is available.',
|
'Queue local changes, then push and pull when network is available.',
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
|
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
FrostedCard(
|
Expanded(
|
||||||
child: Column(
|
child: syncState.when(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
data: (SyncState state) => _buildBody(context, state),
|
||||||
children: <Widget>[
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
Wrap(
|
error: (Object error, StackTrace stackTrace) {
|
||||||
spacing: 10,
|
return Center(
|
||||||
runSpacing: 10,
|
child: Text(
|
||||||
children: <Widget>[
|
'Unable to load sync state. $error',
|
||||||
FilledButton.icon(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
onPressed: _busy ? null : _pushSample,
|
color: AppTheme.textSecondary,
|
||||||
icon: const Icon(Icons.upload_rounded),
|
|
||||||
label: const Text('Push Sample Changes'),
|
|
||||||
),
|
),
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: _busy ? null : _pullChanges,
|
|
||||||
icon: const Icon(Icons.download_rounded),
|
|
||||||
label: const Text('Pull Changes'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Cursor: ${_cursor ?? 'not set'}',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
_status,
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
||||||
color: AppTheme.textSecondary,
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
],
|
},
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
FrostedCard(
|
|
||||||
child: Text(
|
|
||||||
'Sync policy\n• Core usage never blocks on backend\n• Retries only for transient failures\n• Non-idempotent requests require idempotency key',
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -83,71 +58,168 @@ class _SyncViewState extends ConsumerState<SyncView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildBody(BuildContext context, SyncState state) {
|
||||||
|
return ListView(
|
||||||
|
children: <Widget>[
|
||||||
|
FrostedCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Wrap(
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 10,
|
||||||
|
children: <Widget>[
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _busy ? null : _syncNow,
|
||||||
|
icon: const Icon(Icons.sync_rounded),
|
||||||
|
label: const Text('Sync Now'),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _busy ? null : _pushPending,
|
||||||
|
icon: const Icon(Icons.upload_rounded),
|
||||||
|
label: const Text('Push Pending'),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _busy ? null : _pullChanges,
|
||||||
|
icon: const Icon(Icons.download_rounded),
|
||||||
|
label: const Text('Pull Changes'),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _busy ? null : _clearQueue,
|
||||||
|
icon: const Icon(Icons.clear_all_rounded),
|
||||||
|
label: const Text('Clear Queue'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_status,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FrostedCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
'Queue Status',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_infoRow('Pending changes', '${state.pendingChanges.length}'),
|
||||||
|
_infoRow('Cursor', state.cursor ?? 'not set'),
|
||||||
|
_infoRow('Last sync', _formatDateTime(state.lastSyncAt)),
|
||||||
|
_infoRow('Last attempt', _formatDateTime(state.lastAttemptAt)),
|
||||||
|
_infoRow('Last rejected count', '${state.lastRejected.length}'),
|
||||||
|
_infoRow('Last error', state.lastError ?? 'none'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FrostedCard(
|
||||||
|
child: Text(
|
||||||
|
'Sync policy\n• Core usage never blocks on backend\n• Local changes queue first and remain safe offline\n• Pull applies backend envelopes into local state',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _infoRow(String label, String value) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
SizedBox(width: 150, child: Text(label)),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDateTime(DateTime? value) {
|
||||||
|
if (value == null) {
|
||||||
|
return 'never';
|
||||||
|
}
|
||||||
|
|
||||||
|
final DateTime local = value.toLocal();
|
||||||
|
final String month = local.month.toString().padLeft(2, '0');
|
||||||
|
final String day = local.day.toString().padLeft(2, '0');
|
||||||
|
final String hour = local.hour.toString().padLeft(2, '0');
|
||||||
|
final String minute = local.minute.toString().padLeft(2, '0');
|
||||||
|
return '${local.year}-$month-$day $hour:$minute';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _syncNow() async {
|
||||||
|
await _run((SyncCoordinator coordinator) => coordinator.syncNow());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pushPending() async {
|
||||||
|
await _run((SyncCoordinator coordinator) => coordinator.pushPending());
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _pullChanges() async {
|
Future<void> _pullChanges() async {
|
||||||
|
await _run((SyncCoordinator coordinator) => coordinator.pullRemote());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearQueue() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_busy = true;
|
_busy = true;
|
||||||
_status = 'Pulling changes...';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final SyncPullResult result = await ref
|
await ref.read(syncQueueRepositoryProvider.notifier).clearQueue();
|
||||||
.read(backendGatewayProvider)
|
|
||||||
.pullChanges(cursor: _cursor);
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_cursor = result.cursor;
|
_status = 'Cleared pending sync queue.';
|
||||||
_status = 'Pulled ${result.changes.length} changes successfully.';
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
setState(() {
|
|
||||||
_status = 'Pull failed. Staying offline-safe. ($error)';
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setState(() {
|
if (mounted) {
|
||||||
_busy = false;
|
setState(() {
|
||||||
});
|
_busy = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pushSample() async {
|
Future<void> _run(
|
||||||
|
Future<SyncRunResult> Function(SyncCoordinator coordinator) action,
|
||||||
|
) async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_busy = true;
|
_busy = true;
|
||||||
_status = 'Pushing local sample changes...';
|
_status = 'Running sync operation...';
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<ChangeEnvelope> changes = <ChangeEnvelope>[
|
|
||||||
ChangeEnvelope(
|
|
||||||
schemaVersion: 1,
|
|
||||||
entityType: 'capture',
|
|
||||||
entityId: 'capture-${_uuid.v4()}',
|
|
||||||
op: ChangeOperation.upsert,
|
|
||||||
modifiedAt: DateTime.now().toUtc(),
|
|
||||||
clientMutationId: _uuid.v4(),
|
|
||||||
payload: const <String, dynamic>{
|
|
||||||
'summary': 'Shared a thoughtful check-in after work.',
|
|
||||||
'sentiment': 'positive',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final SyncPushResult result = await ref
|
final SyncRunResult result = await action(
|
||||||
.read(backendGatewayProvider)
|
ref.read(syncCoordinatorProvider),
|
||||||
.pushChanges(changes: changes, cursor: _cursor);
|
);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_cursor = result.cursor;
|
_status = result.message;
|
||||||
_status =
|
|
||||||
'Push complete. accepted=${result.accepted.length}, rejected=${result.rejected.length}';
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_status = 'Push failed. Local data remains safe. ($error)';
|
_status = 'Sync operation failed unexpectedly. ($error)';
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setState(() {
|
if (mounted) {
|
||||||
_busy = false;
|
setState(() {
|
||||||
});
|
_busy = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
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/sync/sync_coordinator.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/backend_gateway_fake.dart';
|
||||||
|
import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart';
|
||||||
|
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pushPending flushes queued local mutations', () async {
|
||||||
|
final ProviderContainer container = ProviderContainer(
|
||||||
|
overrides: [backendGatewayProvider.overrideWithValue(_TestGateway())],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await container.read(localRepositoryProvider.future);
|
||||||
|
await container
|
||||||
|
.read(localRepositoryProvider.notifier)
|
||||||
|
.addPerson(
|
||||||
|
name: 'Queued Person',
|
||||||
|
relationship: 'Friend',
|
||||||
|
notes: 'sync me',
|
||||||
|
tags: <String>['test'],
|
||||||
|
);
|
||||||
|
|
||||||
|
final SyncState before = await container.read(
|
||||||
|
syncQueueRepositoryProvider.future,
|
||||||
|
);
|
||||||
|
expect(before.pendingChanges.length, 1);
|
||||||
|
|
||||||
|
final SyncRunResult result = await container
|
||||||
|
.read(syncCoordinatorProvider)
|
||||||
|
.pushPending();
|
||||||
|
|
||||||
|
expect(result.success, isTrue);
|
||||||
|
expect(result.accepted, 1);
|
||||||
|
|
||||||
|
final SyncState after = await container.read(
|
||||||
|
syncQueueRepositoryProvider.future,
|
||||||
|
);
|
||||||
|
expect(after.pendingChanges, isEmpty);
|
||||||
|
expect(after.cursor, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pullRemote applies backend changes into local repository', () async {
|
||||||
|
final SyncPullResult pullResult = SyncPullResult(
|
||||||
|
cursor: 'cursor-remote-1',
|
||||||
|
changes: <ChangeEnvelope>[
|
||||||
|
ChangeEnvelope(
|
||||||
|
schemaVersion: 1,
|
||||||
|
entityType: 'person',
|
||||||
|
entityId: 'p-remote-1',
|
||||||
|
op: ChangeOperation.upsert,
|
||||||
|
modifiedAt: DateTime(2026, 2, 15, 8).toUtc(),
|
||||||
|
clientMutationId: 'remote-1',
|
||||||
|
payload: const <String, dynamic>{
|
||||||
|
'name': 'Remote Person',
|
||||||
|
'relationship': 'Sibling',
|
||||||
|
'affinityScore': 88,
|
||||||
|
'nextMoment': '2026-02-20T12:00:00Z',
|
||||||
|
'tags': <String>['remote'],
|
||||||
|
'notes': 'arrived from backend',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final ProviderContainer container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
backendGatewayProvider.overrideWithValue(
|
||||||
|
_TestGateway(pullResult: pullResult),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await container.read(localRepositoryProvider.future);
|
||||||
|
|
||||||
|
final SyncRunResult result = await container
|
||||||
|
.read(syncCoordinatorProvider)
|
||||||
|
.pullRemote();
|
||||||
|
|
||||||
|
expect(result.success, isTrue);
|
||||||
|
expect(result.pulled, 1);
|
||||||
|
|
||||||
|
final localState = await container.read(localRepositoryProvider.future);
|
||||||
|
final bool hasRemote = localState.people.any(
|
||||||
|
(person) => person.id == 'p-remote-1' && person.name == 'Remote Person',
|
||||||
|
);
|
||||||
|
expect(hasRemote, isTrue);
|
||||||
|
|
||||||
|
final SyncState queueState = await container.read(
|
||||||
|
syncQueueRepositoryProvider.future,
|
||||||
|
);
|
||||||
|
expect(queueState.cursor, 'cursor-remote-1');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TestGateway extends BackendGatewayFake {
|
||||||
|
_TestGateway({this.pullResult});
|
||||||
|
|
||||||
|
final SyncPullResult? pullResult;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SyncPullResult> pullChanges({String? cursor, int limit = 200}) async {
|
||||||
|
return pullResult ?? super.pullChanges(cursor: cursor, limit: limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.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';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enqueue persists and can be reloaded', () async {
|
||||||
|
final ProviderContainer container = ProviderContainer();
|
||||||
|
|
||||||
|
await container
|
||||||
|
.read(syncQueueRepositoryProvider.notifier)
|
||||||
|
.enqueue(_change(id: 'p-1', mutationId: 'cm-1'));
|
||||||
|
final SyncStateData firstState = await _readState(container);
|
||||||
|
expect(firstState.pending.length, 1);
|
||||||
|
|
||||||
|
container.dispose();
|
||||||
|
|
||||||
|
final ProviderContainer reloaded = ProviderContainer();
|
||||||
|
addTearDown(reloaded.dispose);
|
||||||
|
final SyncStateData reloadedState = await _readState(reloaded);
|
||||||
|
expect(reloadedState.pending.length, 1);
|
||||||
|
expect(reloadedState.pending.first.clientMutationId, 'cm-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'applyPushResult removes completed mutation ids and tracks rejections',
|
||||||
|
() async {
|
||||||
|
final ProviderContainer container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
final SyncQueueRepository notifier = container.read(
|
||||||
|
syncQueueRepositoryProvider.notifier,
|
||||||
|
);
|
||||||
|
|
||||||
|
await notifier.enqueueAll(<ChangeEnvelope>[
|
||||||
|
_change(id: 'p-1', mutationId: 'cm-a'),
|
||||||
|
_change(id: 'p-2', mutationId: 'cm-b'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await notifier.applyPushResult(
|
||||||
|
SyncPushResult(
|
||||||
|
cursor: 'cursor-2',
|
||||||
|
accepted: const <MutationAck>[MutationAck(clientMutationId: 'cm-a')],
|
||||||
|
rejected: const <MutationRejection>[
|
||||||
|
MutationRejection(
|
||||||
|
clientMutationId: 'cm-b',
|
||||||
|
code: 'VALIDATION_FAILED',
|
||||||
|
message: 'invalid payload',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
at: DateTime(2026, 2, 15, 12),
|
||||||
|
);
|
||||||
|
|
||||||
|
final SyncStateData state = await _readState(container);
|
||||||
|
expect(state.pending, isEmpty);
|
||||||
|
expect(state.cursor, 'cursor-2');
|
||||||
|
expect(state.rejected.length, 1);
|
||||||
|
expect(state.lastError, contains('Push rejected'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class SyncStateData {
|
||||||
|
const SyncStateData({
|
||||||
|
required this.cursor,
|
||||||
|
required this.pending,
|
||||||
|
required this.rejected,
|
||||||
|
required this.lastError,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? cursor;
|
||||||
|
final List<ChangeEnvelope> pending;
|
||||||
|
final List<MutationRejection> rejected;
|
||||||
|
final String? lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SyncStateData> _readState(ProviderContainer container) async {
|
||||||
|
final state = await container.read(syncQueueRepositoryProvider.future);
|
||||||
|
return SyncStateData(
|
||||||
|
cursor: state.cursor,
|
||||||
|
pending: state.pendingChanges,
|
||||||
|
rejected: state.lastRejected,
|
||||||
|
lastError: state.lastError,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ChangeEnvelope _change({required String id, required String mutationId}) {
|
||||||
|
return ChangeEnvelope(
|
||||||
|
schemaVersion: 1,
|
||||||
|
entityType: 'person',
|
||||||
|
entityId: id,
|
||||||
|
op: ChangeOperation.upsert,
|
||||||
|
modifiedAt: DateTime(2026, 2, 15, 12).toUtc(),
|
||||||
|
clientMutationId: mutationId,
|
||||||
|
payload: const <String, dynamic>{'name': 'Alex', 'relationship': 'Friend'},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user