import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:relationship_saver/core/config/app_theme.dart'; import 'package:relationship_saver/features/local/local_models.dart'; import 'package:relationship_saver/features/local/local_repository.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 && _supportsEntityRepair(matched.entityType) ? () => _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 { if (!_supportsEntityRepair(matched.entityType)) { 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), entityType: matched.entityType, entityId: matched.entityId, ), ), ); } bool _supportsEntityRepair(String entityType) { switch (entityType) { case 'person': case 'capture': case 'giftIdea': case 'eventIdea': case 'reminderRule': return true; default: return false; } } 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 ConsumerWidget { const _EntityResolveScreen({ required this.title, required this.entityType, required this.entityId, }); final String title; final String entityType; final String entityId; @override Widget build(BuildContext context, WidgetRef ref) { final AsyncValue localData = ref.watch( localRepositoryProvider, ); return Scaffold( appBar: AppBar(title: Text(title)), body: Column( children: [ Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), color: const Color(0xFFEAF7FB), child: Wrap( spacing: 8, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ Text('Focus: $entityType:$entityId'), TextButton.icon( onPressed: () async { await Clipboard.setData( ClipboardData(text: '$entityType:$entityId'), ); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Entity ID copied.')), ); } }, icon: const Icon(Icons.copy_rounded), label: const Text('Copy'), ), ], ), ), Expanded( child: localData.when( data: (LocalDataState data) => _buildEditor(context, ref, data), loading: () => const Center(child: CircularProgressIndicator()), error: (Object error, StackTrace stackTrace) => const Center(child: Text('Unable to load local data')), ), ), ], ), ); } Widget _buildEditor( BuildContext context, WidgetRef ref, LocalDataState data, ) { switch (entityType) { case 'person': final PersonProfile? person = _findPersonById(data.people, entityId); if (person == null) { return _MissingEntityView(entityType: entityType, entityId: entityId); } return _PersonRepairForm( person: person, onSave: (PersonProfile updated) async { await ref .read(localRepositoryProvider.notifier) .updatePerson(updated); }, ); case 'capture': final RelationshipMoment? moment = _findMomentById( data.moments, entityId, ); if (moment == null) { return _MissingEntityView(entityType: entityType, entityId: entityId); } return _MomentRepairForm( moment: moment, people: data.people, onSave: (RelationshipMoment updated) async { await ref .read(localRepositoryProvider.notifier) .updateMoment(updated); }, ); case 'giftIdea': case 'eventIdea': final RelationshipIdea? idea = _findIdeaById(data.ideas, entityId); if (idea == null) { return _MissingEntityView(entityType: entityType, entityId: entityId); } return _IdeaRepairForm( idea: idea, people: data.people, onSave: (RelationshipIdea updated) async { await ref .read(localRepositoryProvider.notifier) .updateIdea(updated); }, ); case 'reminderRule': final ReminderRule? reminder = _findReminderById( data.reminders, entityId, ); if (reminder == null) { return _MissingEntityView(entityType: entityType, entityId: entityId); } return _ReminderRepairForm( reminder: reminder, people: data.people, onSave: (ReminderRule updated) async { await ref .read(localRepositoryProvider.notifier) .updateReminder(updated); }, ); default: return _MissingEntityView(entityType: entityType, entityId: entityId); } } PersonProfile? _findPersonById(List people, String id) { for (final PersonProfile person in people) { if (person.id == id) { return person; } } return null; } RelationshipMoment? _findMomentById( List moments, String id, ) { for (final RelationshipMoment moment in moments) { if (moment.id == id) { return moment; } } return null; } RelationshipIdea? _findIdeaById(List ideas, String id) { for (final RelationshipIdea idea in ideas) { if (idea.id == id) { return idea; } } return null; } ReminderRule? _findReminderById(List reminders, String id) { for (final ReminderRule reminder in reminders) { if (reminder.id == id) { return reminder; } } return null; } } class _MissingEntityView extends StatelessWidget { const _MissingEntityView({required this.entityType, required this.entityId}); final String entityType; final String entityId; @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(20), child: Text( 'Could not find $entityType:$entityId in local data.', textAlign: TextAlign.center, ), ), ); } } class _PersonRepairForm extends StatefulWidget { const _PersonRepairForm({required this.person, required this.onSave}); final PersonProfile person; final Future Function(PersonProfile updated) onSave; @override State<_PersonRepairForm> createState() => _PersonRepairFormState(); } class _PersonRepairFormState extends State<_PersonRepairForm> { late final TextEditingController _nameController; late final TextEditingController _relationshipController; late final TextEditingController _tagsController; late final TextEditingController _notesController; bool _saving = false; @override void initState() { super.initState(); _nameController = TextEditingController(text: widget.person.name); _relationshipController = TextEditingController( text: widget.person.relationship, ); _tagsController = TextEditingController( text: widget.person.tags.join(', '), ); _notesController = TextEditingController(text: widget.person.notes); } @override void dispose() { _nameController.dispose(); _relationshipController.dispose(); _tagsController.dispose(); _notesController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return _RepairFormScaffold( children: [ TextField( controller: _nameController, decoration: const InputDecoration(labelText: 'Name'), ), TextField( controller: _relationshipController, decoration: const InputDecoration(labelText: 'Relationship'), ), TextField( controller: _tagsController, decoration: const InputDecoration( labelText: 'Tags (comma-separated)', ), ), TextField( controller: _notesController, maxLines: 3, decoration: const InputDecoration(labelText: 'Notes'), ), FilledButton.icon( onPressed: _saving ? null : _save, icon: _saving ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.save_rounded), label: const Text('Save Changes'), ), ], ); } Future _save() async { final String name = _nameController.text.trim(); final String relationship = _relationshipController.text.trim(); if (name.isEmpty || relationship.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Name and relationship are required.')), ); return; } setState(() { _saving = true; }); try { await widget.onSave( widget.person.copyWith( name: name, relationship: relationship, tags: _tagsController.text .split(',') .map((String e) => e.trim()) .where((String e) => e.isNotEmpty) .toList(growable: false), notes: _notesController.text.trim(), ), ); if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Person updated.'))); } } finally { if (mounted) { setState(() { _saving = false; }); } } } } class _MomentRepairForm extends StatefulWidget { const _MomentRepairForm({ required this.moment, required this.people, required this.onSave, }); final RelationshipMoment moment; final List people; final Future Function(RelationshipMoment updated) onSave; @override State<_MomentRepairForm> createState() => _MomentRepairFormState(); } class _MomentRepairFormState extends State<_MomentRepairForm> { late final TextEditingController _titleController; late final TextEditingController _summaryController; late String _personId; bool _saving = false; @override void initState() { super.initState(); _titleController = TextEditingController(text: widget.moment.title); _summaryController = TextEditingController(text: widget.moment.summary); _personId = widget.people.any((PersonProfile p) => p.id == widget.moment.personId) ? widget.moment.personId : (widget.people.isEmpty ? widget.moment.personId : widget.people.first.id); } @override void dispose() { _titleController.dispose(); _summaryController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return _RepairFormScaffold( children: [ if (widget.people.isNotEmpty) DropdownButtonFormField( initialValue: _personId, items: widget.people .map( (PersonProfile person) => DropdownMenuItem( value: person.id, child: Text(person.name), ), ) .toList(growable: false), onChanged: (String? value) { if (value == null) { return; } setState(() { _personId = value; }); }, decoration: const InputDecoration(labelText: 'Person'), ), TextField( controller: _titleController, decoration: const InputDecoration(labelText: 'Title'), ), TextField( controller: _summaryController, maxLines: 4, decoration: const InputDecoration(labelText: 'Summary'), ), FilledButton.icon( onPressed: _saving ? null : _save, icon: _saving ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.save_rounded), label: const Text('Save Changes'), ), ], ); } Future _save() async { final String summary = _summaryController.text.trim(); if (summary.isEmpty) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Summary is required.'))); return; } setState(() { _saving = true; }); try { await widget.onSave( widget.moment.copyWith( personId: _personId, title: _titleController.text.trim().isEmpty ? widget.moment.title : _titleController.text.trim(), summary: summary, ), ); if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Moment updated.'))); } } finally { if (mounted) { setState(() { _saving = false; }); } } } } class _IdeaRepairForm extends StatefulWidget { const _IdeaRepairForm({ required this.idea, required this.people, required this.onSave, }); final RelationshipIdea idea; final List people; final Future Function(RelationshipIdea updated) onSave; @override State<_IdeaRepairForm> createState() => _IdeaRepairFormState(); } class _IdeaRepairFormState extends State<_IdeaRepairForm> { late final TextEditingController _titleController; late final TextEditingController _detailsController; late IdeaType _type; String? _personId; late bool _archived; bool _saving = false; @override void initState() { super.initState(); _titleController = TextEditingController(text: widget.idea.title); _detailsController = TextEditingController(text: widget.idea.details); _type = widget.idea.type; _personId = widget.idea.personId; _archived = widget.idea.isArchived; } @override void dispose() { _titleController.dispose(); _detailsController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return _RepairFormScaffold( children: [ TextField( controller: _titleController, decoration: const InputDecoration(labelText: 'Title'), ), DropdownButtonFormField( initialValue: _type, items: IdeaType.values .map( (IdeaType type) => DropdownMenuItem( value: type, child: Text(type == IdeaType.gift ? 'Gift' : 'Event'), ), ) .toList(growable: false), onChanged: (IdeaType? value) { if (value == null) { return; } setState(() { _type = value; }); }, decoration: const InputDecoration(labelText: 'Type'), ), DropdownButtonFormField( initialValue: _personId, items: >[ const DropdownMenuItem( value: null, child: Text('Unassigned'), ), ...widget.people.map( (PersonProfile person) => DropdownMenuItem( value: person.id, child: Text(person.name), ), ), ], onChanged: (String? value) { setState(() { _personId = value; }); }, decoration: const InputDecoration(labelText: 'Person'), ), TextField( controller: _detailsController, maxLines: 4, decoration: const InputDecoration(labelText: 'Details'), ), SwitchListTile.adaptive( value: _archived, onChanged: (bool value) { setState(() { _archived = value; }); }, title: const Text('Archived'), contentPadding: EdgeInsets.zero, ), FilledButton.icon( onPressed: _saving ? null : _save, icon: _saving ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.save_rounded), label: const Text('Save Changes'), ), ], ); } Future _save() async { final String title = _titleController.text.trim(); if (title.isEmpty) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Title is required.'))); return; } setState(() { _saving = true; }); try { await widget.onSave( widget.idea.copyWith( title: title, details: _detailsController.text.trim(), type: _type, personId: _personId, isArchived: _archived, ), ); if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Idea updated.'))); } } finally { if (mounted) { setState(() { _saving = false; }); } } } } class _ReminderRepairForm extends StatefulWidget { const _ReminderRepairForm({ required this.reminder, required this.people, required this.onSave, }); final ReminderRule reminder; final List people; final Future Function(ReminderRule updated) onSave; @override State<_ReminderRepairForm> createState() => _ReminderRepairFormState(); } class _ReminderRepairFormState extends State<_ReminderRepairForm> { late final TextEditingController _titleController; late ReminderCadence _cadence; late DateTime _nextAt; late bool _enabled; String? _personId; bool _saving = false; @override void initState() { super.initState(); _titleController = TextEditingController(text: widget.reminder.title); _cadence = widget.reminder.cadence; _nextAt = widget.reminder.nextAt; _enabled = widget.reminder.enabled; _personId = widget.reminder.personId; } @override void dispose() { _titleController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return _RepairFormScaffold( children: [ TextField( controller: _titleController, decoration: const InputDecoration(labelText: 'Title'), ), DropdownButtonFormField( initialValue: _cadence, items: ReminderCadence.values .map( (ReminderCadence cadence) => DropdownMenuItem( value: cadence, child: Text(_labelForCadence(cadence)), ), ) .toList(growable: false), onChanged: (ReminderCadence? value) { if (value == null) { return; } setState(() { _cadence = value; }); }, decoration: const InputDecoration(labelText: 'Cadence'), ), DropdownButtonFormField( initialValue: _personId, items: >[ const DropdownMenuItem( value: null, child: Text('Unassigned'), ), ...widget.people.map( (PersonProfile person) => DropdownMenuItem( value: person.id, child: Text(person.name), ), ), ], onChanged: (String? value) { setState(() { _personId = value; }); }, decoration: const InputDecoration(labelText: 'Person'), ), Wrap( spacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ Text('Next at: ${_formatDateTime(_nextAt)}'), TextButton.icon( onPressed: _pickDateTime, icon: const Icon(Icons.schedule_rounded), label: const Text('Change'), ), ], ), SwitchListTile.adaptive( value: _enabled, onChanged: (bool value) { setState(() { _enabled = value; }); }, title: const Text('Enabled'), contentPadding: EdgeInsets.zero, ), FilledButton.icon( onPressed: _saving ? null : _save, icon: _saving ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.save_rounded), label: const Text('Save Changes'), ), ], ); } Future _pickDateTime() async { final DateTime? date = await showDatePicker( context: context, initialDate: _nextAt, firstDate: DateTime.now().subtract(const Duration(days: 365 * 3)), lastDate: DateTime.now().add(const Duration(days: 365 * 10)), ); if (date == null || !mounted) { return; } final TimeOfDay? time = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(_nextAt), ); if (time == null || !mounted) { return; } setState(() { _nextAt = DateTime( date.year, date.month, date.day, time.hour, time.minute, ); }); } Future _save() async { final String title = _titleController.text.trim(); if (title.isEmpty) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Title is required.'))); return; } setState(() { _saving = true; }); try { await widget.onSave( widget.reminder.copyWith( title: title, cadence: _cadence, nextAt: _nextAt, enabled: _enabled, personId: _personId, ), ); if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Reminder updated.'))); } } finally { if (mounted) { setState(() { _saving = false; }); } } } } class _RepairFormScaffold extends StatelessWidget { const _RepairFormScaffold({required this.children}); final List children; @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 12, 16, 20), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 680), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: children .expand((Widget child) sync* { yield child; yield const SizedBox(height: 12); }) .toList(growable: false), ), ), ); } } String _labelForCadence(ReminderCadence cadence) { switch (cadence) { case ReminderCadence.daily: return 'Daily'; case ReminderCadence.weekly: return 'Weekly'; case ReminderCadence.monthly: return 'Monthly'; } } String _formatDateTime(DateTime value) { 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'; }