f655adfbea
Migrate app code into canonical feature slices, add phone-only AI digest scheduling and review, wire local notification/background task support, and cover the flow with tests.
1034 lines
35 KiB
Dart
1034 lines
35 KiB
Dart
part of 'people_view.dart';
|
|
|
|
class _PersonDetail extends ConsumerWidget {
|
|
const _PersonDetail({
|
|
required this.person,
|
|
required this.data,
|
|
required this.onEdit,
|
|
required this.onDelete,
|
|
required this.onQuickAction,
|
|
this.compact = false,
|
|
});
|
|
|
|
final PersonProfile person;
|
|
final LocalDataState data;
|
|
final VoidCallback onEdit;
|
|
final VoidCallback onDelete;
|
|
final ValueChanged<_PersonQuickAction> onQuickAction;
|
|
final bool compact;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final List<RelationshipMoment> moments =
|
|
data.moments
|
|
.where((RelationshipMoment moment) => moment.personId == person.id)
|
|
.toList(growable: false)
|
|
..sort(
|
|
(RelationshipMoment a, RelationshipMoment b) =>
|
|
b.at.compareTo(a.at),
|
|
);
|
|
final List<RelationshipIdea> ideas =
|
|
data.ideas
|
|
.where((RelationshipIdea idea) => idea.personId == person.id)
|
|
.toList(growable: false)
|
|
..sort(
|
|
(RelationshipIdea a, RelationshipIdea b) =>
|
|
b.createdAt.compareTo(a.createdAt),
|
|
);
|
|
final List<ReminderRule> reminders =
|
|
data.reminders
|
|
.where((ReminderRule reminder) => reminder.personId == person.id)
|
|
.toList(growable: false)
|
|
..sort(
|
|
(ReminderRule a, ReminderRule b) => a.nextAt.compareTo(b.nextAt),
|
|
);
|
|
final List<SharedMessageEntry> sharedMessages =
|
|
data.sharedMessages
|
|
.where((SharedMessageEntry entry) => entry.profileId == person.id)
|
|
.toList(growable: false)
|
|
..sort(
|
|
(SharedMessageEntry a, SharedMessageEntry b) =>
|
|
b.sharedAt.compareTo(a.sharedAt),
|
|
);
|
|
final List<SourceProfileLink> sourceLinks =
|
|
data.sourceLinks
|
|
.where((SourceProfileLink link) => link.profileId == person.id)
|
|
.toList(growable: false)
|
|
..sort(
|
|
(SourceProfileLink a, SourceProfileLink b) =>
|
|
b.lastSeenAt.compareTo(a.lastSeenAt),
|
|
);
|
|
final List<PersonPreferenceSignal> preferenceSignals =
|
|
data.preferenceSignals
|
|
.where(
|
|
(PersonPreferenceSignal signal) => signal.personId == person.id,
|
|
)
|
|
.toList(growable: false)
|
|
..sort(_peoplePreferenceSignalSort);
|
|
final List<PersonFact> personFacts =
|
|
data.personFacts
|
|
.where((PersonFact fact) => fact.personId == person.id)
|
|
.toList(growable: false)
|
|
..sort(
|
|
(PersonFact a, PersonFact b) => b.updatedAt.compareTo(a.updatedAt),
|
|
);
|
|
final List<PersonImportantDate> importantDates =
|
|
data.importantDates
|
|
.where((PersonImportantDate value) => value.personId == person.id)
|
|
.toList(growable: false)
|
|
..sort(
|
|
(PersonImportantDate a, PersonImportantDate b) =>
|
|
a.date.compareTo(b.date),
|
|
);
|
|
final bool hasNotes = person.notes.trim().isNotEmpty;
|
|
final String? location = person.location?.trim().isEmpty ?? true
|
|
? null
|
|
: person.location!.trim();
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
_PersonWorkspaceHero(
|
|
person: person,
|
|
compact: compact,
|
|
sourceLinks: sourceLinks,
|
|
onEdit: onEdit,
|
|
onDelete: onDelete,
|
|
onQuickAction: onQuickAction,
|
|
),
|
|
SizedBox(height: compact ? 12 : 14),
|
|
Wrap(
|
|
spacing: 10,
|
|
runSpacing: 10,
|
|
children: <Widget>[
|
|
_PersonStatTile(
|
|
label: 'Affinity',
|
|
value: '${person.affinityScore}%',
|
|
accent: const Color(0xFF1AB6C8),
|
|
compact: compact,
|
|
),
|
|
_PersonStatTile(
|
|
label: 'Next moment',
|
|
value: _dateTimeLabel(person.nextMoment),
|
|
accent: const Color(0xFF2274E0),
|
|
compact: compact,
|
|
),
|
|
_PersonStatTile(
|
|
label: 'Captures',
|
|
value: '${moments.length}',
|
|
accent: const Color(0xFF23A26D),
|
|
compact: compact,
|
|
),
|
|
_PersonStatTile(
|
|
label: 'Ideas',
|
|
value:
|
|
'${ideas.where((RelationshipIdea e) => !e.isArchived).length}',
|
|
accent: const Color(0xFFF4A524),
|
|
compact: compact,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
_PeopleInsightSectionCard(
|
|
title: 'Profile context',
|
|
icon: Icons.person_outline_rounded,
|
|
children: <Widget>[
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: <Widget>[
|
|
_InlineMetaPill(
|
|
icon: Icons.favorite_outline_rounded,
|
|
label: person.relationship,
|
|
),
|
|
if (location != null)
|
|
_InlineMetaPill(
|
|
icon: Icons.location_on_outlined,
|
|
label: location,
|
|
),
|
|
if (sourceLinks.isNotEmpty)
|
|
_InlineMetaPill(
|
|
icon: Icons.link_rounded,
|
|
label:
|
|
'${sourceLinks.length} linked source${sourceLinks.length == 1 ? '' : 's'}',
|
|
),
|
|
...person.aliases.map(
|
|
(String alias) => _InlineMetaPill(
|
|
icon: Icons.alternate_email_rounded,
|
|
label: alias,
|
|
),
|
|
),
|
|
...person.tags.map(
|
|
(String tag) =>
|
|
_InlineMetaPill(icon: Icons.sell_outlined, label: tag),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
_PeopleInsightBodyBlock(
|
|
text: hasNotes
|
|
? person.notes.trim()
|
|
: 'Add notes about preferences, boundaries, and what matters so future follow-ups stay relevant.',
|
|
muted: !hasNotes,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
_PeopleInsightSectionCard(
|
|
title: 'Captured facts',
|
|
icon: Icons.fact_check_outlined,
|
|
children: personFacts.isEmpty
|
|
? const <Widget>[
|
|
_PeopleSectionEmpty(
|
|
label:
|
|
'No structured facts yet. Shared captures can become notes, likes, dislikes, and ideas here.',
|
|
),
|
|
]
|
|
: personFacts
|
|
.take(compact ? 4 : 6)
|
|
.map(
|
|
(PersonFact fact) => _PeopleInsightRow(
|
|
title: fact.label?.trim().isNotEmpty == true
|
|
? fact.label!.trim()
|
|
: _factTypeLabel(fact.type),
|
|
subtitle: fact.text,
|
|
meta:
|
|
'${_factTypeLabel(fact.type).toUpperCase()} • ${fact.sourceKind.name.toUpperCase()} • ${_shortDateTimeLabel(fact.updatedAt)}${fact.isSensitive ? ' • SENSITIVE' : ''}',
|
|
actions: <Widget>[
|
|
TextButton(
|
|
onPressed: () async {
|
|
final PersonFact? edited =
|
|
await _showPersonFactEditor(
|
|
context,
|
|
fact: fact,
|
|
);
|
|
if (edited == null) {
|
|
return;
|
|
}
|
|
await ref
|
|
.read(localRepositoryProvider.notifier)
|
|
.updatePersonFact(edited);
|
|
},
|
|
child: const Text('Edit'),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
await ref
|
|
.read(localRepositoryProvider.notifier)
|
|
.deletePersonFact(fact.id);
|
|
},
|
|
child: const Text('Delete'),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_PeopleInsightSectionCard(
|
|
title: 'Important dates',
|
|
icon: Icons.event_outlined,
|
|
children: importantDates.isEmpty
|
|
? const <Widget>[
|
|
_PeopleSectionEmpty(
|
|
label:
|
|
'No structured dates yet. Save birthdays, anniversaries, and milestones from shared captures or manual edits.',
|
|
),
|
|
]
|
|
: importantDates
|
|
.take(compact ? 4 : 6)
|
|
.map(
|
|
(PersonImportantDate value) => _PeopleInsightRow(
|
|
title: value.label,
|
|
subtitle: _dateTimeLabel(value.date),
|
|
meta:
|
|
'${value.classification.toUpperCase()} • ${value.sourceKind.name.toUpperCase()}${value.isSensitive ? ' • SENSITIVE' : ''}',
|
|
actions: <Widget>[
|
|
TextButton(
|
|
onPressed: () async {
|
|
final PersonImportantDate? edited =
|
|
await _showImportantDateEditor(
|
|
context,
|
|
value: value,
|
|
);
|
|
if (edited == null) {
|
|
return;
|
|
}
|
|
await ref
|
|
.read(localRepositoryProvider.notifier)
|
|
.updateImportantDate(edited);
|
|
},
|
|
child: const Text('Edit'),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
await ref
|
|
.read(localRepositoryProvider.notifier)
|
|
.deleteImportantDate(value.id);
|
|
},
|
|
child: const Text('Delete'),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
),
|
|
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(
|
|
title: 'Ideas',
|
|
icon: Icons.lightbulb_outline_rounded,
|
|
children: ideas.isEmpty
|
|
? const <Widget>[
|
|
_PeopleSectionEmpty(
|
|
label:
|
|
'No ideas yet. Use Quick add to capture gift or event ideas.',
|
|
),
|
|
]
|
|
: ideas
|
|
.take(compact ? 4 : 6)
|
|
.map(
|
|
(RelationshipIdea idea) => _PeopleInsightRow(
|
|
title: idea.title,
|
|
subtitle: idea.details.isEmpty
|
|
? 'No details yet'
|
|
: idea.details,
|
|
meta:
|
|
'${idea.type.name.toUpperCase()} • ${_shortDateTimeLabel(idea.createdAt)}${idea.isArchived ? ' • ARCHIVED' : ''}',
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_PeopleInsightSectionCard(
|
|
title: 'Moments',
|
|
icon: Icons.auto_awesome_rounded,
|
|
children: moments.isEmpty
|
|
? const <Widget>[
|
|
_PeopleSectionEmpty(
|
|
label: 'No captures logged yet for this person.',
|
|
),
|
|
]
|
|
: moments
|
|
.take(compact ? 4 : 6)
|
|
.map(
|
|
(RelationshipMoment moment) => _PeopleInsightRow(
|
|
title: moment.title,
|
|
subtitle: moment.summary,
|
|
meta:
|
|
'${moment.type.toUpperCase()} • ${_shortDateTimeLabel(moment.at)}',
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_PeopleInsightSectionCard(
|
|
title: 'Reminders',
|
|
icon: Icons.notifications_active_outlined,
|
|
children: reminders.isEmpty
|
|
? const <Widget>[
|
|
_PeopleSectionEmpty(
|
|
label:
|
|
'No reminders yet. Add one to keep the relationship active.',
|
|
),
|
|
]
|
|
: reminders
|
|
.take(compact ? 4 : 6)
|
|
.map(
|
|
(ReminderRule reminder) => _PeopleInsightRow(
|
|
title: reminder.title,
|
|
subtitle: reminder.enabled ? 'Enabled' : 'Disabled',
|
|
meta:
|
|
'${reminder.cadence.name.toUpperCase()} • Next ${_shortDateTimeLabel(reminder.nextAt)}',
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_PeopleInsightSectionCard(
|
|
title: 'Shared message history',
|
|
icon: Icons.chat_bubble_outline_rounded,
|
|
children: sharedMessages.isEmpty
|
|
? const <Widget>[
|
|
_PeopleSectionEmpty(
|
|
label: 'No imported shared messages for this profile yet.',
|
|
),
|
|
]
|
|
: sharedMessages
|
|
.take(compact ? 3 : 5)
|
|
.map(
|
|
(SharedMessageEntry entry) => _PeopleInsightRow(
|
|
title:
|
|
entry.sourceDisplayName?.trim().isNotEmpty == true
|
|
? entry.sourceDisplayName!.trim()
|
|
: 'Shared from ${entry.sourceApp}',
|
|
subtitle: entry.messageText.trim().isEmpty
|
|
? 'Imported message content is empty'
|
|
: entry.messageText.trim(),
|
|
meta:
|
|
'${entry.resolvedAutomatically ? 'AUTO-LINKED' : 'MANUAL-LINK'} • ${_shortDateTimeLabel(entry.sharedAt)}',
|
|
),
|
|
)
|
|
.toList(growable: false),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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());
|
|
}
|
|
|
|
String _factTypeLabel(CapturedFactType type) {
|
|
return switch (type) {
|
|
CapturedFactType.note => 'Note',
|
|
CapturedFactType.like => 'Like',
|
|
CapturedFactType.dislike => 'Dislike',
|
|
CapturedFactType.importantDate => 'Date',
|
|
CapturedFactType.giftIdea => 'Gift Idea',
|
|
CapturedFactType.placeIdea => 'Place Idea',
|
|
CapturedFactType.activityIdea => 'Activity Idea',
|
|
CapturedFactType.misc => 'Misc',
|
|
};
|
|
}
|
|
|
|
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 {
|
|
const _PersonWorkspaceHero({
|
|
required this.person,
|
|
required this.compact,
|
|
required this.sourceLinks,
|
|
required this.onEdit,
|
|
required this.onDelete,
|
|
required this.onQuickAction,
|
|
});
|
|
|
|
final PersonProfile person;
|
|
final bool compact;
|
|
final List<SourceProfileLink> sourceLinks;
|
|
final VoidCallback onEdit;
|
|
final VoidCallback onDelete;
|
|
final ValueChanged<_PersonQuickAction> onQuickAction;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final Widget actions = Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
alignment: WrapAlignment.end,
|
|
children: <Widget>[
|
|
FilledButton.icon(
|
|
onPressed: () => onQuickAction(_PersonQuickAction.capture),
|
|
icon: const Icon(Icons.add_comment_outlined, size: 18),
|
|
label: const Text('Capture'),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: () => onQuickAction(_PersonQuickAction.idea),
|
|
icon: const Icon(Icons.lightbulb_outline_rounded, size: 18),
|
|
label: const Text('Idea'),
|
|
),
|
|
PopupMenuButton<_PersonQuickAction>(
|
|
tooltip: 'More quick add actions',
|
|
onSelected: onQuickAction,
|
|
itemBuilder: (BuildContext context) =>
|
|
const <PopupMenuEntry<_PersonQuickAction>>[
|
|
PopupMenuItem<_PersonQuickAction>(
|
|
value: _PersonQuickAction.capture,
|
|
child: Text('Add Capture'),
|
|
),
|
|
PopupMenuItem<_PersonQuickAction>(
|
|
value: _PersonQuickAction.note,
|
|
child: Text('Add Note'),
|
|
),
|
|
PopupMenuItem<_PersonQuickAction>(
|
|
value: _PersonQuickAction.idea,
|
|
child: Text('Add Idea'),
|
|
),
|
|
PopupMenuItem<_PersonQuickAction>(
|
|
value: _PersonQuickAction.reminder,
|
|
child: Text('Add Reminder'),
|
|
),
|
|
],
|
|
child: const _HeroIconButton(
|
|
icon: Icons.add_circle_outline_rounded,
|
|
tooltip: 'Quick add',
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: onEdit,
|
|
icon: const Icon(Icons.edit_rounded),
|
|
tooltip: 'Edit person',
|
|
),
|
|
IconButton(
|
|
onPressed: onDelete,
|
|
icon: const Icon(Icons.delete_outline_rounded),
|
|
tooltip: 'Delete person',
|
|
),
|
|
],
|
|
);
|
|
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: EdgeInsets.all(compact ? 14 : 16),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(18),
|
|
gradient: const LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: <Color>[Color(0xFFF7FBFD), Color(0xFFEAF4FA)],
|
|
),
|
|
border: Border.all(color: Colors.white.withValues(alpha: 0.9)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
if (compact) ...<Widget>[
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
_AvatarSeed(name: person.name, size: 58),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: _HeroIdentity(person: person)),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
actions,
|
|
] else ...<Widget>[
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
_AvatarSeed(name: person.name, size: 64),
|
|
const SizedBox(width: 14),
|
|
Expanded(child: _HeroIdentity(person: person)),
|
|
const SizedBox(width: 10),
|
|
Flexible(
|
|
child: Align(alignment: Alignment.topRight, child: actions),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
const SizedBox(height: 10),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: <Widget>[
|
|
_InlineMetaPill(
|
|
icon: Icons.schedule_rounded,
|
|
label: 'Next ${_dateTimeLabel(person.nextMoment)}',
|
|
),
|
|
if (sourceLinks.isNotEmpty)
|
|
_InlineMetaPill(
|
|
icon: Icons.link_rounded,
|
|
label:
|
|
'Last linked ${_shortDateTimeLabel(sourceLinks.first.lastSeenAt)}',
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HeroIdentity extends StatelessWidget {
|
|
const _HeroIdentity({required this.person});
|
|
|
|
final PersonProfile person;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Text(
|
|
person.name,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
person.relationship,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HeroIconButton extends StatelessWidget {
|
|
const _HeroIconButton({required this.icon, required this.tooltip});
|
|
|
|
final IconData icon;
|
|
final String tooltip;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Tooltip(
|
|
message: tooltip,
|
|
child: Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF2F7FA),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.white),
|
|
),
|
|
child: Icon(icon, color: AppTheme.primary, size: 20),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PersonStatTile extends StatelessWidget {
|
|
const _PersonStatTile({
|
|
required this.label,
|
|
required this.value,
|
|
required this.accent,
|
|
required this.compact,
|
|
});
|
|
|
|
final String label;
|
|
final String value;
|
|
final Color accent;
|
|
final bool compact;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final double width = compact ? 154 : 172;
|
|
return Container(
|
|
width: width,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withValues(alpha: 0.66),
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(color: accent.withValues(alpha: 0.12)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Text(
|
|
label,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.labelMedium?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
value,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
color: accent,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PeopleInsightSectionCard extends StatelessWidget {
|
|
const _PeopleInsightSectionCard({
|
|
required this.title,
|
|
required this.icon,
|
|
required this.children,
|
|
});
|
|
|
|
final String title;
|
|
final IconData icon;
|
|
final List<Widget> children;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF8FBFD),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: Colors.white.withValues(alpha: 0.9)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Row(
|
|
children: <Widget>[
|
|
Icon(icon, size: 18, color: AppTheme.primary),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
...children,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PeopleInsightRow extends StatelessWidget {
|
|
const _PeopleInsightRow({
|
|
required this.title,
|
|
required this.subtitle,
|
|
required this.meta,
|
|
this.actions = const <Widget>[],
|
|
});
|
|
|
|
final String title;
|
|
final String subtitle;
|
|
final String meta;
|
|
final List<Widget> actions;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
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>[
|
|
Text(
|
|
title,
|
|
style: Theme.of(context).textTheme.titleSmall,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
subtitle,
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
meta,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.labelSmall?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
if (actions.isNotEmpty) ...<Widget>[
|
|
const SizedBox(height: 8),
|
|
Wrap(spacing: 8, runSpacing: 8, children: actions),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PeopleInsightBodyBlock extends StatelessWidget {
|
|
const _PeopleInsightBodyBlock({required this.text, required this.muted});
|
|
|
|
final String text;
|
|
final bool muted;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return 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: Text(
|
|
text,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: muted ? AppTheme.textSecondary : null,
|
|
height: 1.35,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PeopleSectionEmpty extends StatelessWidget {
|
|
const _PeopleSectionEmpty({required this.label});
|
|
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return 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: Text(
|
|
label,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
|
),
|
|
);
|
|
}
|
|
}
|