import 'dart:convert'; 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/ideas/ideas_view.dart'; import 'package:relationship_saver/features/moments/moments_view.dart'; import 'package:relationship_saver/features/people/people_view.dart'; import 'package:relationship_saver/features/reminders/reminders_view.dart'; import 'package:relationship_saver/features/shared/frosted_card.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/models/backend_models.dart'; class SyncView extends ConsumerStatefulWidget { const SyncView({super.key}); @override ConsumerState createState() => _SyncViewState(); } class _SyncViewState extends ConsumerState { bool _busy = false; String _status = 'Ready. Local-first mode keeps app usable without sync.'; final Set _expandedRejections = {}; @override Widget build(BuildContext context) { final AsyncValue syncState = ref.watch( syncQueueRepositoryProvider, ); return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { final bool compact = constraints.maxWidth < 760; return Padding( padding: EdgeInsets.fromLTRB( compact ? 16 : 28, compact ? 16 : 24, compact ? 16 : 28, compact ? 20 : 28, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Sync', style: Theme.of(context).textTheme.headlineMedium), const SizedBox(height: 6), Text( '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), Expanded( child: syncState.when( data: (SyncState state) => _buildBody(context, state, compact), 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, bool compact) { return ListView( children: [ FrostedCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: 10, runSpacing: 10, children: [ 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: [ Text( 'Queue Status', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 10), _infoRow( label: 'Pending changes', value: '${state.pendingChanges.length}', compact: compact, ), _infoRow( label: 'Cursor', value: state.cursor ?? 'not set', compact: compact, ), _infoRow( label: 'Last sync', value: _formatDateTime(state.lastSyncAt), compact: compact, ), _infoRow( label: 'Last attempt', value: _formatDateTime(state.lastAttemptAt), compact: compact, ), _infoRow( label: 'Last rejected count', value: '${state.lastRejected.length}', compact: compact, ), _infoRow( label: 'Last error', value: state.lastError ?? 'none', compact: compact, ), ], ), ), const SizedBox(height: 16), if (state.lastRejected.isNotEmpty) ...[ FrostedCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: 8, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ Text( '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), label: const Text('Dismiss'), ), ], ), const SizedBox(height: 8), ...state.lastRejected.map((MutationRejection rejection) { final ChangeEnvelope? matched = _findRejectedChange( state.lastRejectedChanges, rejection.clientMutationId, ); final bool expanded = _expandedRejections.contains( rejection.clientMutationId, ); return Padding( padding: const EdgeInsets.only(bottom: 10), child: _rejectionTile( rejection, matched: matched, expanded: expanded, onToggleExpanded: matched?.payload == null ? null : () => _toggleRejectionExpanded( rejection.clientMutationId, ), onOpenEntity: matched != null && _entityViewForType(matched.entityType) != null ? () => _openEntityViewFor(matched) : null, ), ); }), ], ), ), 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({ required String label, required String value, required bool compact, }) { if (compact) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: Theme.of( context, ).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: 2), Text(value, style: Theme.of(context).textTheme.bodyMedium), ], ), ); } return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(width: 150, child: Text(label)), Expanded( child: Text( value, style: Theme.of( context, ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), ), ), ], ), ); } Widget _rejectionTile( MutationRejection rejection, { required ChangeEnvelope? matched, required bool expanded, required VoidCallback? onToggleExpanded, required VoidCallback? onOpenEntity, }) { return Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.72), borderRadius: BorderRadius.circular(12), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: 8, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: const Color(0xFFEAF7FB), borderRadius: BorderRadius.circular(999), ), child: Text( rejection.code, style: Theme.of( context, ).textTheme.labelMedium?.copyWith(color: AppTheme.primary), ), ), Text( 'Mutation ${rejection.clientMutationId}', style: Theme.of( 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, ), ), if (onToggleExpanded != null) IconButton( onPressed: onToggleExpanded, tooltip: expanded ? 'Hide payload' : 'Show payload', icon: Icon( expanded ? Icons.keyboard_arrow_up_rounded : Icons.keyboard_arrow_down_rounded, color: AppTheme.textSecondary, ), ), ], ), const SizedBox(height: 6), Text( rejection.message, style: Theme.of(context).textTheme.bodyMedium, ), if (expanded && matched?.payload != null) ...[ const SizedBox(height: 10), Container( width: double.infinity, padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: const Color(0xFFF3F8FB), borderRadius: BorderRadius.circular(10), ), child: SelectableText( const JsonEncoder.withIndent(' ').convert(matched!.payload), style: Theme.of(context).textTheme.bodySmall, ), ), ], if (onOpenEntity != null) ...[ const SizedBox(height: 8), Wrap( spacing: 8, children: [ TextButton.icon( onPressed: onOpenEntity, icon: const Icon(Icons.open_in_new_rounded), label: const Text('Open Related Screen'), ), ], ), ], ], ), ); } 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'; } 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 _syncNow() async { await _run((SyncCoordinator coordinator) => coordinator.syncNow()); } Future _pushPending() async { await _run((SyncCoordinator coordinator) => coordinator.pushPending()); } Future _pullChanges() async { await _run((SyncCoordinator coordinator) => coordinator.pullRemote()); } Future _clearQueue() async { setState(() { _busy = true; }); try { await ref.read(syncQueueRepositoryProvider.notifier).clearQueue(); setState(() { _status = 'Cleared pending sync queue.'; }); } finally { if (mounted) { setState(() { _busy = false; }); } } } Future _dismissRejections() async { setState(() { _busy = true; }); try { await ref.read(syncQueueRepositoryProvider.notifier).clearRejections(); setState(() { _status = 'Dismissed latest rejected-mutation details.'; }); } finally { if (mounted) { setState(() { _busy = false; }); } } } 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 { 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; }); } } } void _toggleRejectionExpanded(String clientMutationId) { setState(() { if (_expandedRejections.contains(clientMutationId)) { _expandedRejections.remove(clientMutationId); } else { _expandedRejections.add(clientMutationId); } }); } Future _openEntityViewFor(ChangeEnvelope matched) async { final Widget? view = _entityViewForType(matched.entityType); if (view == null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('No editor available for ${matched.entityType}.'), ), ); return; } await Navigator.of(context).push( MaterialPageRoute( builder: (BuildContext context) => _EntityResolveScreen( title: _titleForEntityType(matched.entityType), child: view, ), ), ); } Widget? _entityViewForType(String entityType) { switch (entityType) { case 'person': return const PeopleView(); case 'capture': return const MomentsView(); case 'giftIdea': case 'eventIdea': return const IdeasView(); case 'reminderRule': return const RemindersView(); default: return null; } } String _titleForEntityType(String entityType) { switch (entityType) { case 'person': return 'Resolve Person'; case 'capture': return 'Resolve Moment'; case 'giftIdea': case 'eventIdea': return 'Resolve Idea'; case 'reminderRule': return 'Resolve Reminder'; default: return 'Resolve Item'; } } } class _EntityResolveScreen extends StatelessWidget { const _EntityResolveScreen({required this.title, required this.child}); final String title; final Widget child; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(title)), body: child, ); } }