Add preference review UI to person insights
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
await tester.pumpWidget(
|
final ProviderContainer container = ProviderContainer(
|
||||||
ProviderScope(
|
|
||||||
overrides: [
|
overrides: [
|
||||||
localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()),
|
localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()),
|
||||||
syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()),
|
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(
|
||||||
|
UncontrolledProviderScope(
|
||||||
|
container: container,
|
||||||
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));
|
||||||
|
|||||||
Reference in New Issue
Block a user