Add sync queue orchestration and local mutation envelopes
This commit is contained in:
@@ -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: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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user