Add quick-add actions to graph person insights
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user