import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:relationship_saver/core/config/app_theme.dart'; import 'package:relationship_saver/features/local/local_models.dart'; import 'package:relationship_saver/features/local/local_repository.dart'; import 'package:relationship_saver/features/shared/frosted_card.dart'; class SelectedPersonIdNotifier extends Notifier { @override String? build() => null; void select(String id) { state = id; } void clear() { state = null; } } final NotifierProvider 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 { const PeopleView({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { 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 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( (PersonProfile person) => person.id == selectedId, orElse: () => people.first, ); return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { final bool isCompact = constraints.maxWidth < 860; if (isCompact) { return _buildCompactLayout( context, ref, data, people, selected, relationshipOptions, ); } return _buildWideLayout( context, ref, data, people, selected, relationshipOptions, ); }, ); }, loading: () => const Center(child: CircularProgressIndicator()), error: (Object error, StackTrace stackTrace) { return const Center(child: Text('Unable to load people')); }, ); } 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 List existingPeople = ref.read(localRepositoryProvider).asData?.value.people ?? const []; final _PersonDraft? draft = await _showPersonEditor( context, title: 'Add Person', existingPeople: existingPeople, ); if (draft == null) { return; } await ref .read(localRepositoryProvider.notifier) .addPerson( name: draft.name, relationship: draft.relationship, notes: draft.notes, 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( BuildContext context, WidgetRef ref, PersonProfile person, ) async { final List existingPeople = ref.read(localRepositoryProvider).asData?.value.people ?? const []; final _PersonDraft? draft = await _showPersonEditor( context, title: 'Edit Person', initial: _PersonDraft( name: person.name, relationship: person.relationship, notes: person.notes, tags: person.tags, location: person.location, ), existingPeople: existingPeople, editingPersonId: person.id, ); if (draft == null) { return; } await ref .read(localRepositoryProvider.notifier) .updatePerson( person.copyWith( name: draft.name, relationship: draft.relationship, notes: draft.notes, tags: draft.tags, location: draft.location, ), ); } Future _handleDeletePerson( BuildContext context, WidgetRef ref, String personId, ) async { final bool? confirmed = await showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: const Text('Delete person?'), content: const Text( 'This will remove the person and related moments from local storage.', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('Delete'), ), ], ); }, ); if (confirmed != true) { return; } await ref.read(localRepositoryProvider.notifier).deletePerson(personId); ref.read(selectedPersonIdProvider.notifier).clear(); } Widget _buildWideLayout( BuildContext context, WidgetRef ref, LocalDataState data, List people, PersonProfile selected, List relationshipOptions, ) { return Padding( padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), child: Row( children: [ Expanded( flex: 5, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _PeopleHeader( onAdd: () => _handleAddPerson(context, ref), onMerge: () => _handleMergePeople(context, ref, people), compact: false, ), const SizedBox(height: 12), _PeopleListControls( compact: false, relationshipOptions: relationshipOptions, ), const SizedBox(height: 20), Expanded( child: ListView.separated( itemCount: people.length, separatorBuilder: (_, _) => const SizedBox(height: 12), itemBuilder: (BuildContext context, int index) { final PersonProfile person = people[index]; return _PersonListItem( person: person, selectedId: selected.id, compact: false, onSelect: () { ref .read(selectedPersonIdProvider.notifier) .select(person.id); }, ); }, ), ), ], ), ), const SizedBox(width: 18), Expanded( flex: 6, child: FrostedCard( 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), ), ), ), ), ], ), ); } Widget _buildCompactLayout( BuildContext context, WidgetRef ref, LocalDataState data, List people, PersonProfile selected, List relationshipOptions, ) { return Padding( padding: const EdgeInsets.fromLTRB(18, 16, 18, 20), child: ListView( children: [ _PeopleHeader( onAdd: () => _handleAddPerson(context, ref), 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); }, ), ), ), ], ), ); } Future _handleQuickAction( BuildContext context, WidgetRef ref, PersonProfile person, _PersonQuickAction action, ) async { switch (action) { case _PersonQuickAction.capture: final String? summary = await _showQuickTextCaptureDialog( context, title: 'Add Capture', hintText: 'What happened in this interaction?', ); if (summary == null) { return; } await ref .read(localRepositoryProvider.notifier) .addMoment(personId: person.id, summary: summary, type: 'capture'); return; case _PersonQuickAction.note: final String? note = await _showQuickTextCaptureDialog( context, title: 'Add Note', hintText: 'Write a quick context note...', ); if (note == null) { return; } await ref .read(localRepositoryProvider.notifier) .addMoment(personId: person.id, summary: note, type: 'note'); return; case _PersonQuickAction.idea: final _QuickIdeaDraft? idea = await showDialog<_QuickIdeaDraft>( context: context, builder: (BuildContext context) => const _QuickIdeaDialog(), ); if (idea == null) { return; } await ref .read(localRepositoryProvider.notifier) .addIdea( type: idea.type, title: idea.title, details: idea.details, personId: person.id, ); return; case _PersonQuickAction.reminder: final _QuickReminderDraft? reminder = await showDialog<_QuickReminderDraft>( context: context, builder: (BuildContext context) => const _QuickReminderDialog(), ); if (reminder == null) { return; } await ref .read(localRepositoryProvider.notifier) .addReminder( title: reminder.title, cadence: reminder.cadence, nextAt: reminder.nextAt, personId: person.id, ); return; } } Future _handleMergePeople( BuildContext context, WidgetRef ref, List people, ) async { if (people.length < 2) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Need at least two profiles to merge.')), ); return; } final List duplicateCandidates = _duplicateCandidates( people, ); if (duplicateCandidates.length < 2) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'No duplicate-name profiles found. Edit names first if needed.', ), ), ); return; } final _MergePeopleDraft? draft = await showDialog<_MergePeopleDraft>( context: context, builder: (BuildContext context) => _MergePeopleDialog(people: duplicateCandidates), ); if (draft == null) { return; } await ref .read(localRepositoryProvider.notifier) .mergePersonProfiles( sourcePersonId: draft.sourcePersonId, targetPersonId: draft.targetPersonId, ); ref.read(selectedPersonIdProvider.notifier).select(draft.targetPersonId); if (!context.mounted) { return; } ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Profiles merged successfully.')), ); } List _duplicateCandidates(List people) { final Map> grouped = >{}; for (final PersonProfile person in people) { final String key = _normalizeNameKey(person.name); if (key.isEmpty) { continue; } grouped.putIfAbsent(key, () => []).add(person); } final List result = []; for (final List group in grouped.values) { if (group.length > 1) { result.addAll(group); } } return result; } String _normalizeNameKey(String value) { return value .trim() .toLowerCase() .replaceAll(RegExp(r'[^a-z0-9]+'), ' ') .replaceAll(RegExp(r'\s+'), ' '); } Future<_PersonDraft?> _showPersonEditor( BuildContext context, { required String title, _PersonDraft? initial, List existingPeople = const [], String? editingPersonId, }) { final bool compact = MediaQuery.sizeOf(context).width < 720; if (compact) { return showModalBottomSheet<_PersonDraft>( context: context, isScrollControlled: true, useSafeArea: true, backgroundColor: Colors.transparent, builder: (BuildContext context) { return _PersonEditorBottomSheet( title: title, initial: initial, existingPeople: existingPeople, editingPersonId: editingPersonId, ); }, ); } return showDialog<_PersonDraft>( context: context, builder: (BuildContext context) { return _PersonEditorDialog( title: title, initial: initial, existingPeople: existingPeople, editingPersonId: editingPersonId, ); }, ); } Future _showQuickTextCaptureDialog( BuildContext context, { required String title, required String hintText, }) async { final TextEditingController controller = TextEditingController(); final String? result = await showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: Text(title), content: TextField( controller: controller, minLines: 2, maxLines: 4, decoration: InputDecoration(hintText: hintText), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () { final String value = controller.text.trim(); if (value.isEmpty) { return; } Navigator.of(context).pop(value); }, child: const Text('Save'), ), ], ); }, ); controller.dispose(); return result; } } class _EmptyPeopleView extends StatelessWidget { const _EmptyPeopleView({required this.onAdd}); final VoidCallback onAdd; @override Widget build(BuildContext context) { return Center( child: FrostedCard( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( 'No people yet', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 8), Text( 'Add the first relationship profile to start tracking moments.', style: Theme.of( context, ).textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), textAlign: TextAlign.center, ), const SizedBox(height: 12), FilledButton.icon( onPressed: onAdd, icon: const Icon(Icons.person_add_alt_1_rounded), label: const Text('Add Person'), ), ], ), ), ); } } 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 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 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 List preferenceSignals = data.preferenceSignals .where( (PersonPreferenceSignal signal) => signal.personId == person.id, ) .toList(growable: false) ..sort(_peoplePreferenceSignalSort); 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: [ _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, ), 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: 'Chat-derived preferences', icon: Icons.psychology_alt_outlined, children: preferenceSignals.isEmpty ? const [ _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 [ _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 [ _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 [ _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 [ _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 _showPeoplePreferenceEvidence( BuildContext context, { required PersonPreferenceSignal signal, required String personName, }) { return showModalBottomSheet( 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: [ 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 Function() onConfirm; final Future 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: [ Wrap( spacing: 8, runSpacing: 8, crossAxisAlignment: WrapCrossAlignment.center, children: [ 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: [ 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) ...[ 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 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)}', ), ], ), ], ), ); } } 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.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, required this.onMerge, required this.compact, }); final VoidCallback onAdd; final VoidCallback onMerge; final bool compact; @override Widget build(BuildContext context) { final TextTheme textTheme = Theme.of(context).textTheme; if (compact) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('People', style: textTheme.headlineMedium), const SizedBox(height: 6), Text( 'Track what matters to each relationship and follow through.', style: textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 12), FilledButton.icon( onPressed: onAdd, icon: const Icon(Icons.person_add_alt_1_rounded), label: const Text('Add person'), ), const SizedBox(height: 8), OutlinedButton.icon( onPressed: onMerge, icon: const Icon(Icons.merge_type_rounded), label: const Text('Merge duplicates'), ), ], ); } return Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('People', style: textTheme.headlineMedium), const SizedBox(height: 6), Text( 'Track what matters to each relationship and follow through.', style: textTheme.bodyLarge?.copyWith( color: AppTheme.textSecondary, ), ), ], ), ), FilledButton.icon( onPressed: onAdd, icon: const Icon(Icons.person_add_alt_1_rounded), label: const Text('Add'), ), const SizedBox(width: 8), OutlinedButton.icon( onPressed: onMerge, icon: const Icon(Icons.merge_type_rounded), label: const Text('Merge'), ), ], ); } } 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 Widget build(BuildContext context) { final bool isSelected = person.id == selectedId; return InkWell( onTap: onSelect, borderRadius: BorderRadius.circular(20), child: FrostedCard( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ 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(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, ), ), ], ), ], ), ), ); } } class _PersonEditorDialog extends StatelessWidget { const _PersonEditorDialog({ required this.title, required this.existingPeople, this.initial, this.editingPersonId, }); final String title; final List existingPeople; final _PersonDraft? initial; final String? editingPersonId; @override Widget build(BuildContext context) { return Dialog( backgroundColor: Colors.transparent, insetPadding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 720), child: _PersonEditorPanel( title: title, initial: initial, existingPeople: existingPeople, editingPersonId: editingPersonId, compact: false, sheetStyle: false, ), ), ); } } class _PersonEditorBottomSheet extends StatelessWidget { const _PersonEditorBottomSheet({ required this.title, required this.existingPeople, this.initial, this.editingPersonId, }); final String title; final List existingPeople; final _PersonDraft? initial; final String? editingPersonId; @override Widget build(BuildContext context) { return Padding( padding: EdgeInsets.only( left: 8, right: 8, bottom: MediaQuery.viewInsetsOf(context).bottom + 8, ), child: _PersonEditorPanel( title: title, initial: initial, existingPeople: existingPeople, editingPersonId: editingPersonId, compact: true, sheetStyle: true, ), ); } } class _PersonEditorPanel extends StatefulWidget { const _PersonEditorPanel({ required this.title, required this.compact, required this.sheetStyle, required this.existingPeople, this.initial, this.editingPersonId, }); final String title; final bool compact; final bool sheetStyle; final List existingPeople; final _PersonDraft? initial; final String? editingPersonId; @override State<_PersonEditorPanel> createState() => _PersonEditorPanelState(); } class _PersonEditorPanelState extends State<_PersonEditorPanel> { late final TextEditingController _nameController; late final TextEditingController _relationshipController; late final TextEditingController _locationController; late final TextEditingController _tagsController; late final TextEditingController _notesController; bool _attemptedSubmit = false; @override void initState() { super.initState(); _nameController = TextEditingController(text: widget.initial?.name ?? '') ..addListener(_handlePreviewChanged); _relationshipController = TextEditingController( text: widget.initial?.relationship ?? '', )..addListener(_handlePreviewChanged); _locationController = TextEditingController( text: widget.initial?.location ?? '', )..addListener(_handlePreviewChanged); _tagsController = TextEditingController( text: widget.initial?.tags.join(', ') ?? '', )..addListener(_handlePreviewChanged); _notesController = TextEditingController(text: widget.initial?.notes ?? ''); } @override void dispose() { _nameController ..removeListener(_handlePreviewChanged) ..dispose(); _relationshipController ..removeListener(_handlePreviewChanged) ..dispose(); _locationController ..removeListener(_handlePreviewChanged) ..dispose(); _tagsController ..removeListener(_handlePreviewChanged) ..dispose(); _notesController.dispose(); super.dispose(); } void _handlePreviewChanged() { if (mounted) { setState(() {}); } } @override Widget build(BuildContext context) { final String name = _nameController.text.trim(); final String relationship = _relationshipController.text.trim(); final bool nameMissing = _attemptedSubmit && name.isEmpty; final bool relationshipMissing = _attemptedSubmit && relationship.isEmpty; final String? location = _locationController.text.trim().isEmpty ? null : _locationController.text.trim(); final List tags = _tagsController.text .split(',') .map((String tag) => tag.trim()) .where((String tag) => tag.isNotEmpty) .toList(growable: false); final List duplicateMatches = _findDuplicateNameMatches( name: name, people: widget.existingPeople, excludePersonId: widget.editingPersonId, ); final bool hasDuplicateWarning = duplicateMatches.isNotEmpty; final BorderRadius radius = BorderRadius.circular(widget.compact ? 24 : 22); final double maxHeight = widget.compact ? 720 : 760; return Material( color: Colors.transparent, child: Container( constraints: BoxConstraints( maxHeight: maxHeight, minHeight: widget.compact ? 360 : 420, ), decoration: BoxDecoration( borderRadius: radius, gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFF7FBFD), Color(0xFFEAF3F9)], ), border: Border.all(color: Colors.white.withValues(alpha: 0.95)), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.08), blurRadius: 26, offset: const Offset(0, 14), ), ], ), child: ClipRRect( borderRadius: radius, child: Column( children: [ if (widget.sheetStyle) Padding( padding: const EdgeInsets.only(top: 8), child: Container( width: 42, height: 4, decoration: BoxDecoration( color: const Color(0xFFCCD6DD), borderRadius: BorderRadius.circular(99), ), ), ), Expanded( child: SingleChildScrollView( padding: EdgeInsets.fromLTRB( widget.compact ? 14 : 18, widget.compact ? 10 : 16, widget.compact ? 14 : 18, 12, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _PersonEditorHero( title: widget.title, compact: widget.compact, previewName: name, previewRelationship: relationship, previewLocation: location, previewTags: tags, ), if (hasDuplicateWarning) ...[ const SizedBox(height: 10), _PersonEditorWarningBanner(matches: duplicateMatches), ], const SizedBox(height: 12), if (widget.compact) Column( children: [ _PersonEditorSectionCard( title: 'Identity', icon: Icons.person_outline_rounded, child: Column( children: [ TextField( controller: _nameController, autofocus: true, decoration: InputDecoration( labelText: 'Name', errorText: nameMissing ? 'Name is required' : null, ), ), const SizedBox(height: 10), TextField( controller: _relationshipController, decoration: InputDecoration( labelText: 'Relationship', errorText: relationshipMissing ? 'Relationship is required' : null, ), ), const SizedBox(height: 10), TextField( controller: _locationController, decoration: const InputDecoration( labelText: 'Location (optional)', ), ), ], ), ), const SizedBox(height: 10), _PersonEditorSectionCard( title: 'Context', icon: Icons.auto_awesome_rounded, child: Column( children: [ TextField( controller: _tagsController, decoration: const InputDecoration( labelText: 'Tags (comma separated)', ), ), const SizedBox(height: 10), TextField( controller: _notesController, minLines: 3, maxLines: 5, decoration: const InputDecoration( labelText: 'Notes', hintText: 'Preferences, boundaries, gift clues, recurring themes...', ), ), ], ), ), ], ) else Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: _PersonEditorSectionCard( title: 'Identity', icon: Icons.person_outline_rounded, child: Column( children: [ TextField( controller: _nameController, autofocus: true, decoration: InputDecoration( labelText: 'Name', errorText: nameMissing ? 'Name is required' : null, ), ), const SizedBox(height: 10), TextField( controller: _relationshipController, decoration: InputDecoration( labelText: 'Relationship', errorText: relationshipMissing ? 'Relationship is required' : null, ), ), const SizedBox(height: 10), TextField( controller: _locationController, decoration: const InputDecoration( labelText: 'Location (optional)', ), ), ], ), ), ), const SizedBox(width: 12), Expanded( child: _PersonEditorSectionCard( title: 'Context', icon: Icons.auto_awesome_rounded, child: Column( children: [ TextField( controller: _tagsController, decoration: const InputDecoration( labelText: 'Tags (comma separated)', ), ), const SizedBox(height: 10), TextField( controller: _notesController, minLines: 5, maxLines: 8, decoration: const InputDecoration( labelText: 'Notes', hintText: 'Preferences, boundaries, gift clues, recurring themes...', ), ), ], ), ), ), ], ), ], ), ), ), Container( padding: EdgeInsets.fromLTRB( widget.compact ? 14 : 18, 10, widget.compact ? 14 : 18, widget.compact ? 14 : 16, ), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.75), border: Border( top: BorderSide(color: Colors.white.withValues(alpha: 0.9)), ), ), child: Row( children: [ Expanded( child: Text( 'Required: name and relationship', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTheme.textSecondary, ), ), ), const SizedBox(width: 8), TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), const SizedBox(width: 8), FilledButton( onPressed: () { final String submittedName = _nameController.text .trim(); final String submittedRelationship = _relationshipController.text.trim(); if (submittedName.isEmpty || submittedRelationship.isEmpty) { setState(() { _attemptedSubmit = true; }); return; } final _PersonDraft draft = _PersonDraft( name: submittedName, relationship: submittedRelationship, location: _locationController.text.trim().isEmpty ? null : _locationController.text.trim(), notes: _notesController.text.trim(), tags: _tagsController.text .split(',') .map((String tag) => tag.trim()) .where((String tag) => tag.isNotEmpty) .toList(growable: false), ); Navigator.of(context).pop(draft); }, child: const Text('Save'), ), ], ), ), ], ), ), ), ); } } class _PersonEditorHero extends StatelessWidget { const _PersonEditorHero({ required this.title, required this.compact, required this.previewName, required this.previewRelationship, required this.previewLocation, required this.previewTags, }); final String title; final bool compact; final String previewName; final String previewRelationship; final String? previewLocation; final List previewTags; @override Widget build(BuildContext context) { final String displayName = previewName.isEmpty ? 'New person' : previewName; final String displayRelationship = previewRelationship.isEmpty ? 'Relationship type' : previewRelationship; return Container( width: double.infinity, padding: EdgeInsets.all(compact ? 14 : 16), decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: Colors.white.withValues(alpha: 0.72), border: Border.all(color: Colors.white), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ _AvatarSeed(name: displayName, size: compact ? 50 : 56), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 4), Text( 'Shape the profile first, then use quick-add from the People workspace.', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: AppTheme.textSecondary, ), ), ], ), ), ], ), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, children: [ _InlineMetaPill( icon: Icons.person_outline_rounded, label: displayName, ), _InlineMetaPill( icon: Icons.favorite_outline_rounded, label: displayRelationship, ), if (previewLocation != null) _InlineMetaPill( icon: Icons.location_on_outlined, label: previewLocation!, ), ...previewTags .take(compact ? 2 : 4) .map( (String tag) => _InlineMetaPill(icon: Icons.sell_outlined, label: tag), ), ], ), ], ), ); } } class _PersonEditorSectionCard extends StatelessWidget { const _PersonEditorSectionCard({ required this.title, required this.icon, required this.child, }); final String title; final IconData icon; final Widget child; @override Widget build(BuildContext context) { return Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.78), borderRadius: BorderRadius.circular(16), border: Border.all(color: Colors.white), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(icon, size: 18, color: AppTheme.primary), const SizedBox(width: 8), Expanded( child: Text( title, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleMedium, ), ), ], ), const SizedBox(height: 10), child, ], ), ); } } class _PersonEditorWarningBanner extends StatelessWidget { const _PersonEditorWarningBanner({required this.matches}); final List matches; @override Widget build(BuildContext context) { final String names = matches .take(2) .map( (PersonProfile person) => '${person.name} (${person.relationship})', ) .join(', '); final int extraCount = matches.length - 2; final String suffix = extraCount > 0 ? ' +$extraCount more' : ''; return Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: const Color(0xFFFFF7E8), borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFFFE2A8)), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Padding( padding: EdgeInsets.only(top: 1), child: Icon( Icons.warning_amber_rounded, size: 18, color: Color(0xFFA56A00), ), ), const SizedBox(width: 8), Expanded( child: Text( 'Possible duplicate profile name detected. Existing: $names$suffix. You can still save, then merge duplicates from People.', style: Theme.of( context, ).textTheme.bodyMedium?.copyWith(color: const Color(0xFF6F5200)), ), ), ], ), ); } } class _PersonDraft { const _PersonDraft({ required this.name, required this.relationship, required this.notes, required this.tags, this.location, }); final String name; final String relationship; final String notes; final List tags; final String? location; } List _findDuplicateNameMatches({ required String name, required List people, String? excludePersonId, }) { final String normalized = _normalizePersonNameKey(name); if (normalized.isEmpty) { return const []; } return people .where((PersonProfile person) { if (excludePersonId != null && person.id == excludePersonId) { return false; } return _normalizePersonNameKey(person.name) == normalized; }) .toList(growable: false); } String _normalizePersonNameKey(String value) { return value .trim() .toLowerCase() .replaceAll(RegExp(r'[^a-z0-9]+'), ' ') .replaceAll(RegExp(r'\s+'), ' '); } class _MergePeopleDraft { const _MergePeopleDraft({ required this.sourcePersonId, required this.targetPersonId, }); final String sourcePersonId; final String targetPersonId; } class _MergePeopleDialog extends StatefulWidget { const _MergePeopleDialog({required this.people}); final List people; @override State<_MergePeopleDialog> createState() => _MergePeopleDialogState(); } class _MergePeopleDialogState extends State<_MergePeopleDialog> { late String _sourcePersonId; late String _targetPersonId; @override void initState() { super.initState(); _targetPersonId = widget.people.first.id; _sourcePersonId = widget.people.length > 1 ? widget.people[1].id : widget.people.first.id; } @override Widget build(BuildContext context) { return AlertDialog( title: const Text('Merge Duplicate Profiles'), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( 'Move captures, ideas, reminders, and share links from source into target profile.', style: Theme.of( context, ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: 12), DropdownButtonFormField( key: ValueKey('target-$_targetPersonId'), initialValue: _targetPersonId, decoration: const InputDecoration( labelText: 'Keep target profile', ), items: widget.people .map( (PersonProfile person) => DropdownMenuItem( value: person.id, child: Text('${person.name} · ${person.relationship}'), ), ) .toList(growable: false), onChanged: (String? value) { if (value == null) { return; } setState(() { _targetPersonId = value; if (_targetPersonId == _sourcePersonId) { _sourcePersonId = widget.people .firstWhere( (PersonProfile person) => person.id != _targetPersonId, orElse: () => widget.people.first, ) .id; } }); }, ), const SizedBox(height: 10), DropdownButtonFormField( key: ValueKey('source-$_targetPersonId-$_sourcePersonId'), initialValue: _sourcePersonId, decoration: const InputDecoration( labelText: 'Merge and remove source profile', ), items: widget.people .where((PersonProfile person) => person.id != _targetPersonId) .map( (PersonProfile person) => DropdownMenuItem( value: person.id, child: Text('${person.name} · ${person.relationship}'), ), ) .toList(growable: false), onChanged: (String? value) { if (value == null) { return; } setState(() { _sourcePersonId = value; }); }, ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () { if (_sourcePersonId == _targetPersonId) { return; } Navigator.of(context).pop( _MergePeopleDraft( sourcePersonId: _sourcePersonId, targetPersonId: _targetPersonId, ), ); }, child: const Text('Merge'), ), ], ); } } class _QuickIdeaDraft { const _QuickIdeaDraft({ required this.type, required this.title, required this.details, }); final IdeaType type; final String title; final String details; } class _QuickIdeaDialog extends StatefulWidget { const _QuickIdeaDialog(); @override State<_QuickIdeaDialog> createState() => _QuickIdeaDialogState(); } class _QuickIdeaDialogState extends State<_QuickIdeaDialog> { IdeaType _type = IdeaType.gift; final TextEditingController _titleController = TextEditingController(); final TextEditingController _detailsController = TextEditingController(); @override void dispose() { _titleController.dispose(); _detailsController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AlertDialog( title: const Text('Add Idea'), content: SingleChildScrollView( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Column( mainAxisSize: MainAxisSize.min, children: [ SegmentedButton( segments: const >[ ButtonSegment( value: IdeaType.gift, label: Text('Gift'), ), ButtonSegment( value: IdeaType.event, label: Text('Event'), ), ], selected: {_type}, onSelectionChanged: (Set values) { setState(() { _type = values.first; }); }, ), const SizedBox(height: 10), TextField( controller: _titleController, decoration: const InputDecoration(labelText: 'Title'), ), TextField( controller: _detailsController, maxLines: 3, decoration: const InputDecoration(labelText: 'Details'), ), ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () { final String title = _titleController.text.trim(); if (title.isEmpty) { return; } Navigator.of(context).pop( _QuickIdeaDraft( type: _type, title: title, details: _detailsController.text.trim(), ), ); }, child: const Text('Add'), ), ], ); } } class _QuickReminderDraft { const _QuickReminderDraft({ required this.title, required this.cadence, required this.nextAt, }); final String title; final ReminderCadence cadence; final DateTime nextAt; } class _QuickReminderDialog extends StatefulWidget { const _QuickReminderDialog(); @override State<_QuickReminderDialog> createState() => _QuickReminderDialogState(); } class _QuickReminderDialogState extends State<_QuickReminderDialog> { final TextEditingController _titleController = TextEditingController(); ReminderCadence _cadence = ReminderCadence.weekly; DateTime _nextAt = DateTime.now().add(const Duration(days: 1)); @override void dispose() { _titleController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AlertDialog( title: const Text('Add Reminder'), content: SingleChildScrollView( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: _titleController, decoration: const InputDecoration(labelText: 'Title'), ), const SizedBox(height: 10), DropdownButtonFormField( initialValue: _cadence, decoration: const InputDecoration(labelText: 'Cadence'), items: ReminderCadence.values .map( (ReminderCadence cadence) => DropdownMenuItem( value: cadence, child: Text(cadence.name), ), ) .toList(growable: false), onChanged: (ReminderCadence? value) { if (value == null) { return; } setState(() { _cadence = value; }); }, ), const SizedBox(height: 10), Row( children: [ Expanded( child: Text( 'Next: ${_nextAt.year}-${_nextAt.month.toString().padLeft(2, '0')}-${_nextAt.day.toString().padLeft(2, '0')} ${_nextAt.hour.toString().padLeft(2, '0')}:${_nextAt.minute.toString().padLeft(2, '0')}', ), ), TextButton( onPressed: () async { final DateTime now = DateTime.now(); final DateTime? date = await showDatePicker( context: context, firstDate: now, lastDate: now.add(const Duration(days: 3650)), initialDate: _nextAt, ); if (date == null || !context.mounted) { return; } final TimeOfDay? time = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime(_nextAt), ); if (time == null) { return; } setState(() { _nextAt = DateTime( date.year, date.month, date.day, time.hour, time.minute, ); }); }, child: const Text('Change'), ), ], ), ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () { final String title = _titleController.text.trim(); if (title.isEmpty) { return; } Navigator.of(context).pop( _QuickReminderDraft( title: title, cadence: _cadence, nextAt: _nextAt, ), ); }, child: const Text('Add'), ), ], ); } } 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: 10, vertical: 7), decoration: BoxDecoration( 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), ), ), ], ), ); } } class _AvatarSeed extends StatelessWidget { const _AvatarSeed({required this.name, this.size = 48}); final String name; final double size; @override Widget build(BuildContext context) { final String initials = name .split(' ') .where((String part) => part.isNotEmpty) .take(2) .map((String part) => part[0].toUpperCase()) .join(); return Container( width: size, height: size, decoration: BoxDecoration( borderRadius: BorderRadius.circular(size / 2), gradient: const LinearGradient( colors: [Color(0xFF1AB6C8), Color(0xFF2274E0)], ), ), alignment: Alignment.center, child: Text( initials, style: Theme.of(context).textTheme.titleMedium?.copyWith( color: Colors.white, fontWeight: FontWeight.w700, ), ), ); } } class _ScorePill extends StatelessWidget { const _ScorePill({required this.score}); final int score; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: const Color(0xFFEEF7F3), borderRadius: BorderRadius.circular(99), ), child: Text( '$score%', style: Theme.of(context).textTheme.labelLarge?.copyWith( color: const Color(0xFF1D9C66), fontWeight: FontWeight.w700, ), ), ); } } 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'; }