Add rejected mutation requeue flow in sync UI
This commit is contained in:
@@ -68,22 +68,39 @@ class SyncQueueRepository extends AsyncNotifier<SyncState> {
|
||||
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),
|
||||
final Set<String> rejectedMutationIds = <String>{
|
||||
...result.rejected.map(
|
||||
(MutationRejection rejection) => rejection.clientMutationId,
|
||||
),
|
||||
};
|
||||
final Set<String> completedMutationIds = <String>{
|
||||
...result.accepted.map((MutationAck ack) => ack.clientMutationId),
|
||||
...rejectedMutationIds,
|
||||
};
|
||||
final List<ChangeEnvelope> rejectedChanges = current.pendingChanges
|
||||
.where(
|
||||
(ChangeEnvelope change) =>
|
||||
rejectedMutationIds.contains(change.clientMutationId),
|
||||
)
|
||||
.toList(growable: false);
|
||||
final List<ChangeEnvelope> pending = current.pendingChanges
|
||||
.where(
|
||||
(ChangeEnvelope change) =>
|
||||
!completedMutationIds.contains(change.clientMutationId),
|
||||
)
|
||||
.toList(growable: false);
|
||||
final List<ChangeEnvelope> 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<SyncState> {
|
||||
lastSyncAt: now,
|
||||
lastError: rejectionMessage,
|
||||
lastRejected: result.rejected,
|
||||
lastRejectedChanges: dedupedRejectedChanges,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Requeues rejected changes to pending sync list for manual retry.
|
||||
Future<void> requeueRejectedChanges() async {
|
||||
final SyncState current = await _currentState();
|
||||
if (current.lastRejectedChanges.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final List<ChangeEnvelope> pending = <ChangeEnvelope>[
|
||||
...current.lastRejectedChanges,
|
||||
...current.pendingChanges,
|
||||
];
|
||||
final List<ChangeEnvelope> dedupedPending = _dedupeByMutationId(pending);
|
||||
await _setState(
|
||||
current.copyWith(
|
||||
pendingChanges: dedupedPending,
|
||||
lastRejected: const <MutationRejection>[],
|
||||
lastRejectedChanges: const <ChangeEnvelope>[],
|
||||
lastError: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -107,6 +147,7 @@ class SyncQueueRepository extends AsyncNotifier<SyncState> {
|
||||
lastSyncAt: now,
|
||||
lastError: null,
|
||||
lastRejected: const <MutationRejection>[],
|
||||
lastRejectedChanges: const <ChangeEnvelope>[],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -123,6 +164,7 @@ class SyncQueueRepository extends AsyncNotifier<SyncState> {
|
||||
current.copyWith(
|
||||
pendingChanges: const <ChangeEnvelope>[],
|
||||
lastRejected: const <MutationRejection>[],
|
||||
lastRejectedChanges: const <ChangeEnvelope>[],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -138,11 +180,23 @@ class SyncQueueRepository extends AsyncNotifier<SyncState> {
|
||||
await _setState(
|
||||
current.copyWith(
|
||||
lastRejected: const <MutationRejection>[],
|
||||
lastRejectedChanges: const <ChangeEnvelope>[],
|
||||
lastError: lastError,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<ChangeEnvelope> _dedupeByMutationId(List<ChangeEnvelope> changes) {
|
||||
final Set<String> seen = <String>{};
|
||||
final List<ChangeEnvelope> deduped = <ChangeEnvelope>[];
|
||||
for (final ChangeEnvelope change in changes) {
|
||||
if (seen.add(change.clientMutationId)) {
|
||||
deduped.add(change);
|
||||
}
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
Future<SyncState> _currentState() async {
|
||||
final SyncState? value = state.asData?.value;
|
||||
if (value != null) {
|
||||
|
||||
@@ -11,6 +11,7 @@ class SyncState {
|
||||
this.lastAttemptAt,
|
||||
this.lastError,
|
||||
this.lastRejected = const <MutationRejection>[],
|
||||
this.lastRejectedChanges = const <ChangeEnvelope>[],
|
||||
});
|
||||
|
||||
factory SyncState.fromJson(Map<String, dynamic> json) {
|
||||
@@ -31,6 +32,13 @@ class SyncState {
|
||||
MutationRejection.fromJson(item as Map<String, dynamic>),
|
||||
)
|
||||
.toList(growable: false),
|
||||
lastRejectedChanges:
|
||||
(json['lastRejectedChanges'] as List<dynamic>? ?? <dynamic>[])
|
||||
.map(
|
||||
(dynamic item) =>
|
||||
ChangeEnvelope.fromJson(item as Map<String, dynamic>),
|
||||
)
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,12 +48,14 @@ class SyncState {
|
||||
final DateTime? lastAttemptAt;
|
||||
final String? lastError;
|
||||
final List<MutationRejection> lastRejected;
|
||||
final List<ChangeEnvelope> lastRejectedChanges;
|
||||
|
||||
static const Object _unset = Object();
|
||||
|
||||
static const SyncState empty = SyncState(
|
||||
pendingChanges: <ChangeEnvelope>[],
|
||||
lastRejected: <MutationRejection>[],
|
||||
lastRejectedChanges: <ChangeEnvelope>[],
|
||||
);
|
||||
|
||||
SyncState copyWith({
|
||||
@@ -55,6 +65,7 @@ class SyncState {
|
||||
Object? lastAttemptAt = _unset,
|
||||
Object? lastError = _unset,
|
||||
List<MutationRejection>? lastRejected,
|
||||
List<ChangeEnvelope>? 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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,11 @@ class _SyncViewState extends ConsumerState<SyncView> {
|
||||
'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<SyncView> {
|
||||
...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<SyncView> {
|
||||
);
|
||||
}
|
||||
|
||||
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<SyncView> {
|
||||
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<SyncView> {
|
||||
);
|
||||
}
|
||||
|
||||
ChangeEnvelope? _findRejectedChange(
|
||||
List<ChangeEnvelope> 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<SyncView> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<void> _run(
|
||||
Future<SyncRunResult> Function(SyncCoordinator coordinator) action,
|
||||
) async {
|
||||
|
||||
Reference in New Issue
Block a user