From 1f31a0e76980a78eab288039982b851880b796a2 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sun, 22 Feb 2026 23:46:41 +0100 Subject: [PATCH] Add preference review UI to person insights --- docs/progress.md | 35 +++ lib/features/dashboard/dashboard_view.dart | 260 ++++++++++++++++++ lib/features/people/people_view.dart | 258 ++++++++++++++++- .../dashboard_graph_interactions_test.dart | 59 +++- 4 files changed, 605 insertions(+), 7 deletions(-) diff --git a/docs/progress.md b/docs/progress.md index 0c368b7..33f3227 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -7,6 +7,41 @@ Updated: 2026-02-22 - 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-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 Implemented the first local semantic-enrichment pass so shared chat messages can diff --git a/lib/features/dashboard/dashboard_view.dart b/lib/features/dashboard/dashboard_view.dart index 646dc1d..7c0130e 100644 --- a/lib/features/dashboard/dashboard_view.dart +++ b/lib/features/dashboard/dashboard_view.dart @@ -766,6 +766,14 @@ class _PersonInsightsPage extends ConsumerWidget { (ReminderRule a, ReminderRule b) => a.nextAt.compareTo(b.nextAt), ); + final List preferenceSignals = + data.preferenceSignals + .where( + (PersonPreferenceSignal signal) => + signal.personId == person.id, + ) + .toList(growable: false) + ..sort(_comparePreferenceSignals); final List relatedTasks = _findTasksForPerson( data.tasks, person, @@ -798,6 +806,47 @@ class _PersonInsightsPage extends ConsumerWidget { _handleQuickAction(context, ref, person, action), ), const SizedBox(height: 14), + _InsightSectionCard( + title: 'Chat-derived preferences', + icon: Icons.psychology_alt_outlined, + children: preferenceSignals.isEmpty + ? [ + 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( title: 'Ideas', icon: Icons.lightbulb_outline_rounded, @@ -915,6 +964,68 @@ class _PersonInsightsPage extends ConsumerWidget { ); } + Future _showPreferenceSignalEvidence( + BuildContext context, { + required PersonPreferenceSignal signal, + required String personName, + }) { + return showModalBottomSheet( + 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: [ + 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( BuildContext context, 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 Function() onConfirm; + final Future 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: [ + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + 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: [ + 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) ...[ + 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 { const _InsightQuickIdeaDraft({ required this.type, diff --git a/lib/features/people/people_view.dart b/lib/features/people/people_view.dart index e2c0862..83b9889 100644 --- a/lib/features/people/people_view.dart +++ b/lib/features/people/people_view.dart @@ -956,7 +956,7 @@ class _FilterChipButton extends StatelessWidget { } } -class _PersonDetail extends StatelessWidget { +class _PersonDetail extends ConsumerWidget { const _PersonDetail({ required this.person, required this.data, @@ -974,7 +974,7 @@ class _PersonDetail extends StatelessWidget { final bool compact; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final List moments = data.moments .where((RelationshipMoment moment) => moment.personId == person.id) @@ -1014,6 +1014,13 @@ class _PersonDetail extends StatelessWidget { (SourceProfileLink a, SourceProfileLink b) => b.lastSeenAt.compareTo(a.lastSeenAt), ); + final List preferenceSignals = + data.preferenceSignals + .where( + (PersonPreferenceSignal signal) => signal.personId == person.id, + ) + .toList(growable: false) + ..sort(_peoplePreferenceSignalSort); final bool hasNotes = person.notes.trim().isNotEmpty; final String? location = person.location?.trim().isEmpty ?? true ? null @@ -1102,6 +1109,42 @@ class _PersonDetail extends StatelessWidget { ], ), const SizedBox(height: 12), + _PeopleInsightSectionCard( + title: 'Chat-derived preferences', + icon: Icons.psychology_alt_outlined, + children: preferenceSignals.isEmpty + ? const [ + _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( title: 'Ideas', icon: Icons.lightbulb_outline_rounded, @@ -1203,6 +1246,217 @@ class _PersonDetail extends StatelessWidget { } } +Future _showPeoplePreferenceEvidence( + BuildContext context, { + required PersonPreferenceSignal signal, + required String personName, +}) { + return showModalBottomSheet( + 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: [ + 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 Function() onConfirm; + final Future 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: [ + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + 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: [ + 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) ...[ + 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 { const _PersonWorkspaceHero({ required this.person, diff --git a/test/features/dashboard/dashboard_graph_interactions_test.dart b/test/features/dashboard/dashboard_graph_interactions_test.dart index cc512a6..bd63f6b 100644 --- a/test/features/dashboard/dashboard_graph_interactions_test.dart +++ b/test/features/dashboard/dashboard_graph_interactions_test.dart @@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:relationship_saver/core/config/app_theme.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_provider.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)); 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( - ProviderScope( - overrides: [ - localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()), - syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()), - ], + UncontrolledProviderScope( + container: container, child: MaterialApp( theme: AppTheme.light(), home: const Scaffold(body: DashboardView()), @@ -44,9 +68,34 @@ void main() { expect(find.byTooltip('Quick add'), findsOneWidget); expect(find.byTooltip('Add to person'), findsOneWidget); expect(find.text('Promotions & Signals'), findsOneWidget); + expect(find.text('Chat-derived preferences'), findsOneWidget); expect(find.text('Ideas'), findsOneWidget); expect(find.text('Moments'), 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.pumpAndSettle(const Duration(milliseconds: 500));