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 - 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. 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 ## Latest Milestone (2026-02-18): Duplicate Profile Merge + Share Deep-Link Polish
Improved identity continuity from WhatsApp intake with merge tooling and direct 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 { class _PersonInsightsPage extends ConsumerWidget {
const _PersonInsightsPage({required this.personId}); const _PersonInsightsPage({required this.personId});
@@ -777,7 +779,13 @@ class _PersonInsightsPage extends ConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
_personProfileCard(context, person, compact), _personProfileCard(
context,
person,
compact,
onQuickAction: (_InsightQuickAction action) =>
_handleQuickAction(context, ref, person, action),
),
const SizedBox(height: 14), const SizedBox(height: 14),
_InsightSectionCard( _InsightSectionCard(
title: 'Ideas', title: 'Ideas',
@@ -899,8 +907,9 @@ class _PersonInsightsPage extends ConsumerWidget {
Widget _personProfileCard( Widget _personProfileCard(
BuildContext context, BuildContext context,
PersonProfile person, PersonProfile person,
bool compact, bool compact, {
) { required ValueChanged<_InsightQuickAction> onQuickAction,
}) {
final _RelationshipVisual visual = _relationshipVisualFor( final _RelationshipVisual visual = _relationshipVisualFor(
person.relationship, 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), const SizedBox(height: 10),
@@ -985,6 +1018,31 @@ class _PersonInsightsPage extends ConsumerWidget {
label: 'Next Moment', label: 'Next Moment',
value: _shortDate(person.nextMoment), 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>[ 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({ Widget _insightAvatar({
required PersonProfile person, required PersonProfile person,
required _RelationshipVisual visual, 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 { class _InsightSectionCard extends StatelessWidget {
const _InsightSectionCard({ const _InsightSectionCard({
required this.title, required this.title,
@@ -41,6 +41,7 @@ void main() {
await tester.pumpAndSettle(const Duration(milliseconds: 700)); await tester.pumpAndSettle(const Duration(milliseconds: 700));
expect(find.text('Person Insights'), findsOneWidget); expect(find.text('Person Insights'), findsOneWidget);
expect(find.byTooltip('Quick add'), findsOneWidget);
expect(find.text('Promotions & Signals'), findsOneWidget); expect(find.text('Promotions & Signals'), findsOneWidget);
expect(find.text('Ideas'), findsOneWidget); expect(find.text('Ideas'), findsOneWidget);
expect(find.text('Moments'), findsOneWidget); expect(find.text('Moments'), findsOneWidget);