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'; /// Review unresolved shared messages and map them to a person profile. class ShareInboxView extends ConsumerStatefulWidget { const ShareInboxView({super.key}); @override ConsumerState createState() => _ShareInboxViewState(); } class _ShareInboxViewState extends ConsumerState { bool _submitting = false; @override Widget build(BuildContext context) { final AsyncValue state = ref.watch(localRepositoryProvider); return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { final bool compact = constraints.maxWidth < 760; return Padding( padding: EdgeInsets.fromLTRB( compact ? 16 : 28, compact ? 14 : 20, compact ? 16 : 28, compact ? 16 : 20, ), child: state.when( loading: () => const Center(child: CircularProgressIndicator()), error: (Object error, StackTrace stackTrace) { return Center( child: Text( 'Failed to load Share Inbox.', style: Theme.of(context).textTheme.titleMedium, ), ); }, data: (LocalDataState localState) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Share Inbox', style: Theme.of(context).textTheme.headlineMedium, ), const SizedBox(height: 6), Text( 'Resolve shared messages that could not be matched confidently.', style: Theme.of(context).textTheme.bodyLarge?.copyWith( color: AppTheme.textSecondary, ), ), const SizedBox(height: 14), Expanded( child: localState.sharedInbox.isEmpty ? _EmptyInbox(compact: compact) : ListView.separated( itemCount: localState.sharedInbox.length, separatorBuilder: (_, _) => const SizedBox(height: 10), itemBuilder: (BuildContext context, int index) { final SharedInboxEntry entry = localState.sharedInbox[index]; final List options = _orderedPeopleForEntry(entry, localState); return _InboxEntryCard( entry: entry, compact: compact, submitting: _submitting, suggestedMatches: options, onResolveToExisting: () => _resolveToExisting(entry, options), onCreateProfile: () => _createProfile(entry), onDismiss: () => _dismiss(entry.id), ); }, ), ), ], ); }, ), ); }, ); } List _orderedPeopleForEntry( SharedInboxEntry entry, LocalDataState state, ) { final Map peopleById = { for (final PersonProfile person in state.people) person.id: person, }; final List candidates = []; final Set usedIds = {}; for (final String id in entry.candidateProfileIds) { final PersonProfile? person = peopleById[id]; if (person != null) { candidates.add(person); usedIds.add(person.id); } } final List others = state.people .where((PersonProfile person) => !usedIds.contains(person.id)) .toList(growable: false); return [...candidates, ...others]; } Future _resolveToExisting( SharedInboxEntry entry, List options, ) async { if (_submitting || options.isEmpty) { return; } final List<_CandidateReview> reviews = _buildCandidateReviews( entry: entry, options: options, ); if (reviews.isEmpty) { return; } final String? selectedId = await showDialog( context: context, builder: (BuildContext context) { return _ConflictReviewDialog( entry: entry, reviews: reviews, initialsOf: _initials, ); }, ); if (selectedId == null || !mounted) { return; } await _runRepositoryAction(() async { final SharedMessageIngestResult result = await ref .read(localRepositoryProvider.notifier) .resolveSharedInboxToExistingProfile( inboxEntryId: entry.id, profileId: selectedId, ); if (!mounted) { return; } ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( 'Imported to ${result.profileName ?? 'selected profile'}.', ), ), ); }); } Future _createProfile(SharedInboxEntry entry) async { if (_submitting) { return; } final _CreateProfileDraft? draft = await showDialog<_CreateProfileDraft>( context: context, builder: (BuildContext context) => _CreateProfileDialog( initialName: entry.sourceDisplayName ?? entry.sourceUserId ?? entry.sourceThreadId ?? '', ), ); if (draft == null || !mounted) { return; } await _runRepositoryAction(() async { final SharedMessageIngestResult result = await ref .read(localRepositoryProvider.notifier) .resolveSharedInboxByCreatingProfile( inboxEntryId: entry.id, name: draft.name, relationship: draft.relationship, location: draft.location, ); if (!mounted) { return; } ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( 'Created and imported to ${result.profileName ?? 'profile'}.', ), ), ); }); } Future _dismiss(String inboxEntryId) async { if (_submitting) { return; } await _runRepositoryAction(() async { await ref .read(localRepositoryProvider.notifier) .dismissSharedInboxEntry(inboxEntryId); if (!mounted) { return; } ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Inbox item dismissed.'))); }); } Future _runRepositoryAction(Future Function() action) async { setState(() { _submitting = true; }); try { await action(); } catch (_) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Action failed. Please try again.')), ); } } finally { if (mounted) { setState(() { _submitting = false; }); } } } String _initials(String name) { final List parts = name .split(RegExp(r'\s+')) .where((String value) => value.trim().isNotEmpty) .toList(growable: false); if (parts.isEmpty) { return '?'; } if (parts.length == 1) { return parts.first.substring(0, 1).toUpperCase(); } return '${parts.first.substring(0, 1).toUpperCase()}${parts[1].substring(0, 1).toUpperCase()}'; } List<_CandidateReview> _buildCandidateReviews({ required SharedInboxEntry entry, required List options, }) { final List<_CandidateReview> reviews = <_CandidateReview>[]; for (final PersonProfile person in options) { final int suggestionIndex = entry.candidateProfileIds.indexOf(person.id); final bool suggested = suggestionIndex >= 0; final double confidence = _confidenceScore( entry: entry, person: person, suggested: suggested, suggestionIndex: suggestionIndex, ); reviews.add( _CandidateReview( person: person, confidence: confidence, suggested: suggested, reasons: _confidenceReasons( entry: entry, person: person, confidence: confidence, suggested: suggested, ), ), ); } reviews.sort((_CandidateReview left, _CandidateReview right) { final int compare = right.confidence.compareTo(left.confidence); if (compare != 0) { return compare; } return left.person.name.length.compareTo(right.person.name.length); }); return reviews; } List _confidenceReasons({ required SharedInboxEntry entry, required PersonProfile person, required double confidence, required bool suggested, }) { final List reasons = []; if (suggested) { reasons.add('Previously suggested by identity matching.'); } final String normalizedSender = _normalizedNameForEntry(entry); final String normalizedPerson = _normalizeForScore(person.name); if (normalizedSender.isNotEmpty && normalizedPerson.isNotEmpty) { if (normalizedSender == normalizedPerson) { reasons.add('Exact normalized name match.'); } else if (normalizedSender.contains(normalizedPerson) || normalizedPerson.contains(normalizedSender)) { reasons.add('One name contains the other.'); } } if ((entry.sourceFingerprint ?? '').isNotEmpty) { reasons.add('Share includes stable source fingerprint.'); } if (confidence >= 0.9) { reasons.add('Very high confidence score.'); } else if (confidence >= 0.75) { reasons.add('Strong confidence score.'); } else { reasons.add('Review manually before confirming.'); } return reasons; } double _confidenceScore({ required SharedInboxEntry entry, required PersonProfile person, required bool suggested, required int suggestionIndex, }) { final String senderName = _normalizedNameForEntry(entry); final String personName = _normalizeForScore(person.name); double similarity = 0.42; if (senderName.isNotEmpty && personName.isNotEmpty) { final int distance = _levenshteinDistance(senderName, personName); final int maxLen = senderName.length > personName.length ? senderName.length : personName.length; if (maxLen > 0) { similarity = 1 - (distance / maxLen); } if (similarity < 0) { similarity = 0; } } double score = (similarity * 0.72); if (suggested) { score += 0.2; if (suggestionIndex == 0) { score += 0.05; } } if ((entry.sourceUserId ?? '').isNotEmpty || (entry.sourceThreadId ?? '').isNotEmpty) { score += 0.04; } if ((entry.sourceFingerprint ?? '').isNotEmpty) { score += 0.02; } if (score > 0.99) { return 0.99; } if (score < 0.01) { return 0.01; } return score; } String _normalizedNameForEntry(SharedInboxEntry entry) { if (entry.normalizedDisplayName.isNotEmpty) { return entry.normalizedDisplayName; } return _normalizeForScore(entry.sourceDisplayName ?? ''); } String _normalizeForScore(String value) { return value .trim() .toLowerCase() .replaceAll(RegExp(r'[^a-z0-9]+'), ' ') .replaceAll(RegExp(r'\s+'), ' '); } int _levenshteinDistance(String left, String right) { if (left == right) { return 0; } if (left.isEmpty) { return right.length; } if (right.isEmpty) { return left.length; } List previous = List.generate( right.length + 1, (int index) => index, growable: false, ); for (int i = 1; i <= left.length; i += 1) { final List current = List.filled(right.length + 1, 0); current[0] = i; for (int j = 1; j <= right.length; j += 1) { final int substitutionCost = left[i - 1] == right[j - 1] ? 0 : 1; final int deletion = previous[j] + 1; final int insertion = current[j - 1] + 1; final int substitution = previous[j - 1] + substitutionCost; final int value = deletion < insertion ? deletion : insertion; current[j] = value < substitution ? value : substitution; } previous = current; } return previous[right.length]; } } class _CandidateReview { const _CandidateReview({ required this.person, required this.confidence, required this.suggested, required this.reasons, }); final PersonProfile person; final double confidence; final bool suggested; final List reasons; } class _ConflictReviewDialog extends StatefulWidget { const _ConflictReviewDialog({ required this.entry, required this.reviews, required this.initialsOf, }); final SharedInboxEntry entry; final List<_CandidateReview> reviews; final String Function(String name) initialsOf; @override State<_ConflictReviewDialog> createState() => _ConflictReviewDialogState(); } class _ConflictReviewDialogState extends State<_ConflictReviewDialog> { late String _selectedProfileId; @override void initState() { super.initState(); _selectedProfileId = widget.reviews.first.person.id; } @override Widget build(BuildContext context) { final _CandidateReview selected = widget.reviews.firstWhere( (_CandidateReview review) => review.person.id == _selectedProfileId, orElse: () => widget.reviews.first, ); final String sender = widget.entry.sourceDisplayName ?? widget.entry.sourceUserId ?? 'Unknown sender'; return AlertDialog( title: const Text('Review Match'), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 780), child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Compare incoming share identity with a profile before linking.', style: Theme.of( context, ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: 12), if (MediaQuery.sizeOf(context).width < 760) Column( children: [ _IncomingSharePanel( entry: widget.entry, sender: sender, messagePreview: widget.entry.messageText, ), const SizedBox(height: 8), _CandidatePanel( review: selected, initials: widget.initialsOf(selected.person.name), ), ], ) else Row( children: [ Expanded( child: _IncomingSharePanel( entry: widget.entry, sender: sender, messagePreview: widget.entry.messageText, ), ), const SizedBox(width: 8), Expanded( child: _CandidatePanel( review: selected, initials: widget.initialsOf(selected.person.name), ), ), ], ), const SizedBox(height: 12), Text( 'Candidate profiles', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), Column( children: widget.reviews .map((_CandidateReview review) { final bool selectedTile = review.person.id == _selectedProfileId; return Padding( padding: const EdgeInsets.only(bottom: 4), child: ListTile( selected: selectedTile, selectedTileColor: const Color(0xFFEAF7FB), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), leading: CircleAvatar( child: Text(widget.initialsOf(review.person.name)), ), title: Text(review.person.name), subtitle: Text( '${review.person.relationship} ยท ${_formatPercent(review.confidence)} confidence', ), trailing: review.suggested ? const Icon( Icons.star_rounded, color: Color(0xFF1AB6C8), ) : null, onTap: () { setState(() { _selectedProfileId = review.person.id; }); }, ), ); }) .toList(growable: false), ), ], ), ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () => Navigator.of(context).pop(_selectedProfileId), child: const Text('Confirm Match'), ), ], ); } } class _IncomingSharePanel extends StatelessWidget { const _IncomingSharePanel({ required this.entry, required this.sender, required this.messagePreview, }); final SharedInboxEntry entry; final String sender; final String messagePreview; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: const Color(0xFFF7FBFF), borderRadius: BorderRadius.circular(14), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Incoming Share', style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), _kv('Sender', sender), _kv('Source User ID', entry.sourceUserId ?? '-'), _kv('Source Thread', entry.sourceThreadId ?? '-'), _kv('Fingerprint', entry.sourceFingerprint ?? '-'), const SizedBox(height: 8), Text( 'Message', style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: 4), Text( messagePreview, maxLines: 3, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium, ), ], ), ); } } class _CandidatePanel extends StatelessWidget { const _CandidatePanel({required this.review, required this.initials}); final _CandidateReview review; final String initials; @override Widget build(BuildContext context) { final PersonProfile person = review.person; return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: const Color(0xFFF4FAF8), borderRadius: BorderRadius.circular(14), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ CircleAvatar(child: Text(initials)), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( person.name, style: Theme.of(context).textTheme.titleMedium, ), Text( person.relationship, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTheme.textSecondary, ), ), ], ), ), ], ), const SizedBox(height: 10), Text( 'Confidence ${_formatPercent(review.confidence)}', style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 4), LinearProgressIndicator(value: review.confidence), const SizedBox(height: 10), if ((person.location ?? '').trim().isNotEmpty) _kv('Location', person.location!.trim()), if (person.tags.isNotEmpty) _kv('Tags', person.tags.take(4).join(', ')), const SizedBox(height: 4), ...review.reasons.map( (String reason) => Padding( padding: const EdgeInsets.only(bottom: 2), child: Text( '- $reason', style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: AppTheme.textSecondary), ), ), ), ], ), ); } } Widget _kv(String label, String value) { return Padding( padding: const EdgeInsets.only(bottom: 4), child: RichText( text: TextSpan( style: const TextStyle(color: AppTheme.textSecondary, fontSize: 12), children: [ TextSpan( text: '$label: ', style: const TextStyle( color: AppTheme.textPrimary, fontWeight: FontWeight.w600, ), ), TextSpan(text: value), ], ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ); } String _formatPercent(double value) { final int percent = (value * 100).round().clamp(1, 99); return '$percent%'; } class _InboxEntryCard extends StatelessWidget { const _InboxEntryCard({ required this.entry, required this.compact, required this.submitting, required this.suggestedMatches, required this.onResolveToExisting, required this.onCreateProfile, required this.onDismiss, }); final SharedInboxEntry entry; final bool compact; final bool submitting; final List suggestedMatches; final VoidCallback onResolveToExisting; final VoidCallback onCreateProfile; final VoidCallback onDismiss; @override Widget build(BuildContext context) { final String sender = entry.sourceDisplayName ?? entry.sourceUserId ?? 'Unknown sender'; final bool hasCandidates = suggestedMatches.isNotEmpty; final String reasonLabel = switch (entry.reason) { SharedInboxReason.ambiguousProfileMatch => 'Ambiguous match', SharedInboxReason.nearProfileConflict => 'Near match conflict', SharedInboxReason.missingIdentity => 'Missing identity', }; return FrostedCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( sender, style: Theme.of(context).textTheme.titleLarge, ), ), Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 4, ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(999), color: const Color(0xFFEFF5FA), ), child: Text( reasonLabel, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppTheme.textSecondary, ), ), ), ], ), const SizedBox(height: 8), Text(entry.messageText, style: Theme.of(context).textTheme.bodyLarge), const SizedBox(height: 10), Text( 'Shared ${_formatDateTime(entry.sharedAt)}', style: Theme.of( context, ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), ), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, children: [ FilledButton.icon( onPressed: submitting || !hasCandidates ? null : onResolveToExisting, icon: const Icon(Icons.person_search_rounded), label: Text(hasCandidates ? 'Review & Match' : 'No Matches'), ), OutlinedButton.icon( onPressed: submitting ? null : onCreateProfile, icon: const Icon(Icons.person_add_alt_1_rounded), label: const Text('Create Profile'), ), TextButton.icon( onPressed: submitting ? null : onDismiss, icon: const Icon(Icons.close_rounded), label: Text(compact ? 'Dismiss' : 'Dismiss Item'), ), ], ), ], ), ); } } class _EmptyInbox extends StatelessWidget { const _EmptyInbox({required this.compact}); final bool compact; @override Widget build(BuildContext context) { return FrostedCard( child: SizedBox( width: double.infinity, child: Padding( padding: EdgeInsets.symmetric( vertical: compact ? 18 : 30, horizontal: compact ? 6 : 14, ), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.inbox_rounded, size: 40, color: AppTheme.primary, ), const SizedBox(height: 10), Text( 'No unresolved shares.', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 6), Text( 'Incoming WhatsApp shares that need manual profile mapping will appear here.', style: Theme.of( context, ).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary), textAlign: TextAlign.center, ), ], ), ), ), ); } } class _CreateProfileDraft { const _CreateProfileDraft({ required this.name, required this.relationship, this.location, }); final String name; final String relationship; final String? location; } class _CreateProfileDialog extends StatefulWidget { const _CreateProfileDialog({required this.initialName}); final String initialName; @override State<_CreateProfileDialog> createState() => _CreateProfileDialogState(); } class _CreateProfileDialogState extends State<_CreateProfileDialog> { late final TextEditingController _nameController; late final TextEditingController _relationshipController; late final TextEditingController _locationController; @override void initState() { super.initState(); _nameController = TextEditingController(text: widget.initialName); _relationshipController = TextEditingController(text: 'WhatsApp Contact'); _locationController = TextEditingController(); } @override void dispose() { _nameController.dispose(); _relationshipController.dispose(); _locationController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AlertDialog( title: const Text('Create Profile'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: _nameController, decoration: const InputDecoration(labelText: 'Name'), ), const SizedBox(height: 8), TextField( controller: _relationshipController, decoration: const InputDecoration(labelText: 'Relationship'), ), const SizedBox(height: 8), TextField( controller: _locationController, decoration: const InputDecoration( labelText: 'Location (optional)', ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () { Navigator.of(context).pop( _CreateProfileDraft( name: _nameController.text, relationship: _relationshipController.text, location: _locationController.text, ), ); }, child: const Text('Create'), ), ], ); } } String _formatDateTime(DateTime value) { String twoDigits(int number) => number.toString().padLeft(2, '0'); return '${value.year}-${twoDigits(value.month)}-${twoDigits(value.day)} ${twoDigits(value.hour)}:${twoDigits(value.minute)}'; }