Add sync queue orchestration and local mutation envelopes

This commit is contained in:
Rijad Zuzo
2026-02-15 16:56:55 +01:00
parent aaabc51d2d
commit d711f270dd
8 changed files with 1411 additions and 101 deletions
+32 -4
View File
@@ -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
+592 -10
View File
@@ -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);
+179
View File
@@ -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,
);
+98
View File
@@ -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();
}
}
+143 -71
View File
@@ -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,12 +31,36 @@ 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),
Expanded(
child: syncState.when(
data: (SyncState state) => _buildBody(context, state),
loading: () => const Center(child: CircularProgressIndicator()),
error: (Object error, StackTrace stackTrace) {
return Center(
child: Text(
'Unable to load sync state. $error',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.textSecondary,
),
),
);
},
),
),
],
),
);
}
Widget _buildBody(BuildContext context, SyncState state) {
return ListView(
children: <Widget>[
FrostedCard( FrostedCard(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -45,109 +70,156 @@ class _SyncViewState extends ConsumerState<SyncView> {
runSpacing: 10, runSpacing: 10,
children: <Widget>[ children: <Widget>[
FilledButton.icon( FilledButton.icon(
onPressed: _busy ? null : _pushSample, 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), icon: const Icon(Icons.upload_rounded),
label: const Text('Push Sample Changes'), label: const Text('Push Pending'),
), ),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: _busy ? null : _pullChanges, onPressed: _busy ? null : _pullChanges,
icon: const Icon(Icons.download_rounded), icon: const Icon(Icons.download_rounded),
label: const Text('Pull Changes'), 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), const SizedBox(height: 16),
Text(
'Cursor: ${_cursor ?? 'not set'}',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8),
Text( Text(
_status, _status,
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(
color: AppTheme.textSecondary, context,
), ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
), ),
], ],
), ),
), ),
const SizedBox(height: 16), 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( FrostedCard(
child: Text( child: Text(
'Sync policy\n• Core usage never blocks on backend\nRetries only for transient failures\n• Non-idempotent requests require idempotency key', 'Sync policy\n• Core usage never blocks on backend\nLocal changes queue first and remain safe offline\n• Pull applies backend envelopes into local state',
style: Theme.of(context).textTheme.bodyLarge, 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 {
setState(() {
_busy = false;
});
}
}
Future<void> _pushSample() async {
setState(() {
_busy = true;
_status = 'Pushing local sample changes...';
});
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 {
final SyncPushResult result = await ref
.read(backendGatewayProvider)
.pushChanges(changes: changes, cursor: _cursor);
setState(() {
_cursor = result.cursor;
_status =
'Push complete. accepted=${result.accepted.length}, rejected=${result.rejected.length}';
});
} catch (error) {
setState(() {
_status = 'Push failed. Local data remains safe. ($error)';
}); });
} finally { } finally {
if (mounted) {
setState(() { setState(() {
_busy = false; _busy = false;
}); });
} }
} }
} }
Future<void> _run(
Future<SyncRunResult> Function(SyncCoordinator coordinator) action,
) async {
setState(() {
_busy = true;
_status = 'Running sync operation...';
});
try {
final SyncRunResult result = await action(
ref.read(syncCoordinatorProvider),
);
setState(() {
_status = result.message;
});
} catch (error) {
setState(() {
_status = 'Sync operation failed unexpectedly. ($error)';
});
} finally {
if (mounted) {
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'},
);
}