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
+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();
}
}
+159 -87
View File
@@ -2,9 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart';
import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
import 'package:uuid/uuid.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';
class SyncView extends ConsumerStatefulWidget {
const SyncView({super.key});
@@ -14,14 +14,15 @@ class SyncView extends ConsumerStatefulWidget {
}
class _SyncViewState extends ConsumerState<SyncView> {
static const Uuid _uuid = Uuid();
bool _busy = false;
String _status = 'Ready. Local-first mode keeps app usable without sync.';
String? _cursor;
@override
Widget build(BuildContext context) {
final AsyncValue<SyncState> syncState = ref.watch(
syncQueueRepositoryProvider,
);
return Padding(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
child: Column(
@@ -30,52 +31,26 @@ class _SyncViewState extends ConsumerState<SyncView> {
Text('Sync', style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 6),
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(
context,
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 16),
FrostedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Wrap(
spacing: 10,
runSpacing: 10,
children: <Widget>[
FilledButton.icon(
onPressed: _busy ? null : _pushSample,
icon: const Icon(Icons.upload_rounded),
label: const Text('Push Sample Changes'),
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,
),
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 {
await _run((SyncCoordinator coordinator) => coordinator.pullRemote());
}
Future<void> _clearQueue() async {
setState(() {
_busy = true;
_status = 'Pulling changes...';
});
try {
final SyncPullResult result = await ref
.read(backendGatewayProvider)
.pullChanges(cursor: _cursor);
await ref.read(syncQueueRepositoryProvider.notifier).clearQueue();
setState(() {
_cursor = result.cursor;
_status = 'Pulled ${result.changes.length} changes successfully.';
});
} catch (error) {
setState(() {
_status = 'Pull failed. Staying offline-safe. ($error)';
_status = 'Cleared pending sync queue.';
});
} finally {
setState(() {
_busy = false;
});
if (mounted) {
setState(() {
_busy = false;
});
}
}
}
Future<void> _pushSample() async {
Future<void> _run(
Future<SyncRunResult> Function(SyncCoordinator coordinator) action,
) async {
setState(() {
_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 {
final SyncPushResult result = await ref
.read(backendGatewayProvider)
.pushChanges(changes: changes, cursor: _cursor);
final SyncRunResult result = await action(
ref.read(syncCoordinatorProvider),
);
setState(() {
_cursor = result.cursor;
_status =
'Push complete. accepted=${result.accepted.length}, rejected=${result.rejected.length}';
_status = result.message;
});
} catch (error) {
setState(() {
_status = 'Push failed. Local data remains safe. ($error)';
_status = 'Sync operation failed unexpectedly. ($error)';
});
} finally {
setState(() {
_busy = false;
});
if (mounted) {
setState(() {
_busy = false;
});
}
}
}
}