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
+260
View File
@@ -766,6 +766,14 @@ class _PersonInsightsPage extends ConsumerWidget {
(ReminderRule a, ReminderRule b) =>
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(
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
? <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(
title: 'Ideas',
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(
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<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 {
const _InsightQuickIdeaDraft({
required this.type,