Add preference review UI to person insights

This commit is contained in:
Rijad Zuzo
2026-02-22 23:46:41 +01:00
parent 5ffe970179
commit 1f31a0e769
4 changed files with 605 additions and 7 deletions
+35
View File
@@ -7,6 +7,41 @@ Updated: 2026-02-22
- 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-22): Preference Review UI (People + Graph Insights)
Exposed the new chat-derived preference signals in the main person detail
surfaces and added a user review loop to confirm or dismiss inferred context.
- `lib/features/people/people_view.dart`
- added `Chat-derived preferences` section to the People detail workspace
- shows inferred/confirmed/dismissed signals sorted by status/confidence
- per-signal actions:
- `Why?` (evidence preview bottom sheet)
- `Confirm`
- `Dismiss`
- evidence preview includes:
- signal metadata (category/polarity/confidence)
- evidence snippets extracted from shared chat messages
- source app list
- added explicit `Close` action in evidence sheet (better UX + testability)
- `lib/features/dashboard/dashboard_view.dart`
- added matching `Chat-derived preferences` section to graph long-press
`Person Insights` page
- same confirm/dismiss/why review flow and evidence preview
- keeps profile review experience aligned between People page and graph flow
- `test/features/dashboard/dashboard_graph_interactions_test.dart`
- extended graph interaction test to:
- seed an inferred preference signal
- assert section renders in `Person Insights`
- open `Why?` evidence preview
- confirm signal and verify local status updates to `confirmed`
- Validation
- `flutter analyze` -> pass
- `flutter test` -> pass
## Latest Milestone (2026-02-22): MVP Chat Preference Extractor + Ingest Enrichment ## Latest Milestone (2026-02-22): MVP Chat Preference Extractor + Ingest Enrichment
Implemented the first local semantic-enrichment pass so shared chat messages can Implemented the first local semantic-enrichment pass so shared chat messages can
+260
View File
@@ -766,6 +766,14 @@ class _PersonInsightsPage extends ConsumerWidget {
(ReminderRule a, ReminderRule b) => (ReminderRule a, ReminderRule b) =>
a.nextAt.compareTo(b.nextAt), a.nextAt.compareTo(b.nextAt),
); );
final List<PersonPreferenceSignal> preferenceSignals =
data.preferenceSignals
.where(
(PersonPreferenceSignal signal) =>
signal.personId == person.id,
)
.toList(growable: false)
..sort(_comparePreferenceSignals);
final List<DashboardTask> relatedTasks = _findTasksForPerson( final List<DashboardTask> relatedTasks = _findTasksForPerson(
data.tasks, data.tasks,
person, person,
@@ -798,6 +806,47 @@ class _PersonInsightsPage extends ConsumerWidget {
_handleQuickAction(context, ref, person, action), _handleQuickAction(context, ref, person, action),
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
_InsightSectionCard(
title: 'Chat-derived preferences',
icon: Icons.psychology_alt_outlined,
children: preferenceSignals.isEmpty
? <Widget>[
const _SectionEmpty(
label:
'No inferred preferences yet. Share chat messages to build context gradually.',
),
]
: preferenceSignals
.map(
(
PersonPreferenceSignal signal,
) => _InsightPreferenceSignalCard(
signal: signal,
compact: compact,
onConfirm: () async {
await ref
.read(
localRepositoryProvider.notifier,
)
.confirmPreferenceSignal(signal.id);
},
onDismiss: () async {
await ref
.read(
localRepositoryProvider.notifier,
)
.dismissPreferenceSignal(signal.id);
},
onWhy: () => _showPreferenceSignalEvidence(
context,
signal: signal,
personName: person.name,
),
),
)
.toList(growable: false),
),
const SizedBox(height: 12),
_InsightSectionCard( _InsightSectionCard(
title: 'Ideas', title: 'Ideas',
icon: Icons.lightbulb_outline_rounded, icon: Icons.lightbulb_outline_rounded,
@@ -915,6 +964,68 @@ class _PersonInsightsPage extends ConsumerWidget {
); );
} }
Future<void> _showPreferenceSignalEvidence(
BuildContext context, {
required PersonPreferenceSignal signal,
required String personName,
}) {
return showModalBottomSheet<void>(
context: context,
useSafeArea: true,
showDragHandle: true,
builder: (BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Why this signal?',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 6),
Text(
'${signal.label}${signal.category}${signal.polarity.name.toUpperCase()}',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 4),
Text(
'For $personName • confidence ${(signal.confidence * 100).round()}%',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 12),
if (signal.evidenceSnippets.isEmpty)
const _SectionEmpty(
label: 'No evidence snippets stored yet for this signal.',
)
else
...signal.evidenceSnippets.map(
(String snippet) => _InsightRow(
title: 'Evidence',
subtitle: snippet,
meta: 'Source(s): ${signal.sourceApps.join(', ')}',
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
),
],
),
);
},
);
}
Widget _personProfileCard( Widget _personProfileCard(
BuildContext context, BuildContext context,
PersonProfile person, PersonProfile person,
@@ -1382,6 +1493,155 @@ class _PersonInsightsPage extends ConsumerWidget {
} }
} }
int _comparePreferenceSignals(
PersonPreferenceSignal a,
PersonPreferenceSignal b,
) {
int statusRank(PreferenceSignalStatus status) => switch (status) {
PreferenceSignalStatus.inferred => 0,
PreferenceSignalStatus.confirmed => 1,
PreferenceSignalStatus.dismissed => 2,
};
final int byStatus = statusRank(a.status).compareTo(statusRank(b.status));
if (byStatus != 0) {
return byStatus;
}
final int byConfidence = b.confidence.compareTo(a.confidence);
if (byConfidence != 0) {
return byConfidence;
}
return a.label.toLowerCase().compareTo(b.label.toLowerCase());
}
class _InsightPreferenceSignalCard extends StatelessWidget {
const _InsightPreferenceSignalCard({
required this.signal,
required this.compact,
required this.onConfirm,
required this.onDismiss,
required this.onWhy,
});
final PersonPreferenceSignal signal;
final bool compact;
final Future<void> Function() onConfirm;
final Future<void> Function() onDismiss;
final VoidCallback onWhy;
@override
Widget build(BuildContext context) {
final Color tone = switch (signal.status) {
PreferenceSignalStatus.confirmed => const Color(0xFF1D9C66),
PreferenceSignalStatus.dismissed => const Color(0xFF9A6A00),
PreferenceSignalStatus.inferred => AppTheme.primary,
};
final IconData polarityIcon = switch (signal.polarity) {
PreferenceSignalPolarity.like => Icons.thumb_up_alt_outlined,
PreferenceSignalPolarity.dislike => Icons.thumb_down_alt_outlined,
PreferenceSignalPolarity.neutral => Icons.tune_rounded,
};
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF5FAFB),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
Icon(polarityIcon, size: 16, color: tone),
Text(
signal.label,
style: Theme.of(context).textTheme.titleSmall,
),
_PreferenceBadge(label: signal.status.name, color: tone),
_PreferenceBadge(
label: '${(signal.confidence * 100).round()}%',
color: AppTheme.textSecondary,
filled: false,
),
],
),
const SizedBox(height: 6),
Text(
'${signal.category.toUpperCase()} • seen ${signal.occurrenceCount}x',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
OutlinedButton(onPressed: onWhy, child: const Text('Why?')),
if (signal.status != PreferenceSignalStatus.confirmed)
FilledButton.tonal(
onPressed: () async => onConfirm(),
child: const Text('Confirm'),
),
if (signal.status != PreferenceSignalStatus.dismissed)
TextButton(
onPressed: () async => onDismiss(),
child: const Text('Dismiss'),
),
],
),
if (!compact && signal.sourceApps.isNotEmpty) ...<Widget>[
const SizedBox(height: 6),
Text(
'Sources: ${signal.sourceApps.join(', ')}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
),
],
],
),
),
);
}
}
class _PreferenceBadge extends StatelessWidget {
const _PreferenceBadge({
required this.label,
required this.color,
this.filled = true,
});
final String label;
final Color color;
final bool filled;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: filled ? color.withValues(alpha: 0.12) : Colors.transparent,
borderRadius: BorderRadius.circular(99),
border: Border.all(color: color.withValues(alpha: filled ? 0.2 : 0.35)),
),
child: Text(
label.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(color: color),
),
);
}
}
class _InsightQuickIdeaDraft { class _InsightQuickIdeaDraft {
const _InsightQuickIdeaDraft({ const _InsightQuickIdeaDraft({
required this.type, required this.type,
+256 -2
View File
@@ -956,7 +956,7 @@ class _FilterChipButton extends StatelessWidget {
} }
} }
class _PersonDetail extends StatelessWidget { class _PersonDetail extends ConsumerWidget {
const _PersonDetail({ const _PersonDetail({
required this.person, required this.person,
required this.data, required this.data,
@@ -974,7 +974,7 @@ class _PersonDetail extends StatelessWidget {
final bool compact; final bool compact;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final List<RelationshipMoment> moments = final List<RelationshipMoment> moments =
data.moments data.moments
.where((RelationshipMoment moment) => moment.personId == person.id) .where((RelationshipMoment moment) => moment.personId == person.id)
@@ -1014,6 +1014,13 @@ class _PersonDetail extends StatelessWidget {
(SourceProfileLink a, SourceProfileLink b) => (SourceProfileLink a, SourceProfileLink b) =>
b.lastSeenAt.compareTo(a.lastSeenAt), b.lastSeenAt.compareTo(a.lastSeenAt),
); );
final List<PersonPreferenceSignal> preferenceSignals =
data.preferenceSignals
.where(
(PersonPreferenceSignal signal) => signal.personId == person.id,
)
.toList(growable: false)
..sort(_peoplePreferenceSignalSort);
final bool hasNotes = person.notes.trim().isNotEmpty; final bool hasNotes = person.notes.trim().isNotEmpty;
final String? location = person.location?.trim().isEmpty ?? true final String? location = person.location?.trim().isEmpty ?? true
? null ? null
@@ -1102,6 +1109,42 @@ class _PersonDetail extends StatelessWidget {
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_PeopleInsightSectionCard(
title: 'Chat-derived preferences',
icon: Icons.psychology_alt_outlined,
children: preferenceSignals.isEmpty
? const <Widget>[
_PeopleSectionEmpty(
label:
'No inferred preferences yet. Share messages from chat apps to build context for this person.',
),
]
: preferenceSignals
.map(
(PersonPreferenceSignal signal) =>
_PeoplePreferenceSignalCard(
signal: signal,
compact: compact,
onWhy: () => _showPeoplePreferenceEvidence(
context,
signal: signal,
personName: person.name,
),
onConfirm: () async {
await ref
.read(localRepositoryProvider.notifier)
.confirmPreferenceSignal(signal.id);
},
onDismiss: () async {
await ref
.read(localRepositoryProvider.notifier)
.dismissPreferenceSignal(signal.id);
},
),
)
.toList(growable: false),
),
const SizedBox(height: 12),
_PeopleInsightSectionCard( _PeopleInsightSectionCard(
title: 'Ideas', title: 'Ideas',
icon: Icons.lightbulb_outline_rounded, icon: Icons.lightbulb_outline_rounded,
@@ -1203,6 +1246,217 @@ class _PersonDetail extends StatelessWidget {
} }
} }
Future<void> _showPeoplePreferenceEvidence(
BuildContext context, {
required PersonPreferenceSignal signal,
required String personName,
}) {
return showModalBottomSheet<void>(
context: context,
useSafeArea: true,
showDragHandle: true,
builder: (BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Why this signal?',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 6),
Text(
'${signal.label}${signal.category}${signal.polarity.name.toUpperCase()}',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 4),
Text(
'For $personName • confidence ${(signal.confidence * 100).round()}%',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 12),
if (signal.evidenceSnippets.isEmpty)
const _PeopleSectionEmpty(
label: 'No evidence snippets stored yet for this signal.',
)
else
...signal.evidenceSnippets.map(
(String snippet) => _PeopleInsightRow(
title: 'Evidence',
subtitle: snippet,
meta: 'Source(s): ${signal.sourceApps.join(', ')}',
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
),
],
),
);
},
);
}
int _peoplePreferenceSignalSort(
PersonPreferenceSignal a,
PersonPreferenceSignal b,
) {
int rank(PreferenceSignalStatus status) => switch (status) {
PreferenceSignalStatus.inferred => 0,
PreferenceSignalStatus.confirmed => 1,
PreferenceSignalStatus.dismissed => 2,
};
final int byStatus = rank(a.status).compareTo(rank(b.status));
if (byStatus != 0) {
return byStatus;
}
final int byConfidence = b.confidence.compareTo(a.confidence);
if (byConfidence != 0) {
return byConfidence;
}
return a.label.toLowerCase().compareTo(b.label.toLowerCase());
}
class _PeoplePreferenceSignalCard extends StatelessWidget {
const _PeoplePreferenceSignalCard({
required this.signal,
required this.compact,
required this.onConfirm,
required this.onDismiss,
required this.onWhy,
});
final PersonPreferenceSignal signal;
final bool compact;
final Future<void> Function() onConfirm;
final Future<void> Function() onDismiss;
final VoidCallback onWhy;
@override
Widget build(BuildContext context) {
final Color tone = switch (signal.status) {
PreferenceSignalStatus.confirmed => const Color(0xFF1D9C66),
PreferenceSignalStatus.dismissed => const Color(0xFFA56A00),
PreferenceSignalStatus.inferred => AppTheme.primary,
};
final IconData polarityIcon = switch (signal.polarity) {
PreferenceSignalPolarity.like => Icons.thumb_up_alt_outlined,
PreferenceSignalPolarity.dislike => Icons.thumb_down_alt_outlined,
PreferenceSignalPolarity.neutral => Icons.tune_rounded,
};
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFEAF1F4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: <Widget>[
Icon(polarityIcon, size: 16, color: tone),
Text(
signal.label,
style: Theme.of(context).textTheme.titleSmall,
),
_PeoplePreferenceBadge(label: signal.status.name, color: tone),
_PeoplePreferenceBadge(
label: '${(signal.confidence * 100).round()}%',
color: AppTheme.textSecondary,
filled: false,
),
],
),
const SizedBox(height: 5),
Text(
'${signal.category.toUpperCase()} • seen ${signal.occurrenceCount}x',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: AppTheme.textSecondary),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
OutlinedButton(onPressed: onWhy, child: const Text('Why?')),
if (signal.status != PreferenceSignalStatus.confirmed)
FilledButton.tonal(
onPressed: () async => onConfirm(),
child: const Text('Confirm'),
),
if (signal.status != PreferenceSignalStatus.dismissed)
TextButton(
onPressed: () async => onDismiss(),
child: const Text('Dismiss'),
),
],
),
if (!compact && signal.sourceApps.isNotEmpty) ...<Widget>[
const SizedBox(height: 4),
Text(
'Sources: ${signal.sourceApps.join(', ')}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary),
),
],
],
),
),
);
}
}
class _PeoplePreferenceBadge extends StatelessWidget {
const _PeoplePreferenceBadge({
required this.label,
required this.color,
this.filled = true,
});
final String label;
final Color color;
final bool filled;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: filled ? color.withValues(alpha: 0.12) : Colors.transparent,
borderRadius: BorderRadius.circular(99),
border: Border.all(color: color.withValues(alpha: filled ? 0.2 : 0.35)),
),
child: Text(
label.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(color: color),
),
);
}
}
class _PersonWorkspaceHero extends StatelessWidget { class _PersonWorkspaceHero extends StatelessWidget {
const _PersonWorkspaceHero({ const _PersonWorkspaceHero({
required this.person, required this.person,
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:relationship_saver/core/config/app_theme.dart'; import 'package:relationship_saver/core/config/app_theme.dart';
import 'package:relationship_saver/features/dashboard/dashboard_view.dart'; import 'package:relationship_saver/features/dashboard/dashboard_view.dart';
import 'package:relationship_saver/features/local/local_models.dart';
import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/local/storage/local_data_store_in_memory.dart'; import 'package:relationship_saver/features/local/storage/local_data_store_in_memory.dart';
import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart'; import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart';
import 'package:relationship_saver/features/sync/storage/sync_state_store_in_memory.dart'; import 'package:relationship_saver/features/sync/storage/sync_state_store_in_memory.dart';
@@ -15,12 +17,34 @@ void main() {
await tester.binding.setSurfaceSize(const Size(390, 844)); await tester.binding.setSurfaceSize(const Size(390, 844));
addTearDown(() => tester.binding.setSurfaceSize(null)); addTearDown(() => tester.binding.setSurfaceSize(null));
final ProviderContainer container = ProviderContainer(
overrides: [
localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()),
syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()),
],
);
addTearDown(container.dispose);
final LocalDataState seeded = await container.read(
localRepositoryProvider.future,
);
await container
.read(localRepositoryProvider.notifier)
.upsertInferredPreferenceSignalObservation(
personId: seeded.people.first.id,
key: 'drink:tea',
label: 'Tea',
category: 'drink',
polarity: PreferenceSignalPolarity.like,
confidence: 0.82,
sourceApp: 'whatsapp',
evidenceMessageId: 'sm-seed-1',
evidenceSnippet: 'I prefer tea over coffee after dinner.',
);
await tester.pumpWidget( await tester.pumpWidget(
ProviderScope( UncontrolledProviderScope(
overrides: [ container: container,
localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()),
syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()),
],
child: MaterialApp( child: MaterialApp(
theme: AppTheme.light(), theme: AppTheme.light(),
home: const Scaffold(body: DashboardView()), home: const Scaffold(body: DashboardView()),
@@ -44,9 +68,34 @@ void main() {
expect(find.byTooltip('Quick add'), findsOneWidget); expect(find.byTooltip('Quick add'), findsOneWidget);
expect(find.byTooltip('Add to person'), findsOneWidget); expect(find.byTooltip('Add to person'), findsOneWidget);
expect(find.text('Promotions & Signals'), findsOneWidget); expect(find.text('Promotions & Signals'), findsOneWidget);
expect(find.text('Chat-derived preferences'), findsOneWidget);
expect(find.text('Ideas'), findsOneWidget); expect(find.text('Ideas'), findsOneWidget);
expect(find.text('Moments'), findsOneWidget); expect(find.text('Moments'), findsOneWidget);
expect(find.text('Reminders'), findsOneWidget); expect(find.text('Reminders'), findsOneWidget);
expect(find.text('Tea'), findsOneWidget);
expect(find.text('Confirm'), findsOneWidget);
expect(find.text('Why?'), findsOneWidget);
await tester.tap(find.text('Why?').first);
await tester.pumpAndSettle(const Duration(milliseconds: 400));
expect(find.text('Why this signal?'), findsOneWidget);
expect(find.textContaining('I prefer tea over coffee'), findsOneWidget);
await tester.tap(find.widgetWithText(TextButton, 'Close'));
await tester.pumpAndSettle(const Duration(milliseconds: 300));
await tester.tap(find.text('Confirm').first);
await tester.pumpAndSettle(const Duration(milliseconds: 350));
final LocalDataState afterConfirm = await container.read(
localRepositoryProvider.future,
);
expect(
afterConfirm.preferenceSignals
.firstWhere(
(PersonPreferenceSignal signal) => signal.key == 'drink:tea',
)
.status,
PreferenceSignalStatus.confirmed,
);
await tester.tap(find.byTooltip('Back')); await tester.tap(find.byTooltip('Back'));
await tester.pumpAndSettle(const Duration(milliseconds: 500)); await tester.pumpAndSettle(const Duration(milliseconds: 500));