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
|
- 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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user