From 103ed089fb17aee730ac4a12dc7cc2d1487dfb1e Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Thu, 19 Feb 2026 01:02:32 +0100 Subject: [PATCH] Add guided conflict review flow in Share Inbox --- docs/SETUP.md | 2 + docs/progress.md | 21 + ios/Podfile.lock | 18 + .../share_intake/share_inbox_view.dart | 512 +++++++++++++++++- .../share_intake/share_inbox_view_test.dart | 56 ++ 5 files changed, 586 insertions(+), 23 deletions(-) diff --git a/docs/SETUP.md b/docs/SETUP.md index 9d2a7b3..377592c 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -105,6 +105,8 @@ Behavior notes: profile mismatches - follow-up routing prefers stable source fingerprint matching (when source user/thread IDs are available) +- Share Inbox now provides a `Review & Match` conflict flow with candidate + confidence scoring and side-by-side preview before confirming profile link - app launch from initial share intent auto-opens the relevant destination: - resolved import -> `People` with imported profile selected - unresolved import -> `Share Inbox` diff --git a/docs/progress.md b/docs/progress.md index 3d5ace6..f73d93e 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -69,6 +69,27 @@ Strengthened WhatsApp identity mapping to reduce accidental mis-links: - near-match conflict queueing - follow-up routing succeeds via fingerprint after manual resolution +## Latest Milestone (2026-02-18): Share Inbox Conflict Review UX + +Upgraded unresolved-share resolution from a basic picker to guided review: + +- Added conflict review modal in Share Inbox: + - `lib/features/share_intake/share_inbox_view.dart` + - new flow: + - tap `Review & Match` + - inspect side-by-side comparison: + - incoming share identity panel (sender, source ids, fingerprint, message) + - selected profile panel (name, relationship, tags/location, confidence) + - review candidate list with confidence percentages and suggestion marker + - confirm explicit link with `Confirm Match` +- Added confidence scoring + rationale hints: + - combines normalized-name similarity, prior candidate suggestion, and source + identity signals into a 1-99% confidence display + - shows short rationale bullets to make matching decisions transparent +- Added widget coverage: + - `test/features/share_intake/share_inbox_view_test.dart` + - verifies conflict review dialog opens and confirm mapping resolves inbox item + ## Latest Milestone (2026-02-18): Share Inbox Resolution Flow Added manual resolution UX and data paths for ambiguous/unidentifiable WhatsApp diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 6159edd..c04ae29 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,28 +1,46 @@ PODS: + - connectivity_plus (0.0.1): + - Flutter - Flutter (1.0.0) + - flutter_local_notifications (0.0.1): + - Flutter - flutter_secure_storage_darwin (10.0.0): - Flutter - FlutterMacOS + - receive_sharing_intent (1.8.1): + - Flutter - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS DEPENDENCIES: + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - Flutter (from `Flutter`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) + - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) EXTERNAL SOURCES: + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" Flutter: :path: Flutter + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" flutter_secure_storage_darwin: :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" + receive_sharing_intent: + :path: ".symlinks/plugins/receive_sharing_intent/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" SPEC CHECKSUMS: + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 + receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/lib/features/share_intake/share_inbox_view.dart b/lib/features/share_intake/share_inbox_view.dart index 233821e..0b9968f 100644 --- a/lib/features/share_intake/share_inbox_view.dart +++ b/lib/features/share_intake/share_inbox_view.dart @@ -120,30 +120,21 @@ class _ShareInboxViewState extends ConsumerState { if (_submitting || options.isEmpty) { return; } - final String? selectedId = await showModalBottomSheet( + final List<_CandidateReview> reviews = _buildCandidateReviews( + entry: entry, + options: options, + ); + if (reviews.isEmpty) { + return; + } + + final String? selectedId = await showDialog( context: context, - showDragHandle: true, builder: (BuildContext context) { - return SafeArea( - child: ListView( - children: options - .map((PersonProfile person) { - final bool suggested = entry.candidateProfileIds.contains( - person.id, - ); - return ListTile( - title: Text(person.name), - subtitle: Text( - suggested - ? '${person.relationship} · Suggested' - : person.relationship, - ), - leading: CircleAvatar(child: Text(_initials(person.name))), - onTap: () => Navigator.of(context).pop(person.id), - ); - }) - .toList(growable: false), - ), + return _ConflictReviewDialog( + entry: entry, + reviews: reviews, + initialsOf: _initials, ); }, ); @@ -263,6 +254,481 @@ class _ShareInboxViewState extends ConsumerState { } 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 { @@ -344,7 +810,7 @@ class _InboxEntryCard extends StatelessWidget { ? null : onResolveToExisting, icon: const Icon(Icons.person_search_rounded), - label: Text(hasCandidates ? 'Match Existing' : 'No Matches'), + label: Text(hasCandidates ? 'Review & Match' : 'No Matches'), ), OutlinedButton.icon( onPressed: submitting ? null : onCreateProfile, diff --git a/test/features/share_intake/share_inbox_view_test.dart b/test/features/share_intake/share_inbox_view_test.dart index 863dfb4..4820eb5 100644 --- a/test/features/share_intake/share_inbox_view_test.dart +++ b/test/features/share_intake/share_inbox_view_test.dart @@ -66,12 +66,68 @@ void main() { expect(find.text('Share Inbox'), findsOneWidget); expect(find.text('Ambiguous match'), findsOneWidget); + expect(find.widgetWithText(FilledButton, 'Review & Match'), findsOneWidget); await tester.tap(find.widgetWithText(TextButton, 'Dismiss Item').first); await tester.pumpAndSettle(); expect(find.text('No unresolved shares.'), findsOneWidget); }); + + testWidgets('opens conflict review dialog and confirms mapping', ( + WidgetTester tester, + ) async { + final ProviderContainer container = ProviderContainer( + overrides: [ + localDataStoreProvider.overrideWithValue(InMemoryLocalDataStore()), + syncStateStoreProvider.overrideWithValue(InMemorySyncStateStore()), + reminderSchedulerProvider.overrideWithValue(_NoopReminderScheduler()), + ], + ); + addTearDown(container.dispose); + + await container.read(localRepositoryProvider.future); + await container + .read(localRepositoryProvider.notifier) + .addPerson( + name: 'Marek Havel', + relationship: 'Friend', + notes: '', + tags: const [], + ); + await container + .read(localRepositoryProvider.notifier) + .ingestSharedMessage( + const SharedMessageIngestInput( + sourceApp: 'whatsapp', + sourceDisplayName: 'Marek Havl', + sourceUserId: 'wa:marek_882', + messageText: 'Can you check this event?', + ), + ); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp(home: Scaffold(body: ShareInboxView())), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Near match conflict'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Review & Match').first); + await tester.pumpAndSettle(); + + expect(find.text('Review Match'), findsOneWidget); + expect(find.text('Incoming Share'), findsOneWidget); + expect(find.text('Candidate profiles'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Confirm Match')); + await tester.pumpAndSettle(); + + expect(find.text('No unresolved shares.'), findsOneWidget); + }); } class _NoopReminderScheduler implements ReminderScheduler {