From d70edb4c4cf42b39e8c2c7a01d5c1d013015002c Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Thu, 19 Feb 2026 00:26:18 +0100 Subject: [PATCH] Add Share Inbox flow for unresolved WhatsApp imports --- docs/SETUP.md | 8 +- docs/progress.md | 40 +- lib/features/local/local_models.dart | 113 ++++ lib/features/local/local_repository.dart | 500 +++++++++++++---- lib/features/settings/settings_view.dart | 15 +- .../share_intake/share_inbox_view.dart | 504 ++++++++++++++++++ .../share_intake/whatsapp_share_listener.dart | 11 +- .../features/local/local_repository_test.dart | 114 +++- .../share_intake/share_inbox_view_test.dart | 86 +++ 9 files changed, 1278 insertions(+), 113 deletions(-) create mode 100644 lib/features/share_intake/share_inbox_view.dart create mode 100644 test/features/share_intake/share_inbox_view_test.dart diff --git a/docs/SETUP.md b/docs/SETUP.md index 5b87806..166fa66 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -96,10 +96,14 @@ Behavior notes: - iOS/Android: integrates with share-intent plugin (`receive_sharing_intent`) - shared text is parsed and ingested into: - - person profile (auto-create when unresolved) + - person profile (auto-create when identity is confident) - source->profile link map for follow-up matching - moment history (`type=whatsapp`) -- Settings includes `Simulate WhatsApp Share` action for local/dev testing. +- ambiguous or missing-identity shares are queued in `Share Inbox` for manual + resolution +- Settings includes: + - `Simulate WhatsApp Share` for local/dev testing + - `Open Share Inbox` to resolve queued items ## Local Persistence Backend diff --git a/docs/progress.md b/docs/progress.md index 19cdbd8..b8e08a0 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -1,6 +1,44 @@ # Relationship Saver Progress Log -Updated: 2026-02-17 +Updated: 2026-02-18 + +## Collaboration Rule (Carry Forward) + +- After every sensible code/documentation change set, create a git commit as + the last step so the next agent session can pick up from clean checkpoints. + +## Latest Milestone (2026-02-18): Share Inbox Resolution Flow + +Added manual resolution UX and data paths for ambiguous/unidentifiable WhatsApp +shares: + +- Introduced unresolved-share queue model: + - `lib/features/local/local_models.dart` + - new `SharedInboxEntry` + `SharedInboxReason` + - persisted in `LocalDataState.sharedInbox` +- Extended ingest result + repository APIs: + - `lib/features/local/local_repository.dart` + - `SharedMessageIngestResult` now reports imported vs queued-for-resolution + - ambiguous/missing-identity shares are routed to inbox instead of forced + auto-link + - added resolution actions: + - resolve inbox item to existing profile + - resolve inbox item by creating a new profile (with optional location) + - dismiss inbox item +- Added dedicated Share Inbox view: + - `lib/features/share_intake/share_inbox_view.dart` + - shows unresolved items, suggested matches, create-profile dialog, dismiss +- Wired entry points: + - `lib/features/share_intake/whatsapp_share_listener.dart` + - snackbar now routes unresolved imports to Share Inbox + - `lib/features/settings/settings_view.dart` + - added `Open Share Inbox` action and updated simulation feedback +- Added tests: + - `test/features/local/local_repository_test.dart` + - unresolved queue behavior + - resolve-to-existing-profile flow + - `test/features/share_intake/share_inbox_view_test.dart` + - inbox render + dismiss behavior ## Latest Milestone (2026-02-17): WhatsApp Share Intake + Profile Quick Actions diff --git a/lib/features/local/local_models.dart b/lib/features/local/local_models.dart index 3c1f686..3e620a5 100644 --- a/lib/features/local/local_models.dart +++ b/lib/features/local/local_models.dart @@ -480,6 +480,106 @@ class SharedMessageEntry { } } +enum SharedInboxReason { ambiguousProfileMatch, missingIdentity } + +@immutable +class SharedInboxEntry { + const SharedInboxEntry({ + required this.id, + required this.sourceApp, + required this.messageText, + required this.sharedAt, + required this.receivedAt, + required this.reason, + required this.candidateProfileIds, + required this.normalizedDisplayName, + this.sourceDisplayName, + this.sourceUserId, + this.sourceThreadId, + }); + + final String id; + final String sourceApp; + final String messageText; + final DateTime sharedAt; + final DateTime receivedAt; + final SharedInboxReason reason; + final List candidateProfileIds; + final String normalizedDisplayName; + final String? sourceDisplayName; + final String? sourceUserId; + final String? sourceThreadId; + + SharedInboxEntry copyWith({ + String? id, + String? sourceApp, + String? messageText, + DateTime? sharedAt, + DateTime? receivedAt, + SharedInboxReason? reason, + List? candidateProfileIds, + String? normalizedDisplayName, + String? sourceDisplayName, + String? sourceUserId, + String? sourceThreadId, + }) { + return SharedInboxEntry( + id: id ?? this.id, + sourceApp: sourceApp ?? this.sourceApp, + messageText: messageText ?? this.messageText, + sharedAt: sharedAt ?? this.sharedAt, + receivedAt: receivedAt ?? this.receivedAt, + reason: reason ?? this.reason, + candidateProfileIds: candidateProfileIds ?? this.candidateProfileIds, + normalizedDisplayName: + normalizedDisplayName ?? this.normalizedDisplayName, + sourceDisplayName: sourceDisplayName ?? this.sourceDisplayName, + sourceUserId: sourceUserId ?? this.sourceUserId, + sourceThreadId: sourceThreadId ?? this.sourceThreadId, + ); + } + + Map toJson() { + return { + 'id': id, + 'sourceApp': sourceApp, + 'messageText': messageText, + 'sharedAt': sharedAt.toUtc().toIso8601String(), + 'receivedAt': receivedAt.toUtc().toIso8601String(), + 'reason': reason.name, + 'candidateProfileIds': candidateProfileIds, + 'normalizedDisplayName': normalizedDisplayName, + 'sourceDisplayName': sourceDisplayName, + 'sourceUserId': sourceUserId, + 'sourceThreadId': sourceThreadId, + }; + } + + factory SharedInboxEntry.fromJson(Map json) { + final String reasonName = + json['reason'] as String? ?? SharedInboxReason.missingIdentity.name; + return SharedInboxEntry( + id: json['id'] as String, + sourceApp: json['sourceApp'] as String, + messageText: json['messageText'] as String? ?? '', + sharedAt: DateTime.parse(json['sharedAt'] as String).toLocal(), + receivedAt: DateTime.parse(json['receivedAt'] as String).toLocal(), + reason: SharedInboxReason.values.firstWhere( + (SharedInboxReason item) => item.name == reasonName, + orElse: () => SharedInboxReason.missingIdentity, + ), + candidateProfileIds: + (json['candidateProfileIds'] as List? ?? []) + .map((dynamic id) => '$id') + .toList(growable: false), + normalizedDisplayName: json['normalizedDisplayName'] as String? ?? '', + sourceDisplayName: json['sourceDisplayName'] as String?, + sourceUserId: json['sourceUserId'] as String?, + sourceThreadId: json['sourceThreadId'] as String?, + ); + } +} + @immutable class DashboardSummary { const DashboardSummary({ @@ -505,6 +605,7 @@ class LocalDataState { required this.tasks, this.sourceLinks = const [], this.sharedMessages = const [], + this.sharedInbox = const [], }); final List people; @@ -514,6 +615,7 @@ class LocalDataState { final List tasks; final List sourceLinks; final List sharedMessages; + final List sharedInbox; LocalDataState copyWith({ List? people, @@ -523,6 +625,7 @@ class LocalDataState { List? tasks, List? sourceLinks, List? sharedMessages, + List? sharedInbox, }) { return LocalDataState( people: people ?? this.people, @@ -532,6 +635,7 @@ class LocalDataState { tasks: tasks ?? this.tasks, sourceLinks: sourceLinks ?? this.sourceLinks, sharedMessages: sharedMessages ?? this.sharedMessages, + sharedInbox: sharedInbox ?? this.sharedInbox, ); } @@ -574,6 +678,9 @@ class LocalDataState { 'sharedMessages': sharedMessages .map((SharedMessageEntry entry) => entry.toJson()) .toList(growable: false), + 'sharedInbox': sharedInbox + .map((SharedInboxEntry entry) => entry.toJson()) + .toList(growable: false), }; } @@ -621,6 +728,12 @@ class LocalDataState { SharedMessageEntry.fromJson(entry as Map), ) .toList(growable: false), + sharedInbox: (json['sharedInbox'] as List? ?? []) + .map( + (dynamic entry) => + SharedInboxEntry.fromJson(entry as Map), + ) + .toList(growable: false), ); } diff --git a/lib/features/local/local_repository.dart b/lib/features/local/local_repository.dart index 0f221a6..4b2493d 100644 --- a/lib/features/local/local_repository.dart +++ b/lib/features/local/local_repository.dart @@ -32,20 +32,47 @@ class SharedMessageIngestInput { } /// Result of shared-message ingest with profile resolution metadata. +enum SharedMessageIngestStatus { imported, queuedForResolution } + class SharedMessageIngestResult { const SharedMessageIngestResult({ + required this.status, + required this.createdProfile, + required this.createdSourceLink, + this.profileId, + this.profileName, + this.momentId, + this.inboxEntryId, + }); + + const SharedMessageIngestResult.imported({ required this.profileId, required this.profileName, required this.createdProfile, required this.createdSourceLink, required this.momentId, - }); + }) : status = SharedMessageIngestStatus.imported, + inboxEntryId = null; - final String profileId; - final String profileName; + const SharedMessageIngestResult.queuedForResolution({ + required this.inboxEntryId, + }) : status = SharedMessageIngestStatus.queuedForResolution, + createdProfile = false, + createdSourceLink = false, + profileId = null, + profileName = null, + momentId = null; + + final SharedMessageIngestStatus status; + final String? profileId; + final String? profileName; final bool createdProfile; final bool createdSourceLink; - final String momentId; + final String? momentId; + final String? inboxEntryId; + + bool get isQueuedForResolution => + status == SharedMessageIngestStatus.queuedForResolution; } /// Persisted local data source for offline-first product state. @@ -170,10 +197,29 @@ class LocalRepository extends AsyncNotifier { resolvedPerson = _findPersonById(current.people, matchedLink.profileId); } - if (resolvedPerson == null && normalizedDisplayName.isNotEmpty) { - resolvedPerson = _findPersonByNormalizedName( - current.people, - normalizedDisplayName, + final List matchingPeople = normalizedDisplayName.isEmpty + ? const [] + : _findPeopleByNormalizedName(current.people, normalizedDisplayName); + + if (resolvedPerson == null && matchingPeople.length == 1) { + resolvedPerson = matchingPeople.first; + } + + if (resolvedPerson == null && matchingPeople.length > 1) { + return _queueSharedInbox( + current: current, + sourceApp: sourceApp, + messageText: trimmedMessage, + sharedAt: sharedAt, + receivedAt: now, + sourceDisplayName: sourceDisplayName, + sourceUserId: sourceUserId, + sourceThreadId: sourceThreadId, + normalizedDisplayName: normalizedDisplayName, + reason: SharedInboxReason.ambiguousProfileMatch, + candidateProfileIds: matchingPeople + .map((PersonProfile person) => person.id) + .toList(growable: false), ); } @@ -182,10 +228,30 @@ class LocalRepository extends AsyncNotifier { growable: true, ); if (resolvedPerson == null) { + final String? inferredName = _deriveAutoProfileName( + sourceDisplayName: sourceDisplayName, + sourceUserId: sourceUserId, + sourceThreadId: sourceThreadId, + ); + if (inferredName == null) { + return _queueSharedInbox( + current: current, + sourceApp: sourceApp, + messageText: trimmedMessage, + sharedAt: sharedAt, + receivedAt: now, + sourceDisplayName: sourceDisplayName, + sourceUserId: sourceUserId, + sourceThreadId: sourceThreadId, + normalizedDisplayName: normalizedDisplayName, + reason: SharedInboxReason.missingIdentity, + candidateProfileIds: const [], + ); + } createdProfile = true; resolvedPerson = PersonProfile( id: 'p-${_uuid.v4()}', - name: sourceDisplayName ?? 'WhatsApp Contact', + name: inferredName, relationship: 'WhatsApp Contact', affinityScore: 70, nextMoment: DateTime.now().add(const Duration(days: 2)), @@ -195,106 +261,142 @@ class LocalRepository extends AsyncNotifier { nextPeople.insert(0, resolvedPerson); } - final bool createdSourceLink = matchedLink == null; - final List nextLinks = current.sourceLinks.toList( - growable: true, + return _ingestIntoResolvedProfile( + current: current, + sourceApp: sourceApp, + messageText: trimmedMessage, + sharedAt: sharedAt, + importedAt: now, + sourceDisplayName: sourceDisplayName, + sourceUserId: sourceUserId, + sourceThreadId: sourceThreadId, + normalizedDisplayName: normalizedDisplayName, + resolvedPerson: resolvedPerson, + matchedLink: matchedLink, + createdProfile: createdProfile, + people: nextPeople, + resolvedAutomatically: true, ); - if (matchedLink == null) { - nextLinks.insert( - 0, - SourceProfileLink( - id: 'sl-${_uuid.v4()}', - sourceApp: sourceApp, - sourceUserId: sourceUserId, - sourceThreadId: sourceThreadId, - normalizedDisplayName: normalizedDisplayName, - profileId: resolvedPerson.id, - firstSeenAt: sharedAt, - lastSeenAt: sharedAt, - ), - ); - } else { - final int index = nextLinks.indexWhere( - (SourceProfileLink link) => link.id == matchedLink.id, - ); - if (index >= 0) { - nextLinks[index] = matchedLink.copyWith( - sourceUserId: sourceUserId ?? matchedLink.sourceUserId, - sourceThreadId: sourceThreadId ?? matchedLink.sourceThreadId, - normalizedDisplayName: normalizedDisplayName.isEmpty - ? matchedLink.normalizedDisplayName - : normalizedDisplayName, - profileId: resolvedPerson.id, - lastSeenAt: sharedAt, - ); - } + } + + Future resolveSharedInboxToExistingProfile({ + required String inboxEntryId, + required String profileId, + }) async { + final LocalDataState current = _requireState(); + final SharedInboxEntry entry = _requireSharedInboxEntry( + current, + inboxEntryId, + ); + final PersonProfile? resolvedPerson = _findPersonById( + current.people, + profileId, + ); + if (resolvedPerson == null) { + throw ArgumentError('Profile not found for inbox entry'); } - final String summary = trimmedMessage.length > 1800 - ? trimmedMessage.substring(0, 1800) - : trimmedMessage; - final RelationshipMoment moment = RelationshipMoment( - id: 'm-${_uuid.v4()}', - personId: resolvedPerson.id, - title: _titleFromSummary(summary), - summary: summary, - at: sharedAt, - type: sourceApp == 'whatsapp' ? 'whatsapp' : 'shared', + final SourceProfileLink? matchedLink = _findExistingSourceLink( + links: current.sourceLinks, + sourceApp: entry.sourceApp, + sourceUserId: entry.sourceUserId, + sourceThreadId: entry.sourceThreadId, + normalizedDisplayName: entry.normalizedDisplayName, ); - final List nextMoments = [ - moment, - ...current.moments, - ]; - final List nextMessages = [ - SharedMessageEntry( - id: 'sm-${_uuid.v4()}', - sourceApp: sourceApp, - profileId: resolvedPerson.id, - messageText: summary, - sharedAt: sharedAt, - importedAt: now, - resolvedAutomatically: true, - sourceDisplayName: sourceDisplayName, - sourceUserId: sourceUserId, - sourceThreadId: sourceThreadId, - ), - ...current.sharedMessages, - ]; + return _ingestIntoResolvedProfile( + current: current, + sourceApp: entry.sourceApp, + messageText: entry.messageText, + sharedAt: entry.sharedAt, + importedAt: DateTime.now(), + sourceDisplayName: entry.sourceDisplayName, + sourceUserId: entry.sourceUserId, + sourceThreadId: entry.sourceThreadId, + normalizedDisplayName: entry.normalizedDisplayName, + resolvedPerson: resolvedPerson, + matchedLink: matchedLink, + createdProfile: false, + people: current.people, + resolvedAutomatically: false, + consumedInboxEntryId: entry.id, + ); + } - await _setState( - current.copyWith( - people: nextPeople, - moments: nextMoments, - sourceLinks: nextLinks, - sharedMessages: nextMessages, - ), + Future resolveSharedInboxByCreatingProfile({ + required String inboxEntryId, + String? name, + String relationship = 'WhatsApp Contact', + String? location, + }) async { + final LocalDataState current = _requireState(); + final SharedInboxEntry entry = _requireSharedInboxEntry( + current, + inboxEntryId, + ); + final String profileName = + _trimToNull(name) ?? + _deriveAutoProfileName( + sourceDisplayName: entry.sourceDisplayName, + sourceUserId: entry.sourceUserId, + sourceThreadId: entry.sourceThreadId, + ) ?? + 'WhatsApp Contact'; + final String normalizedRelationship = relationship.trim().isEmpty + ? 'WhatsApp Contact' + : relationship.trim(); + final PersonProfile createdPerson = PersonProfile( + id: 'p-${_uuid.v4()}', + name: profileName, + relationship: normalizedRelationship, + affinityScore: 70, + nextMoment: DateTime.now().add(const Duration(days: 2)), + tags: const ['whatsapp'], + notes: 'Created from Share Inbox resolution.', + location: _trimToNull(location), ); - final List outbound = [ - if (createdProfile) - _buildEnvelope( - entityType: 'person', - entityId: resolvedPerson.id, - op: ChangeOperation.upsert, - payload: _personPayload(resolvedPerson), - ), - _buildEnvelope( - entityType: 'capture', - entityId: moment.id, - op: ChangeOperation.upsert, - payload: _momentPayload(moment), - ), - ]; - await _enqueueChanges(outbound); - - return SharedMessageIngestResult( - profileId: resolvedPerson.id, - profileName: resolvedPerson.name, - createdProfile: createdProfile, - createdSourceLink: createdSourceLink, - momentId: moment.id, + final SourceProfileLink? matchedLink = _findExistingSourceLink( + links: current.sourceLinks, + sourceApp: entry.sourceApp, + sourceUserId: entry.sourceUserId, + sourceThreadId: entry.sourceThreadId, + normalizedDisplayName: entry.normalizedDisplayName, ); + + final List nextPeople = [ + createdPerson, + ...current.people, + ]; + + return _ingestIntoResolvedProfile( + current: current, + sourceApp: entry.sourceApp, + messageText: entry.messageText, + sharedAt: entry.sharedAt, + importedAt: DateTime.now(), + sourceDisplayName: entry.sourceDisplayName, + sourceUserId: entry.sourceUserId, + sourceThreadId: entry.sourceThreadId, + normalizedDisplayName: entry.normalizedDisplayName, + resolvedPerson: createdPerson, + matchedLink: matchedLink, + createdProfile: true, + people: nextPeople, + resolvedAutomatically: false, + consumedInboxEntryId: entry.id, + ); + } + + Future dismissSharedInboxEntry(String inboxEntryId) async { + final LocalDataState current = _requireState(); + final List nextInbox = current.sharedInbox + .where((SharedInboxEntry entry) => entry.id != inboxEntryId) + .toList(growable: false); + if (nextInbox.length == current.sharedInbox.length) { + return; + } + await _setState(current.copyWith(sharedInbox: nextInbox)); } Future updatePerson(PersonProfile person) async { @@ -1162,6 +1264,172 @@ class LocalRepository extends AsyncNotifier { ); } + Future _queueSharedInbox({ + required LocalDataState current, + required String sourceApp, + required String messageText, + required DateTime sharedAt, + required DateTime receivedAt, + required SharedInboxReason reason, + required List candidateProfileIds, + required String normalizedDisplayName, + String? sourceDisplayName, + String? sourceUserId, + String? sourceThreadId, + }) async { + final String summary = messageText.length > 1800 + ? messageText.substring(0, 1800) + : messageText; + final SharedInboxEntry entry = SharedInboxEntry( + id: 'si-${_uuid.v4()}', + sourceApp: sourceApp, + messageText: summary, + sharedAt: sharedAt, + receivedAt: receivedAt, + reason: reason, + candidateProfileIds: candidateProfileIds, + normalizedDisplayName: normalizedDisplayName, + sourceDisplayName: sourceDisplayName, + sourceUserId: sourceUserId, + sourceThreadId: sourceThreadId, + ); + final List nextInbox = [ + entry, + ...current.sharedInbox, + ]; + await _setState(current.copyWith(sharedInbox: nextInbox)); + return SharedMessageIngestResult.queuedForResolution( + inboxEntryId: entry.id, + ); + } + + Future _ingestIntoResolvedProfile({ + required LocalDataState current, + required String sourceApp, + required String messageText, + required DateTime sharedAt, + required DateTime importedAt, + required String normalizedDisplayName, + required PersonProfile resolvedPerson, + required bool createdProfile, + required List people, + required bool resolvedAutomatically, + SourceProfileLink? matchedLink, + String? sourceDisplayName, + String? sourceUserId, + String? sourceThreadId, + String? consumedInboxEntryId, + }) async { + final bool createdSourceLink = matchedLink == null; + final List nextLinks = current.sourceLinks.toList( + growable: true, + ); + if (matchedLink == null) { + nextLinks.insert( + 0, + SourceProfileLink( + id: 'sl-${_uuid.v4()}', + sourceApp: sourceApp, + sourceUserId: sourceUserId, + sourceThreadId: sourceThreadId, + normalizedDisplayName: normalizedDisplayName, + profileId: resolvedPerson.id, + firstSeenAt: sharedAt, + lastSeenAt: sharedAt, + ), + ); + } else { + final int index = nextLinks.indexWhere( + (SourceProfileLink link) => link.id == matchedLink.id, + ); + if (index >= 0) { + nextLinks[index] = matchedLink.copyWith( + sourceUserId: sourceUserId ?? matchedLink.sourceUserId, + sourceThreadId: sourceThreadId ?? matchedLink.sourceThreadId, + normalizedDisplayName: normalizedDisplayName.isEmpty + ? matchedLink.normalizedDisplayName + : normalizedDisplayName, + profileId: resolvedPerson.id, + lastSeenAt: sharedAt, + ); + } + } + + final String summary = messageText.length > 1800 + ? messageText.substring(0, 1800) + : messageText; + final RelationshipMoment moment = RelationshipMoment( + id: 'm-${_uuid.v4()}', + personId: resolvedPerson.id, + title: _titleFromSummary(summary), + summary: summary, + at: sharedAt, + type: sourceApp == 'whatsapp' ? 'whatsapp' : 'shared', + ); + + final List nextMoments = [ + moment, + ...current.moments, + ]; + final List nextMessages = [ + SharedMessageEntry( + id: 'sm-${_uuid.v4()}', + sourceApp: sourceApp, + profileId: resolvedPerson.id, + messageText: summary, + sharedAt: sharedAt, + importedAt: importedAt, + resolvedAutomatically: resolvedAutomatically, + sourceDisplayName: sourceDisplayName, + sourceUserId: sourceUserId, + sourceThreadId: sourceThreadId, + ), + ...current.sharedMessages, + ]; + final List nextInbox = consumedInboxEntryId == null + ? current.sharedInbox + : current.sharedInbox + .where( + (SharedInboxEntry entry) => entry.id != consumedInboxEntryId, + ) + .toList(growable: false); + + await _setState( + current.copyWith( + people: people, + moments: nextMoments, + sourceLinks: nextLinks, + sharedMessages: nextMessages, + sharedInbox: nextInbox, + ), + ); + + final List outbound = [ + if (createdProfile) + _buildEnvelope( + entityType: 'person', + entityId: resolvedPerson.id, + op: ChangeOperation.upsert, + payload: _personPayload(resolvedPerson), + ), + _buildEnvelope( + entityType: 'capture', + entityId: moment.id, + op: ChangeOperation.upsert, + payload: _momentPayload(moment), + ), + ]; + await _enqueueChanges(outbound); + + return SharedMessageIngestResult.imported( + profileId: resolvedPerson.id, + profileName: resolvedPerson.name, + createdProfile: createdProfile, + createdSourceLink: createdSourceLink, + momentId: moment.id, + ); + } + String _titleFromSummary(String summary) { final List words = summary.split(RegExp(r'\s+')); final int take = words.length < 5 ? words.length : 5; @@ -1213,16 +1481,38 @@ class LocalRepository extends AsyncNotifier { return null; } - PersonProfile? _findPersonByNormalizedName( + List _findPeopleByNormalizedName( List people, String normalizedDisplayName, ) { - for (final PersonProfile person in people) { - if (_normalizeDisplayName(person.name) == normalizedDisplayName) { - return person; + return people + .where( + (PersonProfile person) => + _normalizeDisplayName(person.name) == normalizedDisplayName, + ) + .toList(growable: false); + } + + SharedInboxEntry _requireSharedInboxEntry( + LocalDataState current, + String inboxEntryId, + ) { + for (final SharedInboxEntry entry in current.sharedInbox) { + if (entry.id == inboxEntryId) { + return entry; } } - return null; + throw ArgumentError('Shared inbox entry not found'); + } + + String? _deriveAutoProfileName({ + required String? sourceDisplayName, + required String? sourceUserId, + required String? sourceThreadId, + }) { + return _trimToNull(sourceDisplayName) ?? + _trimToNull(sourceUserId) ?? + _trimToNull(sourceThreadId); } String _normalizeDisplayName(String? raw) { diff --git a/lib/features/settings/settings_view.dart b/lib/features/settings/settings_view.dart index dd7102f..5cea5ab 100644 --- a/lib/features/settings/settings_view.dart +++ b/lib/features/settings/settings_view.dart @@ -5,6 +5,7 @@ import 'package:relationship_saver/core/config/app_theme.dart'; import 'package:relationship_saver/features/auth/session_controller.dart'; import 'package:relationship_saver/features/local/local_repository.dart'; import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart'; +import 'package:relationship_saver/features/share_intake/share_inbox_view.dart'; import 'package:relationship_saver/features/share_intake/whatsapp_share_parser.dart'; import 'package:relationship_saver/features/shared/frosted_card.dart'; import 'package:relationship_saver/integrations/backend/models/backend_models.dart'; @@ -128,6 +129,16 @@ class SettingsView extends ConsumerWidget { icon: const Icon(Icons.message_rounded), label: const Text('Simulate WhatsApp Share'), ), + OutlinedButton.icon( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => + const ShareInboxView(), + ), + ), + icon: const Icon(Icons.inbox_rounded), + label: const Text('Open Share Inbox'), + ), ], ), ], @@ -223,7 +234,9 @@ class SettingsView extends ConsumerWidget { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - result.createdProfile + result.isQueuedForResolution + ? 'Imported to Share Inbox for profile resolution.' + : result.createdProfile ? 'Imported and created ${result.profileName}.' : 'Imported to ${result.profileName}.', ), diff --git a/lib/features/share_intake/share_inbox_view.dart b/lib/features/share_intake/share_inbox_view.dart new file mode 100644 index 0000000..01d3a60 --- /dev/null +++ b/lib/features/share_intake/share_inbox_view.dart @@ -0,0 +1,504 @@ +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 String? selectedId = await showModalBottomSheet( + 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), + ), + ); + }, + ); + 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()}'; + } +} + +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.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 ? 'Match Existing' : '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)}'; +} diff --git a/lib/features/share_intake/whatsapp_share_listener.dart b/lib/features/share_intake/whatsapp_share_listener.dart index 83d9a31..eb5b60e 100644 --- a/lib/features/share_intake/whatsapp_share_listener.dart +++ b/lib/features/share_intake/whatsapp_share_listener.dart @@ -7,6 +7,7 @@ import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:relationship_saver/core/config/app_config.dart'; import 'package:relationship_saver/features/local/local_repository.dart'; import 'package:relationship_saver/features/people/people_view.dart'; +import 'package:relationship_saver/features/share_intake/share_inbox_view.dart'; import 'package:relationship_saver/features/share_intake/whatsapp_share_parser.dart'; /// Listens for share intents and ingests WhatsApp text into local data. @@ -132,16 +133,20 @@ class _WhatsAppShareListenerState extends ConsumerState { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - result.createdProfile + result.isQueuedForResolution + ? 'WhatsApp share needs profile resolution.' + : result.createdProfile ? 'Imported from WhatsApp and created profile ${result.profileName}.' : 'Imported from WhatsApp to ${result.profileName}.', ), action: SnackBarAction( - label: 'Open People', + label: result.isQueuedForResolution ? 'Open Inbox' : 'Open People', onPressed: () { Navigator.of(context).push( MaterialPageRoute( - builder: (BuildContext context) => const PeopleView(), + builder: (BuildContext context) => result.isQueuedForResolution + ? const ShareInboxView() + : const PeopleView(), ), ); }, diff --git a/test/features/local/local_repository_test.dart b/test/features/local/local_repository_test.dart index 09f0b33..d7ae4ce 100644 --- a/test/features/local/local_repository_test.dart +++ b/test/features/local/local_repository_test.dart @@ -107,7 +107,9 @@ void main() { expect(first.createdProfile, isTrue); expect(afterFirst.people.length, before.people.length + 1); expect( - afterFirst.sourceLinks.any((link) => link.profileId == first.profileId), + afterFirst.sourceLinks.any( + (SourceProfileLink link) => link.profileId == first.profileId, + ), isTrue, ); expect( @@ -142,6 +144,116 @@ void main() { }, ); + test( + 'queues ambiguous shared message into inbox for manual resolution', + () async { + final ProviderContainer container = _createContainer(); + addTearDown(container.dispose); + + final LocalDataState before = await container.read( + localRepositoryProvider.future, + ); + + await container + .read(localRepositoryProvider.notifier) + .addPerson( + name: 'Alex Morgan', + relationship: 'Friend', + notes: '', + tags: const [], + ); + await container + .read(localRepositoryProvider.notifier) + .addPerson( + name: 'Alex Morgan', + relationship: 'Cousin', + notes: '', + tags: const [], + ); + + final SharedMessageIngestResult result = await container + .read(localRepositoryProvider.notifier) + .ingestSharedMessage( + const SharedMessageIngestInput( + sourceApp: 'whatsapp', + sourceDisplayName: 'Alex Morgan', + messageText: 'Can we sync up tomorrow?', + ), + ); + + final LocalDataState after = await container.read( + localRepositoryProvider.future, + ); + expect(result.isQueuedForResolution, isTrue); + expect(result.inboxEntryId, isNotNull); + expect(after.sharedInbox.length, 1); + expect( + after.sharedInbox.first.reason, + SharedInboxReason.ambiguousProfileMatch, + ); + expect(after.moments.length, before.moments.length); + }, + ); + + test('resolves inbox item to existing profile', () async { + final ProviderContainer container = _createContainer(); + addTearDown(container.dispose); + + await container.read(localRepositoryProvider.future); + + await container + .read(localRepositoryProvider.notifier) + .addPerson( + name: 'Alex Morgan', + relationship: 'Friend', + notes: '', + tags: const [], + ); + await container + .read(localRepositoryProvider.notifier) + .addPerson( + name: 'Alex Morgan', + relationship: 'Cousin', + notes: '', + tags: const [], + ); + + final SharedMessageIngestResult queued = await container + .read(localRepositoryProvider.notifier) + .ingestSharedMessage( + const SharedMessageIngestInput( + sourceApp: 'whatsapp', + sourceDisplayName: 'Alex Morgan', + messageText: 'Did you see the offer?', + ), + ); + final LocalDataState pending = await container.read( + localRepositoryProvider.future, + ); + final String profileId = pending.people.first.id; + + final SharedMessageIngestResult resolved = await container + .read(localRepositoryProvider.notifier) + .resolveSharedInboxToExistingProfile( + inboxEntryId: queued.inboxEntryId!, + profileId: profileId, + ); + + final LocalDataState after = await container.read( + localRepositoryProvider.future, + ); + expect(resolved.isQueuedForResolution, isFalse); + expect(resolved.profileId, profileId); + expect(after.sharedInbox, isEmpty); + expect( + after.moments.any( + (RelationshipMoment moment) => moment.personId == profileId, + ), + isTrue, + ); + expect(after.sharedMessages.first.resolvedAutomatically, isFalse); + }); + test('adds, archives, edits, and deletes ideas', () async { final ProviderContainer container = _createContainer(); addTearDown(container.dispose); diff --git a/test/features/share_intake/share_inbox_view_test.dart b/test/features/share_intake/share_inbox_view_test.dart new file mode 100644 index 0000000..863dfb4 --- /dev/null +++ b/test/features/share_intake/share_inbox_view_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:relationship_saver/features/local/local_models.dart'; +import 'package:relationship_saver/features/local/local_repository.dart'; +import 'package:relationship_saver/features/local/storage/local_data_store_in_memory.dart'; +import 'package:relationship_saver/features/local/storage/local_data_store_provider.dart'; +import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler.dart'; +import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart'; +import 'package:relationship_saver/features/share_intake/share_inbox_view.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_in_memory.dart'; +import 'package:relationship_saver/features/sync/storage/sync_state_store_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets('shows queued inbox entry and allows dismiss', ( + 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: 'Alex Morgan', + relationship: 'Friend', + notes: '', + tags: const [], + ); + await container + .read(localRepositoryProvider.notifier) + .addPerson( + name: 'Alex Morgan', + relationship: 'Cousin', + notes: '', + tags: const [], + ); + await container + .read(localRepositoryProvider.notifier) + .ingestSharedMessage( + const SharedMessageIngestInput( + sourceApp: 'whatsapp', + sourceDisplayName: 'Alex Morgan', + messageText: 'Need help choosing this gift?', + ), + ); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp(home: Scaffold(body: ShareInboxView())), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Share Inbox'), findsOneWidget); + expect(find.text('Ambiguous match'), findsOneWidget); + + await tester.tap(find.widgetWithText(TextButton, 'Dismiss Item').first); + await tester.pumpAndSettle(); + + expect(find.text('No unresolved shares.'), findsOneWidget); + }); +} + +class _NoopReminderScheduler implements ReminderScheduler { + @override + Future clearAll() async {} + + @override + Future requestPermissions() async => true; + + @override + Future reconcile(List reminders) async {} +}