From 030f6857491cd88842390e2720f3b823e52c5adc Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Thu, 19 Feb 2026 01:09:10 +0100 Subject: [PATCH] Add quick-add actions to graph person insights --- docs/progress.md | 19 + lib/features/dashboard/dashboard_view.dart | 417 +++++++++++++++++- .../dashboard_graph_interactions_test.dart | 1 + 3 files changed, 434 insertions(+), 3 deletions(-) diff --git a/docs/progress.md b/docs/progress.md index f73d93e..60643ed 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -7,6 +7,25 @@ Updated: 2026-02-18 - After every sensible code/documentation change set, create a git commit as the last step so the next agent session can pick up from clean checkpoints. +## Latest Milestone (2026-02-18): Quick-Add Actions on Graph Person Insights + +Closed a UX gap where long-pressing a graph node opened person insights without +the expected context add action. + +- Added `Quick add` menu to person profile header in graph-based insights page: + - `lib/features/dashboard/dashboard_view.dart` + - supports: + - Add Capture + - Add Note + - Add Idea + - Add Reminder +- Wired actions directly to `LocalRepository` so entries are immediately written + to local-first state and reflected in insights sections. +- Added local dialogs for quick idea/reminder creation in insights flow. +- Updated dashboard interaction coverage: + - `test/features/dashboard/dashboard_graph_interactions_test.dart` + - now asserts `Quick add` is present after opening `Person Insights`. + ## Latest Milestone (2026-02-18): Duplicate Profile Merge + Share Deep-Link Polish Improved identity continuity from WhatsApp intake with merge tooling and direct diff --git a/lib/features/dashboard/dashboard_view.dart b/lib/features/dashboard/dashboard_view.dart index 4206943..78af9a6 100644 --- a/lib/features/dashboard/dashboard_view.dart +++ b/lib/features/dashboard/dashboard_view.dart @@ -697,6 +697,8 @@ class _RelationshipLegendPill extends StatelessWidget { } } +enum _InsightQuickAction { capture, note, idea, reminder } + class _PersonInsightsPage extends ConsumerWidget { const _PersonInsightsPage({required this.personId}); @@ -777,7 +779,13 @@ class _PersonInsightsPage extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _personProfileCard(context, person, compact), + _personProfileCard( + context, + person, + compact, + onQuickAction: (_InsightQuickAction action) => + _handleQuickAction(context, ref, person, action), + ), const SizedBox(height: 14), _InsightSectionCard( title: 'Ideas', @@ -899,8 +907,9 @@ class _PersonInsightsPage extends ConsumerWidget { Widget _personProfileCard( BuildContext context, PersonProfile person, - bool compact, - ) { + bool compact, { + required ValueChanged<_InsightQuickAction> onQuickAction, + }) { final _RelationshipVisual visual = _relationshipVisualFor( person.relationship, ); @@ -934,6 +943,30 @@ class _PersonInsightsPage extends ConsumerWidget { ], ), ), + PopupMenuButton<_InsightQuickAction>( + tooltip: 'Quick add', + onSelected: onQuickAction, + itemBuilder: (BuildContext context) => + const >[ + PopupMenuItem<_InsightQuickAction>( + value: _InsightQuickAction.capture, + child: Text('Add Capture'), + ), + PopupMenuItem<_InsightQuickAction>( + value: _InsightQuickAction.note, + child: Text('Add Note'), + ), + PopupMenuItem<_InsightQuickAction>( + value: _InsightQuickAction.idea, + child: Text('Add Idea'), + ), + PopupMenuItem<_InsightQuickAction>( + value: _InsightQuickAction.reminder, + child: Text('Add Reminder'), + ), + ], + icon: const Icon(Icons.add_circle_outline_rounded), + ), ], ), const SizedBox(height: 10), @@ -985,6 +1018,31 @@ class _PersonInsightsPage extends ConsumerWidget { label: 'Next Moment', value: _shortDate(person.nextMoment), ), + const SizedBox(width: 8), + PopupMenuButton<_InsightQuickAction>( + tooltip: 'Quick add', + onSelected: onQuickAction, + itemBuilder: (BuildContext context) => + const >[ + PopupMenuItem<_InsightQuickAction>( + value: _InsightQuickAction.capture, + child: Text('Add Capture'), + ), + PopupMenuItem<_InsightQuickAction>( + value: _InsightQuickAction.note, + child: Text('Add Note'), + ), + PopupMenuItem<_InsightQuickAction>( + value: _InsightQuickAction.idea, + child: Text('Add Idea'), + ), + PopupMenuItem<_InsightQuickAction>( + value: _InsightQuickAction.reminder, + child: Text('Add Reminder'), + ), + ], + icon: const Icon(Icons.add_circle_outline_rounded), + ), ], ), if (person.tags.isNotEmpty) ...[ @@ -1011,6 +1069,120 @@ class _PersonInsightsPage extends ConsumerWidget { ); } + Future _handleQuickAction( + BuildContext context, + WidgetRef ref, + PersonProfile person, + _InsightQuickAction action, + ) async { + switch (action) { + case _InsightQuickAction.capture: + final String? summary = await _showQuickTextCaptureDialog( + context, + title: 'Add Capture', + hintText: 'What happened in this interaction?', + ); + if (summary == null) { + return; + } + await ref + .read(localRepositoryProvider.notifier) + .addMoment(personId: person.id, summary: summary, type: 'capture'); + return; + case _InsightQuickAction.note: + final String? note = await _showQuickTextCaptureDialog( + context, + title: 'Add Note', + hintText: 'Write a quick context note...', + ); + if (note == null) { + return; + } + await ref + .read(localRepositoryProvider.notifier) + .addMoment(personId: person.id, summary: note, type: 'note'); + return; + case _InsightQuickAction.idea: + final _InsightQuickIdeaDraft? idea = + await showDialog<_InsightQuickIdeaDraft>( + context: context, + builder: (BuildContext context) => + const _InsightQuickIdeaDialog(), + ); + if (idea == null) { + return; + } + await ref + .read(localRepositoryProvider.notifier) + .addIdea( + type: idea.type, + title: idea.title, + details: idea.details, + personId: person.id, + ); + return; + case _InsightQuickAction.reminder: + final _InsightQuickReminderDraft? reminder = + await showDialog<_InsightQuickReminderDraft>( + context: context, + builder: (BuildContext context) => + const _InsightQuickReminderDialog(), + ); + if (reminder == null) { + return; + } + await ref + .read(localRepositoryProvider.notifier) + .addReminder( + title: reminder.title, + cadence: reminder.cadence, + nextAt: reminder.nextAt, + personId: person.id, + ); + return; + } + } + + Future _showQuickTextCaptureDialog( + BuildContext context, { + required String title, + required String hintText, + }) async { + final TextEditingController controller = TextEditingController(); + final String? result = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + minLines: 2, + maxLines: 4, + decoration: InputDecoration(hintText: hintText), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final String value = controller.text.trim(); + if (value.isEmpty) { + return; + } + Navigator.of(context).pop(value); + }, + child: const Text('Save'), + ), + ], + ); + }, + ); + controller.dispose(); + return result; + } + Widget _insightAvatar({ required PersonProfile person, required _RelationshipVisual visual, @@ -1146,6 +1318,245 @@ class _PersonInsightsPage extends ConsumerWidget { } } +class _InsightQuickIdeaDraft { + const _InsightQuickIdeaDraft({ + required this.type, + required this.title, + required this.details, + }); + + final IdeaType type; + final String title; + final String details; +} + +class _InsightQuickIdeaDialog extends StatefulWidget { + const _InsightQuickIdeaDialog(); + + @override + State<_InsightQuickIdeaDialog> createState() => + _InsightQuickIdeaDialogState(); +} + +class _InsightQuickIdeaDialogState extends State<_InsightQuickIdeaDialog> { + IdeaType _type = IdeaType.gift; + final TextEditingController _titleController = TextEditingController(); + final TextEditingController _detailsController = TextEditingController(); + + @override + void dispose() { + _titleController.dispose(); + _detailsController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Add Idea'), + content: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SegmentedButton( + segments: const >[ + ButtonSegment( + value: IdeaType.gift, + label: Text('Gift'), + ), + ButtonSegment( + value: IdeaType.event, + label: Text('Event'), + ), + ], + selected: {_type}, + onSelectionChanged: (Set values) { + setState(() { + _type = values.first; + }); + }, + ), + const SizedBox(height: 10), + TextField( + controller: _titleController, + decoration: const InputDecoration(labelText: 'Title'), + ), + TextField( + controller: _detailsController, + maxLines: 3, + decoration: const InputDecoration(labelText: 'Details'), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final String title = _titleController.text.trim(); + if (title.isEmpty) { + return; + } + Navigator.of(context).pop( + _InsightQuickIdeaDraft( + type: _type, + title: title, + details: _detailsController.text.trim(), + ), + ); + }, + child: const Text('Add'), + ), + ], + ); + } +} + +class _InsightQuickReminderDraft { + const _InsightQuickReminderDraft({ + required this.title, + required this.cadence, + required this.nextAt, + }); + + final String title; + final ReminderCadence cadence; + final DateTime nextAt; +} + +class _InsightQuickReminderDialog extends StatefulWidget { + const _InsightQuickReminderDialog(); + + @override + State<_InsightQuickReminderDialog> createState() => + _InsightQuickReminderDialogState(); +} + +class _InsightQuickReminderDialogState + extends State<_InsightQuickReminderDialog> { + final TextEditingController _titleController = TextEditingController(); + ReminderCadence _cadence = ReminderCadence.weekly; + DateTime _nextAt = DateTime.now().add(const Duration(days: 1)); + + @override + void dispose() { + _titleController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Add Reminder'), + content: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _titleController, + decoration: const InputDecoration(labelText: 'Title'), + ), + const SizedBox(height: 10), + DropdownButtonFormField( + initialValue: _cadence, + decoration: const InputDecoration(labelText: 'Cadence'), + items: ReminderCadence.values + .map( + (ReminderCadence cadence) => + DropdownMenuItem( + value: cadence, + child: Text(cadence.name), + ), + ) + .toList(growable: false), + onChanged: (ReminderCadence? value) { + if (value == null) { + return; + } + setState(() { + _cadence = value; + }); + }, + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: Text( + 'Next: ${_nextAt.year}-${_nextAt.month.toString().padLeft(2, '0')}-${_nextAt.day.toString().padLeft(2, '0')} ${_nextAt.hour.toString().padLeft(2, '0')}:${_nextAt.minute.toString().padLeft(2, '0')}', + ), + ), + TextButton( + onPressed: () async { + final DateTime now = DateTime.now(); + final DateTime? date = await showDatePicker( + context: context, + firstDate: now, + lastDate: now.add(const Duration(days: 3650)), + initialDate: _nextAt, + ); + if (date == null || !context.mounted) { + return; + } + final TimeOfDay? time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(_nextAt), + ); + if (time == null) { + return; + } + setState(() { + _nextAt = DateTime( + date.year, + date.month, + date.day, + time.hour, + time.minute, + ); + }); + }, + child: const Text('Change'), + ), + ], + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + final String title = _titleController.text.trim(); + if (title.isEmpty) { + return; + } + Navigator.of(context).pop( + _InsightQuickReminderDraft( + title: title, + cadence: _cadence, + nextAt: _nextAt, + ), + ); + }, + child: const Text('Add'), + ), + ], + ); + } +} + class _InsightSectionCard extends StatelessWidget { const _InsightSectionCard({ required this.title, diff --git a/test/features/dashboard/dashboard_graph_interactions_test.dart b/test/features/dashboard/dashboard_graph_interactions_test.dart index 64fae2e..f9864af 100644 --- a/test/features/dashboard/dashboard_graph_interactions_test.dart +++ b/test/features/dashboard/dashboard_graph_interactions_test.dart @@ -41,6 +41,7 @@ void main() { await tester.pumpAndSettle(const Duration(milliseconds: 700)); expect(find.text('Person Insights'), findsOneWidget); + expect(find.byTooltip('Quick add'), findsOneWidget); expect(find.text('Promotions & Signals'), findsOneWidget); expect(find.text('Ideas'), findsOneWidget); expect(find.text('Moments'), findsOneWidget);