diff --git a/docs/progress.md b/docs/progress.md index b09dca1..3c2f69f 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -2,6 +2,26 @@ Updated: 2026-02-15 +## Latest Milestone (2026-02-15): Direct Entity Repair Forms From Sync + +Implemented deep-link repair forms for rejected sync items: + +- Updated `lib/features/sync/sync_view.dart`: + - `Open Related Screen` now routes into dedicated entity-specific repair + forms, not just general list screens + - supported entity repair forms: + - `person` + - `capture` + - `giftIdea`/`eventIdea` + - `reminderRule` + - each form updates local state via `LocalRepository` directly + - focus banner keeps exact `entityType:entityId` context with copy action + +Validation for this milestone: + +- `flutter analyze` -> pass +- `flutter test` -> pass + ## Latest Milestone (2026-02-15): Notification Tap Routing Implemented initial in-app routing for reminder notification taps: @@ -264,8 +284,9 @@ Validation for this milestone: - permission UX implemented in settings. - notification tap routing implemented for reminder screen handoff. 2. Conflict resolution UX: - - reject/requeue + payload inspect + related-screen navigation implemented. - - future enhancement: deep-link directly to specific entity edit form. + - reject/requeue + payload inspect + direct entity repair forms implemented. + - future enhancement: auto-select and highlight the exact row in main feature + screens after save. 3. Sync trigger maturity: - implemented via reachability gating; future improvement is explicit OS connectivity event subscription. diff --git a/lib/features/sync/sync_view.dart b/lib/features/sync/sync_view.dart index 84bbcd3..f72a8dd 100644 --- a/lib/features/sync/sync_view.dart +++ b/lib/features/sync/sync_view.dart @@ -4,10 +4,8 @@ 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/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/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'; @@ -214,7 +212,7 @@ class _SyncViewState extends ConsumerState { ), onOpenEntity: matched != null && - _entityViewForType(matched.entityType) != null + _supportsEntityRepair(matched.entityType) ? () => _openEntityViewFor(matched) : null, ), @@ -512,8 +510,7 @@ class _SyncViewState extends ConsumerState { } Future _openEntityViewFor(ChangeEnvelope matched) async { - final Widget? view = _entityViewForType(matched.entityType); - if (view == null) { + if (!_supportsEntityRepair(matched.entityType)) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('No editor available for ${matched.entityType}.'), @@ -528,25 +525,21 @@ class _SyncViewState extends ConsumerState { title: _titleForEntityType(matched.entityType), entityType: matched.entityType, entityId: matched.entityId, - child: view, ), ), ); } - Widget? _entityViewForType(String entityType) { + bool _supportsEntityRepair(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(); + return true; default: - return null; + return false; } } @@ -567,21 +560,23 @@ class _SyncViewState extends ConsumerState { } } -class _EntityResolveScreen extends StatelessWidget { +class _EntityResolveScreen extends ConsumerWidget { const _EntityResolveScreen({ required this.title, required this.entityType, required this.entityId, - required this.child, }); final String title; final String entityType; final String entityId; - final Widget child; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final AsyncValue localData = ref.watch( + localRepositoryProvider, + ); + return Scaffold( appBar: AppBar(title: Text(title)), body: Column( @@ -613,9 +608,786 @@ class _EntityResolveScreen extends StatelessWidget { ], ), ), - Expanded(child: child), + 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'; }