From c281fd8d3d3261a0799f8c7b4afe5265fa0238ed Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sun, 22 Feb 2026 22:59:34 +0100 Subject: [PATCH] Redesign people page with insights-style flow --- docs/progress.md | 40 +- lib/features/people/people_view.dart | 1353 ++++++++++++++++++++++---- 2 files changed, 1184 insertions(+), 209 deletions(-) diff --git a/docs/progress.md b/docs/progress.md index d140844..280db95 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -1,12 +1,50 @@ # Relationship Saver Progress Log -Updated: 2026-02-18 +Updated: 2026-02-22 ## Collaboration Rule (Carry Forward) - 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): People Page UX Redesign (Insights-Aligned) + +Reworked the `People` page to feel closer to the graph long-press `Person +Insights` experience and improved the compact/mobile interaction flow. + +- `lib/features/people/people_view.dart` + - redesigned the selected person detail area into an insights-style workspace: + - hero summary card with quick actions (`Capture`, `Idea`, menu + edit/delete) + - stats tiles (affinity, next moment, captures, ideas) + - section cards for: + - profile context (relationship/location/tags/source links + notes) + - ideas + - moments + - reminders + - shared message history + - added richer person list cards: + - next moment pill + - optional location pill + - tag pills + - added People list controls: + - search + - relationship filter chips + - sort menu (affinity / next / A-Z) + - empty state when filters yield no results + - improved compact/mobile flow: + - selected profile workspace now renders above the full list (instead of + after all profiles) + - explicit `All profiles` section header with count + - improved add-person flow UX: + - newly created person is automatically selected/focused immediately + - also fixed the mobile CRUD widget test expectation after layout changes + - migrated page-local filter state to Riverpod 3 `Notifier` providers + (instead of unavailable legacy `StateProvider` API) + +- Validation + - `flutter analyze` -> pass + - `flutter test` -> pass + ## Latest Milestone (2026-02-18): Quick-Add Actions on Graph Person Insights Closed a UX gap where long-pressing a graph node opened person insights without diff --git a/lib/features/people/people_view.dart b/lib/features/people/people_view.dart index af4f41d..4eb4240 100644 --- a/lib/features/people/people_view.dart +++ b/lib/features/people/people_view.dart @@ -23,6 +23,52 @@ selectedPersonIdProvider = NotifierProvider( SelectedPersonIdNotifier.new, ); +enum PeopleSortOption { affinity, nextMoment, alphabetical } + +class PeopleSearchQueryNotifier extends Notifier { + @override + String build() => ''; + + void set(String value) { + state = value; + } +} + +final NotifierProvider +peopleSearchQueryProvider = NotifierProvider( + PeopleSearchQueryNotifier.new, +); + +class PeopleRelationshipFilterNotifier extends Notifier { + @override + String? build() => null; + + void set(String? value) { + state = value; + } +} + +final NotifierProvider +peopleRelationshipFilterProvider = + NotifierProvider( + PeopleRelationshipFilterNotifier.new, + ); + +class PeopleSortOptionNotifier extends Notifier { + @override + PeopleSortOption build() => PeopleSortOption.affinity; + + void set(PeopleSortOption value) { + state = value; + } +} + +final NotifierProvider +peopleSortOptionProvider = + NotifierProvider( + PeopleSortOptionNotifier.new, + ); + enum _PersonQuickAction { capture, note, idea, reminder } class PeopleView extends ConsumerWidget { @@ -33,14 +79,38 @@ class PeopleView extends ConsumerWidget { final AsyncValue localData = ref.watch( localRepositoryProvider, ); + final String searchQuery = ref.watch(peopleSearchQueryProvider); + final String? relationshipFilter = ref.watch( + peopleRelationshipFilterProvider, + ); + final PeopleSortOption sortOption = ref.watch(peopleSortOptionProvider); return localData.when( data: (LocalDataState data) { - final List people = data.people; - if (people.isEmpty) { + final List allPeople = data.people; + if (allPeople.isEmpty) { return _EmptyPeopleView(onAdd: () => _handleAddPerson(context, ref)); } + final List people = _applyPeopleFilters( + allPeople, + searchQuery: searchQuery, + relationshipFilter: relationshipFilter, + sortOption: sortOption, + ); + final List relationshipOptions = _relationshipOptions( + allPeople, + ); + + if (people.isEmpty) { + return _FilteredPeopleEmptyView( + onClear: () { + ref.read(peopleSearchQueryProvider.notifier).set(''); + ref.read(peopleRelationshipFilterProvider.notifier).set(null); + }, + ); + } + final String selectedId = ref.watch(selectedPersonIdProvider) ?? people.first.id; final PersonProfile selected = people.firstWhere( @@ -52,9 +122,23 @@ class PeopleView extends ConsumerWidget { builder: (BuildContext context, BoxConstraints constraints) { final bool isCompact = constraints.maxWidth < 860; if (isCompact) { - return _buildCompactLayout(context, ref, people, selected); + return _buildCompactLayout( + context, + ref, + data, + people, + selected, + relationshipOptions, + ); } - return _buildWideLayout(context, ref, people, selected); + return _buildWideLayout( + context, + ref, + data, + people, + selected, + relationshipOptions, + ); }, ); }, @@ -65,6 +149,77 @@ class PeopleView extends ConsumerWidget { ); } + List _applyPeopleFilters( + List people, { + required String searchQuery, + required String? relationshipFilter, + required PeopleSortOption sortOption, + }) { + final String query = searchQuery.trim().toLowerCase(); + final String? relationship = relationshipFilter?.trim().toLowerCase(); + + final List filtered = people + .where((PersonProfile person) { + if (relationship != null && + relationship.isNotEmpty && + person.relationship.trim().toLowerCase() != relationship) { + return false; + } + if (query.isEmpty) { + return true; + } + final String haystack = [ + person.name, + person.relationship, + person.location ?? '', + person.notes, + ...person.tags, + ].join(' ').toLowerCase(); + return haystack.contains(query); + }) + .toList(growable: true); + + filtered.sort((PersonProfile a, PersonProfile b) { + switch (sortOption) { + case PeopleSortOption.affinity: + final int score = b.affinityScore.compareTo(a.affinityScore); + if (score != 0) { + return score; + } + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + case PeopleSortOption.nextMoment: + final int next = a.nextMoment.compareTo(b.nextMoment); + if (next != 0) { + return next; + } + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + case PeopleSortOption.alphabetical: + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + } + }); + + return filtered; + } + + List _relationshipOptions(List people) { + final Set seen = {}; + final List values = []; + for (final PersonProfile person in people) { + final String relationship = person.relationship.trim(); + if (relationship.isEmpty) { + continue; + } + final String key = relationship.toLowerCase(); + if (seen.add(key)) { + values.add(relationship); + } + } + values.sort( + (String a, String b) => a.toLowerCase().compareTo(b.toLowerCase()), + ); + return values; + } + Future _handleAddPerson(BuildContext context, WidgetRef ref) async { final _PersonDraft? draft = await showDialog<_PersonDraft>( context: context, @@ -85,6 +240,24 @@ class PeopleView extends ConsumerWidget { tags: draft.tags, location: draft.location, ); + + final LocalDataState? latest = ref + .read(localRepositoryProvider) + .asData + ?.value; + if (latest == null) { + return; + } + for (final PersonProfile person in latest.people) { + final bool locationMatches = + (person.location ?? '').trim() == (draft.location ?? '').trim(); + if (person.name == draft.name && + person.relationship == draft.relationship && + locationMatches) { + ref.read(selectedPersonIdProvider.notifier).select(person.id); + break; + } + } } Future _handleEditPerson( @@ -161,8 +334,10 @@ class PeopleView extends ConsumerWidget { Widget _buildWideLayout( BuildContext context, WidgetRef ref, + LocalDataState data, List people, PersonProfile selected, + List relationshipOptions, ) { return Padding( padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), @@ -178,6 +353,11 @@ class PeopleView extends ConsumerWidget { onMerge: () => _handleMergePeople(context, ref, people), compact: false, ), + const SizedBox(height: 12), + _PeopleListControls( + compact: false, + relationshipOptions: relationshipOptions, + ), const SizedBox(height: 20), Expanded( child: ListView.separated( @@ -188,6 +368,7 @@ class PeopleView extends ConsumerWidget { return _PersonListItem( person: person, selectedId: selected.id, + compact: false, onSelect: () { ref .read(selectedPersonIdProvider.notifier) @@ -204,12 +385,16 @@ class PeopleView extends ConsumerWidget { Expanded( flex: 6, child: FrostedCard( - child: _PersonDetail( - person: selected, - onEdit: () => _handleEditPerson(context, ref, selected), - onDelete: () => _handleDeletePerson(context, ref, selected.id), - onQuickAction: (_PersonQuickAction action) => - _handleQuickAction(context, ref, selected, action), + child: SingleChildScrollView( + child: _PersonDetail( + person: selected, + data: data, + onEdit: () => _handleEditPerson(context, ref, selected), + onDelete: () => + _handleDeletePerson(context, ref, selected.id), + onQuickAction: (_PersonQuickAction action) => + _handleQuickAction(context, ref, selected, action), + ), ), ), ), @@ -221,8 +406,10 @@ class PeopleView extends ConsumerWidget { Widget _buildCompactLayout( BuildContext context, WidgetRef ref, + LocalDataState data, List people, PersonProfile selected, + List relationshipOptions, ) { return Padding( padding: const EdgeInsets.fromLTRB(18, 16, 18, 20), @@ -233,30 +420,56 @@ class PeopleView extends ConsumerWidget { onMerge: () => _handleMergePeople(context, ref, people), compact: true, ), + const SizedBox(height: 10), + _PeopleListControls( + compact: true, + relationshipOptions: relationshipOptions, + ), const SizedBox(height: 14), + FrostedCard( + child: _PersonDetail( + person: selected, + data: data, + compact: true, + onEdit: () => _handleEditPerson(context, ref, selected), + onDelete: () => _handleDeletePerson(context, ref, selected.id), + onQuickAction: (_PersonQuickAction action) => + _handleQuickAction(context, ref, selected, action), + ), + ), + const SizedBox(height: 14), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Row( + children: [ + Text( + 'All profiles', + style: Theme.of(context).textTheme.titleMedium, + ), + const Spacer(), + Text( + '${people.length}', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(height: 10), ...people.map( (PersonProfile person) => Padding( padding: const EdgeInsets.only(bottom: 10), child: _PersonListItem( person: person, selectedId: selected.id, + compact: true, onSelect: () { ref.read(selectedPersonIdProvider.notifier).select(person.id); }, ), ), ), - const SizedBox(height: 8), - FrostedCard( - child: _PersonDetail( - person: selected, - compact: true, - onEdit: () => _handleEditPerson(context, ref, selected), - onDelete: () => _handleDeletePerson(context, ref, selected.id), - onQuickAction: (_PersonQuickAction action) => - _handleQuickAction(context, ref, selected, action), - ), - ), ], ), ); @@ -488,9 +701,221 @@ class _EmptyPeopleView extends StatelessWidget { } } +class _FilteredPeopleEmptyView extends StatelessWidget { + const _FilteredPeopleEmptyView({required this.onClear}); + + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + return Center( + child: FrostedCard( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.search_off_rounded, + size: 34, + color: AppTheme.textSecondary, + ), + const SizedBox(height: 10), + Text( + 'No matching people', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 6), + Text( + 'Try another search, relationship filter, or clear the current filters.', + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: onClear, + icon: const Icon(Icons.restart_alt_rounded), + label: const Text('Clear Filters'), + ), + ], + ), + ), + ); + } +} + +class _PeopleListControls extends ConsumerWidget { + const _PeopleListControls({ + required this.compact, + required this.relationshipOptions, + }); + + final bool compact; + final List relationshipOptions; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final String query = ref.watch(peopleSearchQueryProvider); + final String? selectedRelationship = ref.watch( + peopleRelationshipFilterProvider, + ); + final PeopleSortOption sortOption = ref.watch(peopleSortOptionProvider); + + return FrostedCard( + padding: EdgeInsets.all(compact ? 12 : 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: TextFormField( + key: ValueKey('people-search-$query'), + initialValue: query, + onChanged: (String value) { + ref.read(peopleSearchQueryProvider.notifier).set(value); + }, + decoration: const InputDecoration( + hintText: 'Search name, tags, notes, location', + prefixIcon: Icon(Icons.search_rounded), + isDense: true, + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + PopupMenuButton( + tooltip: 'Sort', + onSelected: (PeopleSortOption value) { + ref.read(peopleSortOptionProvider.notifier).set(value); + }, + itemBuilder: (BuildContext context) => + >[ + const PopupMenuItem( + value: PeopleSortOption.affinity, + child: Text('Sort by affinity'), + ), + const PopupMenuItem( + value: PeopleSortOption.nextMoment, + child: Text('Sort by next moment'), + ), + const PopupMenuItem( + value: PeopleSortOption.alphabetical, + child: Text('Sort A-Z'), + ), + ], + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 10, + ), + decoration: BoxDecoration( + color: const Color(0xFFF3F8FB), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.sort_rounded, size: 18), + if (!compact) ...[ + const SizedBox(width: 6), + Text(switch (sortOption) { + PeopleSortOption.affinity => 'Affinity', + PeopleSortOption.nextMoment => 'Next', + PeopleSortOption.alphabetical => 'A-Z', + }), + ], + ], + ), + ), + ), + ], + ), + const SizedBox(height: 10), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _FilterChipButton( + label: 'All', + selected: selectedRelationship == null, + onTap: () { + ref + .read(peopleRelationshipFilterProvider.notifier) + .set(null); + }, + ), + ...relationshipOptions.map( + (String relationship) => Padding( + padding: const EdgeInsets.only(left: 8), + child: _FilterChipButton( + label: relationship, + selected: + selectedRelationship?.toLowerCase() == + relationship.toLowerCase(), + onTap: () { + final PeopleRelationshipFilterNotifier controller = ref + .read(peopleRelationshipFilterProvider.notifier); + final bool isSelected = + selectedRelationship?.toLowerCase() == + relationship.toLowerCase(); + controller.set(isSelected ? null : relationship); + }, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _FilterChipButton extends StatelessWidget { + const _FilterChipButton({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(99), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: selected ? const Color(0xFFEAF7FB) : const Color(0xFFF4F8FB), + borderRadius: BorderRadius.circular(99), + border: Border.all( + color: selected + ? const Color(0xFFB8E4EE) + : Colors.white.withValues(alpha: 0.8), + ), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: selected ? AppTheme.primary : AppTheme.textSecondary, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ); + } +} + class _PersonDetail extends StatelessWidget { const _PersonDetail({ required this.person, + required this.data, required this.onEdit, required this.onDelete, required this.onQuickAction, @@ -498,6 +923,7 @@ class _PersonDetail extends StatelessWidget { }); final PersonProfile person; + final LocalDataState data; final VoidCallback onEdit; final VoidCallback onDelete; final ValueChanged<_PersonQuickAction> onQuickAction; @@ -505,181 +931,625 @@ class _PersonDetail extends StatelessWidget { @override Widget build(BuildContext context) { + final List 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 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 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 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 sourceLinks = + data.sourceLinks + .where((SourceProfileLink link) => link.profileId == person.id) + .toList(growable: false) + ..sort( + (SourceProfileLink a, SourceProfileLink b) => + b.lastSeenAt.compareTo(a.lastSeenAt), + ); + 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: [ - if (compact) - Column( - children: [ - Row( - children: [ - _AvatarSeed(name: person.name, size: 56), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - 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, - ), - ], - ), - ), - ], - ), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - PopupMenuButton<_PersonQuickAction>( - tooltip: 'Quick add', - onSelected: onQuickAction, - itemBuilder: (BuildContext context) => - const >[ - 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'), - ), - ], - icon: const Icon(Icons.add_circle_outline_rounded), - ), - 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', - ), - ], - ), - ], - ) - else - Row( - children: [ - _AvatarSeed(name: person.name, size: 64), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - 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, - ), - ], + _PersonWorkspaceHero( + person: person, + compact: compact, + sourceLinks: sourceLinks, + onEdit: onEdit, + onDelete: onDelete, + onQuickAction: onQuickAction, + ), + SizedBox(height: compact ? 12 : 14), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + _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: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _InlineMetaPill( + icon: Icons.favorite_outline_rounded, + label: person.relationship, ), - ), - PopupMenuButton<_PersonQuickAction>( - tooltip: 'Quick add', - onSelected: onQuickAction, - itemBuilder: (BuildContext context) => - const >[ - PopupMenuItem<_PersonQuickAction>( - value: _PersonQuickAction.capture, - child: Text('Add Capture'), + 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.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: 'Ideas', + icon: Icons.lightbulb_outline_rounded, + children: ideas.isEmpty + ? const [ + _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' : ''}', ), - PopupMenuItem<_PersonQuickAction>( - value: _PersonQuickAction.note, - child: Text('Add Note'), + ) + .toList(growable: false), + ), + const SizedBox(height: 12), + _PeopleInsightSectionCard( + title: 'Moments', + icon: Icons.auto_awesome_rounded, + children: moments.isEmpty + ? const [ + _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)}', ), - PopupMenuItem<_PersonQuickAction>( - value: _PersonQuickAction.idea, - child: Text('Add Idea'), + ) + .toList(growable: false), + ), + const SizedBox(height: 12), + _PeopleInsightSectionCard( + title: 'Reminders', + icon: Icons.notifications_active_outlined, + children: reminders.isEmpty + ? const [ + _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)}', ), - PopupMenuItem<_PersonQuickAction>( - value: _PersonQuickAction.reminder, - child: Text('Add Reminder'), + ) + .toList(growable: false), + ), + const SizedBox(height: 12), + _PeopleInsightSectionCard( + title: 'Shared message history', + icon: Icons.chat_bubble_outline_rounded, + children: sharedMessages.isEmpty + ? const [ + _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)}', ), - ], - icon: const Icon(Icons.add_circle_outline_rounded), - ), - 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', + ) + .toList(growable: false), + ), + ], + ); + } +} + +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 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: [ + 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 >[ + 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(0xFFF7FBFD), Color(0xFFEAF4FA)], + ), + border: Border.all(color: Colors.white.withValues(alpha: 0.9)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (compact) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AvatarSeed(name: person.name, size: 58), + const SizedBox(width: 12), + Expanded(child: _HeroIdentity(person: person)), + ], + ), + const SizedBox(height: 12), + actions, + ] else ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _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: [ + _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)}', + ), ], ), - if ((person.location ?? '').trim().isNotEmpty) ...[ - SizedBox(height: compact ? 10 : 14), - Text('Location', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - _DetailTag(label: person.location!.trim()), ], - SizedBox(height: compact ? 14 : 22), - Text('Next moment', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - _DetailTag( - label: - '${person.nextMoment.year}-${person.nextMoment.month.toString().padLeft(2, '0')}-${person.nextMoment.day.toString().padLeft(2, '0')} ${person.nextMoment.hour.toString().padLeft(2, '0')}:${person.nextMoment.minute.toString().padLeft(2, '0')}', - ), - const SizedBox(height: 18), - Text('Preferences', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: person.tags - .map((String tag) => _DetailTag(label: tag)) - .toList(growable: false), - ), - const SizedBox(height: 18), - Text('Notes', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), + ), + ); + } +} + +class _HeroIdentity extends StatelessWidget { + const _HeroIdentity({required this.person}); + + final PersonProfile person; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - person.notes, + 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: [ + 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 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: [ + Row( + children: [ + 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, + }); + + final String title; + final String subtitle; + final String meta; + + @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: [ + 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), + ), + ], + ), + ), + ); + } +} + +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), + ), + ); + } +} + class _PeopleHeader extends StatelessWidget { const _PeopleHeader({ required this.onAdd, @@ -759,11 +1629,13 @@ class _PersonListItem extends StatelessWidget { const _PersonListItem({ required this.person, required this.selectedId, + required this.compact, required this.onSelect, }); final PersonProfile person; final String selectedId; + final bool compact; final VoidCallback onSelect; @override @@ -774,39 +1646,68 @@ class _PersonListItem extends StatelessWidget { borderRadius: BorderRadius.circular(20), child: FrostedCard( padding: const EdgeInsets.all(16), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _AvatarSeed(name: person.name), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - person.name, - style: Theme.of(context).textTheme.titleMedium, - maxLines: 1, - overflow: TextOverflow.ellipsis, + Row( + children: [ + _AvatarSeed(name: person.name), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + person.name, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + person.relationship, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ), - const SizedBox(height: 4), - Text( - person.relationship, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppTheme.textSecondary, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + ), + const SizedBox(width: 8), + _ScorePill(score: person.affinityScore), + if (isSelected) + const Padding( + padding: EdgeInsets.only(left: 10), + child: Icon(Icons.check_circle, color: Color(0xFF1AB6C8)), ), - ], - ), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _InlineMetaPill( + icon: Icons.schedule_rounded, + label: _dateTimeLabel(person.nextMoment), + ), + if ((person.location ?? '').trim().isNotEmpty) + _InlineMetaPill( + icon: Icons.location_on_outlined, + label: person.location!.trim(), + ), + ...person.tags + .take(compact ? 2 : 3) + .map( + (String tag) => _InlineMetaPill( + icon: Icons.sell_outlined, + label: tag, + ), + ), + ], ), - const SizedBox(width: 8), - _ScorePill(score: person.affinityScore), - if (isSelected) - const Padding( - padding: EdgeInsets.only(left: 10), - child: Icon(Icons.check_circle, color: Color(0xFF1AB6C8)), - ), ], ), ), @@ -1315,20 +2216,37 @@ class _QuickReminderDialogState extends State<_QuickReminderDialog> { } } -class _DetailTag extends StatelessWidget { - const _DetailTag({required this.label}); +class _InlineMetaPill extends StatelessWidget { + const _InlineMetaPill({required this.icon, required this.label}); + final IconData icon; final String label; @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), decoration: BoxDecoration( - color: const Color(0xFFF2F8FA), + color: const Color(0xFFF1F7FA), borderRadius: BorderRadius.circular(99), + border: Border.all(color: Colors.white), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: AppTheme.textSecondary), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: Theme.of( + context, + ).textTheme.labelLarge?.copyWith(color: AppTheme.textSecondary), + ), + ), + ], ), - child: Text(label, style: Theme.of(context).textTheme.bodyMedium), ); } } @@ -1392,3 +2310,22 @@ class _ScorePill extends StatelessWidget { ); } } + +String _dateTimeLabel(DateTime value) { + final DateTime local = value.toLocal(); + final String month = local.month.toString().padLeft(2, '0'); + final String day = local.day.toString().padLeft(2, '0'); + final String hour = local.hour.toString().padLeft(2, '0'); + final String minute = local.minute.toString().padLeft(2, '0'); + return '$month/$day $hour:$minute'; +} + +String _shortDateTimeLabel(DateTime value) { + final DateTime local = value.toLocal(); + final String year = local.year.toString(); + final String month = local.month.toString().padLeft(2, '0'); + final String day = local.day.toString().padLeft(2, '0'); + final String hour = local.hour.toString().padLeft(2, '0'); + final String minute = local.minute.toString().padLeft(2, '0'); + return '$year-$month-$day $hour:$minute'; +}