Add preference review UI to person insights
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user