Add quick-add actions to graph person insights

This commit is contained in:
Rijad Zuzo
2026-02-19 01:09:10 +01:00
parent 103ed089fb
commit 030f685749
3 changed files with 434 additions and 3 deletions
+19
View File
@@ -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
+414 -3
View File
@@ -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: <Widget>[
_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 <PopupMenuEntry<_InsightQuickAction>>[
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 <PopupMenuEntry<_InsightQuickAction>>[
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) ...<Widget>[
@@ -1011,6 +1069,120 @@ class _PersonInsightsPage extends ConsumerWidget {
);
}
Future<void> _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<String?> _showQuickTextCaptureDialog(
BuildContext context, {
required String title,
required String hintText,
}) async {
final TextEditingController controller = TextEditingController();
final String? result = await showDialog<String>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(title),
content: TextField(
controller: controller,
minLines: 2,
maxLines: 4,
decoration: InputDecoration(hintText: hintText),
),
actions: <Widget>[
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: <Widget>[
SegmentedButton<IdeaType>(
segments: const <ButtonSegment<IdeaType>>[
ButtonSegment<IdeaType>(
value: IdeaType.gift,
label: Text('Gift'),
),
ButtonSegment<IdeaType>(
value: IdeaType.event,
label: Text('Event'),
),
],
selected: <IdeaType>{_type},
onSelectionChanged: (Set<IdeaType> 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: <Widget>[
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: <Widget>[
TextField(
controller: _titleController,
decoration: const InputDecoration(labelText: 'Title'),
),
const SizedBox(height: 10),
DropdownButtonFormField<ReminderCadence>(
initialValue: _cadence,
decoration: const InputDecoration(labelText: 'Cadence'),
items: ReminderCadence.values
.map(
(ReminderCadence cadence) =>
DropdownMenuItem<ReminderCadence>(
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: <Widget>[
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: <Widget>[
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,
@@ -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);