From c4b86c18da672be57cb5262ab120628963ddf201 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sun, 15 Feb 2026 23:06:48 +0100 Subject: [PATCH] Add rejected mutation requeue flow in sync UI --- docs/progress.md | 32 +++++++++- lib/features/sync/sync_queue_repository.dart | 60 ++++++++++++++++++- lib/features/sync/sync_state.dart | 15 +++++ lib/features/sync/sync_view.dart | 58 +++++++++++++++++- .../sync/sync_queue_repository_test.dart | 47 +++++++++++++++ 5 files changed, 205 insertions(+), 7 deletions(-) diff --git a/docs/progress.md b/docs/progress.md index 7d73a71..b130e14 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -2,6 +2,35 @@ Updated: 2026-02-15 +## Latest Milestone (2026-02-15): Rejected-Mutation Requeue Flow + +Extended sync conflict handling so rejected mutations can be retried after local +fixes: + +- Expanded sync state model: + - `lib/features/sync/sync_state.dart` + - stores `lastRejectedChanges` alongside rejection reasons +- Updated sync queue repository: + - `lib/features/sync/sync_queue_repository.dart` + - captures rejected envelopes during push + - adds `requeueRejectedChanges()` to move rejected envelopes back into pending + queue + - keeps clear/dismiss behavior for rejected metadata +- Updated sync UX: + - `lib/features/sync/sync_view.dart` + - rejected section now includes: + - entity context (`entityType:entityId`) + - `Requeue` action for manual retry flow + - existing `Dismiss` action +- Added/updated tests: + - `test/features/sync/sync_queue_repository_test.dart` + - verifies rejected envelope capture and requeue behavior + +Validation for this milestone: + +- `flutter analyze` -> pass +- `flutter test` -> pass + ## Latest Milestone (2026-02-15): Sync Auto-Trigger Backoff Improved sync trigger resilience for repeated failures: @@ -82,8 +111,7 @@ Validation for this milestone: - replace no-op scheduler with platform local notifications implementation for Android/iOS/macOS/Windows/Linux/Web-safe fallback. 2. Conflict resolution UX: - - current UX shows and dismisses rejections; still missing guided - resolve/retry/inspect flows per entity. + - reject/requeue is implemented; still missing guided per-entity repair UI. 3. Sync trigger maturity: - connectivity-aware triggers are still missing. 4. Test depth: diff --git a/lib/features/sync/sync_queue_repository.dart b/lib/features/sync/sync_queue_repository.dart index 96b2f82..a6a9d44 100644 --- a/lib/features/sync/sync_queue_repository.dart +++ b/lib/features/sync/sync_queue_repository.dart @@ -68,22 +68,39 @@ class SyncQueueRepository extends AsyncNotifier { Future applyPushResult(SyncPushResult result, {DateTime? at}) async { final SyncState current = await _currentState(); final DateTime now = at ?? DateTime.now(); - final Set completedMutationIds = { - ...result.accepted.map((MutationAck ack) => ack.clientMutationId), + final Set rejectedMutationIds = { ...result.rejected.map( (MutationRejection rejection) => rejection.clientMutationId, ), }; + final Set completedMutationIds = { + ...result.accepted.map((MutationAck ack) => ack.clientMutationId), + ...rejectedMutationIds, + }; + final List rejectedChanges = current.pendingChanges + .where( + (ChangeEnvelope change) => + rejectedMutationIds.contains(change.clientMutationId), + ) + .toList(growable: false); final List pending = current.pendingChanges .where( (ChangeEnvelope change) => !completedMutationIds.contains(change.clientMutationId), ) .toList(growable: false); + final List dedupedRejectedChanges = rejectedChanges + .where( + (ChangeEnvelope change) => !pending.any( + (ChangeEnvelope pendingChange) => + pendingChange.clientMutationId == change.clientMutationId, + ), + ) + .toList(growable: false); final String? rejectionMessage = result.rejected.isEmpty ? null - : 'Push rejected ${result.rejected.length} change(s).'; + : 'Push rejected ${result.rejected.length} change(s). Fix locally and retry.'; await _setState( current.copyWith( @@ -93,6 +110,29 @@ class SyncQueueRepository extends AsyncNotifier { lastSyncAt: now, lastError: rejectionMessage, lastRejected: result.rejected, + lastRejectedChanges: dedupedRejectedChanges, + ), + ); + } + + /// Requeues rejected changes to pending sync list for manual retry. + Future requeueRejectedChanges() async { + final SyncState current = await _currentState(); + if (current.lastRejectedChanges.isEmpty) { + return; + } + + final List pending = [ + ...current.lastRejectedChanges, + ...current.pendingChanges, + ]; + final List dedupedPending = _dedupeByMutationId(pending); + await _setState( + current.copyWith( + pendingChanges: dedupedPending, + lastRejected: const [], + lastRejectedChanges: const [], + lastError: null, ), ); } @@ -107,6 +147,7 @@ class SyncQueueRepository extends AsyncNotifier { lastSyncAt: now, lastError: null, lastRejected: const [], + lastRejectedChanges: const [], ), ); } @@ -123,6 +164,7 @@ class SyncQueueRepository extends AsyncNotifier { current.copyWith( pendingChanges: const [], lastRejected: const [], + lastRejectedChanges: const [], ), ); } @@ -138,11 +180,23 @@ class SyncQueueRepository extends AsyncNotifier { await _setState( current.copyWith( lastRejected: const [], + lastRejectedChanges: const [], lastError: lastError, ), ); } + List _dedupeByMutationId(List changes) { + final Set seen = {}; + final List deduped = []; + for (final ChangeEnvelope change in changes) { + if (seen.add(change.clientMutationId)) { + deduped.add(change); + } + } + return deduped; + } + Future _currentState() async { final SyncState? value = state.asData?.value; if (value != null) { diff --git a/lib/features/sync/sync_state.dart b/lib/features/sync/sync_state.dart index 37f1227..99d4559 100644 --- a/lib/features/sync/sync_state.dart +++ b/lib/features/sync/sync_state.dart @@ -11,6 +11,7 @@ class SyncState { this.lastAttemptAt, this.lastError, this.lastRejected = const [], + this.lastRejectedChanges = const [], }); factory SyncState.fromJson(Map json) { @@ -31,6 +32,13 @@ class SyncState { MutationRejection.fromJson(item as Map), ) .toList(growable: false), + lastRejectedChanges: + (json['lastRejectedChanges'] as List? ?? []) + .map( + (dynamic item) => + ChangeEnvelope.fromJson(item as Map), + ) + .toList(growable: false), ); } @@ -40,12 +48,14 @@ class SyncState { final DateTime? lastAttemptAt; final String? lastError; final List lastRejected; + final List lastRejectedChanges; static const Object _unset = Object(); static const SyncState empty = SyncState( pendingChanges: [], lastRejected: [], + lastRejectedChanges: [], ); SyncState copyWith({ @@ -55,6 +65,7 @@ class SyncState { Object? lastAttemptAt = _unset, Object? lastError = _unset, List? lastRejected, + List? lastRejectedChanges, }) { return SyncState( cursor: identical(cursor, _unset) ? this.cursor : cursor as String?, @@ -69,6 +80,7 @@ class SyncState { ? this.lastError : lastError as String?, lastRejected: lastRejected ?? this.lastRejected, + lastRejectedChanges: lastRejectedChanges ?? this.lastRejectedChanges, ); } @@ -84,6 +96,9 @@ class SyncState { 'lastRejected': lastRejected .map((MutationRejection rejection) => rejection.toJson()) .toList(growable: false), + 'lastRejectedChanges': lastRejectedChanges + .map((ChangeEnvelope change) => change.toJson()) + .toList(growable: false), }; } diff --git a/lib/features/sync/sync_view.dart b/lib/features/sync/sync_view.dart index 6497be7..1c38ac4 100644 --- a/lib/features/sync/sync_view.dart +++ b/lib/features/sync/sync_view.dart @@ -172,6 +172,11 @@ class _SyncViewState extends ConsumerState { 'Rejected Changes', style: Theme.of(context).textTheme.titleMedium, ), + TextButton.icon( + onPressed: _busy ? null : _requeueRejected, + icon: const Icon(Icons.replay_rounded), + label: const Text('Requeue'), + ), TextButton.icon( onPressed: _busy ? null : _dismissRejections, icon: const Icon(Icons.done_all_rounded), @@ -183,7 +188,13 @@ class _SyncViewState extends ConsumerState { ...state.lastRejected.map( (MutationRejection rejection) => Padding( padding: const EdgeInsets.only(bottom: 10), - child: _rejectionTile(rejection), + child: _rejectionTile( + rejection, + matched: _findRejectedChange( + state.lastRejectedChanges, + rejection.clientMutationId, + ), + ), ), ), ], @@ -244,7 +255,10 @@ class _SyncViewState extends ConsumerState { ); } - Widget _rejectionTile(MutationRejection rejection) { + Widget _rejectionTile( + MutationRejection rejection, { + required ChangeEnvelope? matched, + }) { return Container( width: double.infinity, padding: const EdgeInsets.all(12), @@ -279,6 +293,13 @@ class _SyncViewState extends ConsumerState { context, ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ), + if (matched != null) + Text( + '${matched.entityType}:${matched.entityId}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), ], ), const SizedBox(height: 6), @@ -291,6 +312,18 @@ class _SyncViewState extends ConsumerState { ); } + ChangeEnvelope? _findRejectedChange( + List changes, + String clientMutationId, + ) { + for (final ChangeEnvelope change in changes) { + if (change.clientMutationId == clientMutationId) { + return change; + } + } + return null; + } + String _formatDateTime(DateTime? value) { if (value == null) { return 'never'; @@ -354,6 +387,27 @@ class _SyncViewState extends ConsumerState { } } + Future _requeueRejected() async { + setState(() { + _busy = true; + }); + + try { + await ref + .read(syncQueueRepositoryProvider.notifier) + .requeueRejectedChanges(); + setState(() { + _status = 'Requeued rejected changes. Sync again after local fixes.'; + }); + } finally { + if (mounted) { + setState(() { + _busy = false; + }); + } + } + } + Future _run( Future Function(SyncCoordinator coordinator) action, ) async { diff --git a/test/features/sync/sync_queue_repository_test.dart b/test/features/sync/sync_queue_repository_test.dart index 0ca66ee..e012d14 100644 --- a/test/features/sync/sync_queue_repository_test.dart +++ b/test/features/sync/sync_queue_repository_test.dart @@ -67,6 +67,7 @@ void main() { expect(state.pending, isEmpty); expect(state.cursor, 'cursor-2'); expect(state.rejected.length, 1); + expect(state.rejectedChanges.length, 1); expect(state.lastError, contains('Push rejected')); }, ); @@ -104,9 +105,52 @@ void main() { await notifier.clearRejections(); final SyncStateData state = await _readState(container); expect(state.rejected, isEmpty); + expect(state.rejectedChanges, isEmpty); expect(state.lastError, isNull); }, ); + + test( + 'requeueRejectedChanges adds rejected envelopes back to pending', + () async { + final ProviderContainer container = ProviderContainer( + overrides: [ + syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()), + ], + ); + addTearDown(container.dispose); + + final SyncQueueRepository notifier = container.read( + syncQueueRepositoryProvider.notifier, + ); + + await notifier.enqueue(_change(id: 'p-9', mutationId: 'cm-x')); + await notifier.applyPushResult( + SyncPushResult( + cursor: 'cursor-10', + accepted: const [], + rejected: const [ + MutationRejection( + clientMutationId: 'cm-x', + code: 'VALIDATION_FAILED', + message: 'invalid payload', + ), + ], + ), + ); + + SyncStateData state = await _readState(container); + expect(state.pending, isEmpty); + expect(state.rejectedChanges.length, 1); + + await notifier.requeueRejectedChanges(); + state = await _readState(container); + expect(state.pending.length, 1); + expect(state.pending.first.clientMutationId, 'cm-x'); + expect(state.rejected, isEmpty); + expect(state.rejectedChanges, isEmpty); + }, + ); } class SyncStateData { @@ -114,12 +158,14 @@ class SyncStateData { required this.cursor, required this.pending, required this.rejected, + required this.rejectedChanges, required this.lastError, }); final String? cursor; final List pending; final List rejected; + final List rejectedChanges; final String? lastError; } @@ -129,6 +175,7 @@ Future _readState(ProviderContainer container) async { cursor: state.cursor, pending: state.pendingChanges, rejected: state.lastRejected, + rejectedChanges: state.lastRejectedChanges, lastError: state.lastError, ); }