Add Share Inbox flow for unresolved WhatsApp imports

This commit is contained in:
Rijad Zuzo
2026-02-19 00:26:18 +01:00
parent d2205bd3d9
commit d70edb4c4c
9 changed files with 1278 additions and 113 deletions
+6 -2
View File
@@ -96,10 +96,14 @@ Behavior notes:
- iOS/Android: integrates with share-intent plugin (`receive_sharing_intent`) - iOS/Android: integrates with share-intent plugin (`receive_sharing_intent`)
- shared text is parsed and ingested into: - 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 - source->profile link map for follow-up matching
- moment history (`type=whatsapp`) - 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 ## Local Persistence Backend
+39 -1
View File
@@ -1,6 +1,44 @@
# Relationship Saver Progress Log # 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 ## Latest Milestone (2026-02-17): WhatsApp Share Intake + Profile Quick Actions
+113
View File
@@ -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<String> 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<String>? 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<String, dynamic> toJson() {
return <String, dynamic>{
'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<String, dynamic> 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<dynamic>? ?? <dynamic>[])
.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 @immutable
class DashboardSummary { class DashboardSummary {
const DashboardSummary({ const DashboardSummary({
@@ -505,6 +605,7 @@ class LocalDataState {
required this.tasks, required this.tasks,
this.sourceLinks = const <SourceProfileLink>[], this.sourceLinks = const <SourceProfileLink>[],
this.sharedMessages = const <SharedMessageEntry>[], this.sharedMessages = const <SharedMessageEntry>[],
this.sharedInbox = const <SharedInboxEntry>[],
}); });
final List<PersonProfile> people; final List<PersonProfile> people;
@@ -514,6 +615,7 @@ class LocalDataState {
final List<DashboardTask> tasks; final List<DashboardTask> tasks;
final List<SourceProfileLink> sourceLinks; final List<SourceProfileLink> sourceLinks;
final List<SharedMessageEntry> sharedMessages; final List<SharedMessageEntry> sharedMessages;
final List<SharedInboxEntry> sharedInbox;
LocalDataState copyWith({ LocalDataState copyWith({
List<PersonProfile>? people, List<PersonProfile>? people,
@@ -523,6 +625,7 @@ class LocalDataState {
List<DashboardTask>? tasks, List<DashboardTask>? tasks,
List<SourceProfileLink>? sourceLinks, List<SourceProfileLink>? sourceLinks,
List<SharedMessageEntry>? sharedMessages, List<SharedMessageEntry>? sharedMessages,
List<SharedInboxEntry>? sharedInbox,
}) { }) {
return LocalDataState( return LocalDataState(
people: people ?? this.people, people: people ?? this.people,
@@ -532,6 +635,7 @@ class LocalDataState {
tasks: tasks ?? this.tasks, tasks: tasks ?? this.tasks,
sourceLinks: sourceLinks ?? this.sourceLinks, sourceLinks: sourceLinks ?? this.sourceLinks,
sharedMessages: sharedMessages ?? this.sharedMessages, sharedMessages: sharedMessages ?? this.sharedMessages,
sharedInbox: sharedInbox ?? this.sharedInbox,
); );
} }
@@ -574,6 +678,9 @@ class LocalDataState {
'sharedMessages': sharedMessages 'sharedMessages': sharedMessages
.map((SharedMessageEntry entry) => entry.toJson()) .map((SharedMessageEntry entry) => entry.toJson())
.toList(growable: false), .toList(growable: false),
'sharedInbox': sharedInbox
.map((SharedInboxEntry entry) => entry.toJson())
.toList(growable: false),
}; };
} }
@@ -621,6 +728,12 @@ class LocalDataState {
SharedMessageEntry.fromJson(entry as Map<String, dynamic>), SharedMessageEntry.fromJson(entry as Map<String, dynamic>),
) )
.toList(growable: false), .toList(growable: false),
sharedInbox: (json['sharedInbox'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic entry) =>
SharedInboxEntry.fromJson(entry as Map<String, dynamic>),
)
.toList(growable: false),
); );
} }
+396 -106
View File
@@ -32,20 +32,47 @@ class SharedMessageIngestInput {
} }
/// Result of shared-message ingest with profile resolution metadata. /// Result of shared-message ingest with profile resolution metadata.
enum SharedMessageIngestStatus { imported, queuedForResolution }
class SharedMessageIngestResult { class SharedMessageIngestResult {
const 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.profileId,
required this.profileName, required this.profileName,
required this.createdProfile, required this.createdProfile,
required this.createdSourceLink, required this.createdSourceLink,
required this.momentId, required this.momentId,
}); }) : status = SharedMessageIngestStatus.imported,
inboxEntryId = null;
final String profileId; const SharedMessageIngestResult.queuedForResolution({
final String profileName; 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 createdProfile;
final bool createdSourceLink; 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. /// Persisted local data source for offline-first product state.
@@ -170,10 +197,29 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
resolvedPerson = _findPersonById(current.people, matchedLink.profileId); resolvedPerson = _findPersonById(current.people, matchedLink.profileId);
} }
if (resolvedPerson == null && normalizedDisplayName.isNotEmpty) { final List<PersonProfile> matchingPeople = normalizedDisplayName.isEmpty
resolvedPerson = _findPersonByNormalizedName( ? const <PersonProfile>[]
current.people, : _findPeopleByNormalizedName(current.people, normalizedDisplayName);
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<LocalDataState> {
growable: true, growable: true,
); );
if (resolvedPerson == null) { 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 <String>[],
);
}
createdProfile = true; createdProfile = true;
resolvedPerson = PersonProfile( resolvedPerson = PersonProfile(
id: 'p-${_uuid.v4()}', id: 'p-${_uuid.v4()}',
name: sourceDisplayName ?? 'WhatsApp Contact', name: inferredName,
relationship: 'WhatsApp Contact', relationship: 'WhatsApp Contact',
affinityScore: 70, affinityScore: 70,
nextMoment: DateTime.now().add(const Duration(days: 2)), nextMoment: DateTime.now().add(const Duration(days: 2)),
@@ -195,108 +261,144 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
nextPeople.insert(0, resolvedPerson); nextPeople.insert(0, resolvedPerson);
} }
final bool createdSourceLink = matchedLink == null; return _ingestIntoResolvedProfile(
final List<SourceProfileLink> nextLinks = current.sourceLinks.toList( current: current,
growable: true,
);
if (matchedLink == null) {
nextLinks.insert(
0,
SourceProfileLink(
id: 'sl-${_uuid.v4()}',
sourceApp: sourceApp, sourceApp: sourceApp,
sourceUserId: sourceUserId, messageText: trimmedMessage,
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 = 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 List<RelationshipMoment> nextMoments = <RelationshipMoment>[
moment,
...current.moments,
];
final List<SharedMessageEntry> nextMessages = <SharedMessageEntry>[
SharedMessageEntry(
id: 'sm-${_uuid.v4()}',
sourceApp: sourceApp,
profileId: resolvedPerson.id,
messageText: summary,
sharedAt: sharedAt, sharedAt: sharedAt,
importedAt: now, importedAt: now,
resolvedAutomatically: true,
sourceDisplayName: sourceDisplayName, sourceDisplayName: sourceDisplayName,
sourceUserId: sourceUserId, sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId, sourceThreadId: sourceThreadId,
), normalizedDisplayName: normalizedDisplayName,
...current.sharedMessages, resolvedPerson: resolvedPerson,
]; matchedLink: matchedLink,
await _setState(
current.copyWith(
people: nextPeople,
moments: nextMoments,
sourceLinks: nextLinks,
sharedMessages: nextMessages,
),
);
final List<ChangeEnvelope> outbound = <ChangeEnvelope>[
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, createdProfile: createdProfile,
createdSourceLink: createdSourceLink, people: nextPeople,
momentId: moment.id, resolvedAutomatically: true,
); );
} }
Future<SharedMessageIngestResult> 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 SourceProfileLink? matchedLink = _findExistingSourceLink(
links: current.sourceLinks,
sourceApp: entry.sourceApp,
sourceUserId: entry.sourceUserId,
sourceThreadId: entry.sourceThreadId,
normalizedDisplayName: entry.normalizedDisplayName,
);
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,
);
}
Future<SharedMessageIngestResult> 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 <String>['whatsapp'],
notes: 'Created from Share Inbox resolution.',
location: _trimToNull(location),
);
final SourceProfileLink? matchedLink = _findExistingSourceLink(
links: current.sourceLinks,
sourceApp: entry.sourceApp,
sourceUserId: entry.sourceUserId,
sourceThreadId: entry.sourceThreadId,
normalizedDisplayName: entry.normalizedDisplayName,
);
final List<PersonProfile> nextPeople = <PersonProfile>[
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<void> dismissSharedInboxEntry(String inboxEntryId) async {
final LocalDataState current = _requireState();
final List<SharedInboxEntry> 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<void> updatePerson(PersonProfile person) async { Future<void> updatePerson(PersonProfile person) async {
if (person.name.trim().isEmpty || person.relationship.trim().isEmpty) { if (person.name.trim().isEmpty || person.relationship.trim().isEmpty) {
throw ArgumentError('Name and relationship are required'); throw ArgumentError('Name and relationship are required');
@@ -1162,6 +1264,172 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
); );
} }
Future<SharedMessageIngestResult> _queueSharedInbox({
required LocalDataState current,
required String sourceApp,
required String messageText,
required DateTime sharedAt,
required DateTime receivedAt,
required SharedInboxReason reason,
required List<String> 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<SharedInboxEntry> nextInbox = <SharedInboxEntry>[
entry,
...current.sharedInbox,
];
await _setState(current.copyWith(sharedInbox: nextInbox));
return SharedMessageIngestResult.queuedForResolution(
inboxEntryId: entry.id,
);
}
Future<SharedMessageIngestResult> _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<PersonProfile> people,
required bool resolvedAutomatically,
SourceProfileLink? matchedLink,
String? sourceDisplayName,
String? sourceUserId,
String? sourceThreadId,
String? consumedInboxEntryId,
}) async {
final bool createdSourceLink = matchedLink == null;
final List<SourceProfileLink> 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<RelationshipMoment> nextMoments = <RelationshipMoment>[
moment,
...current.moments,
];
final List<SharedMessageEntry> nextMessages = <SharedMessageEntry>[
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<SharedInboxEntry> 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<ChangeEnvelope> outbound = <ChangeEnvelope>[
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) { String _titleFromSummary(String summary) {
final List<String> words = summary.split(RegExp(r'\s+')); final List<String> words = summary.split(RegExp(r'\s+'));
final int take = words.length < 5 ? words.length : 5; final int take = words.length < 5 ? words.length : 5;
@@ -1213,16 +1481,38 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
return null; return null;
} }
PersonProfile? _findPersonByNormalizedName( List<PersonProfile> _findPeopleByNormalizedName(
List<PersonProfile> people, List<PersonProfile> people,
String normalizedDisplayName, String normalizedDisplayName,
) { ) {
for (final PersonProfile person in people) { return people
if (_normalizeDisplayName(person.name) == normalizedDisplayName) { .where(
return person; (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) { String _normalizeDisplayName(String? raw) {
+14 -1
View File
@@ -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/auth/session_controller.dart';
import 'package:relationship_saver/features/local/local_repository.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/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/share_intake/whatsapp_share_parser.dart';
import 'package:relationship_saver/features/shared/frosted_card.dart'; import 'package:relationship_saver/features/shared/frosted_card.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.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), icon: const Icon(Icons.message_rounded),
label: const Text('Simulate WhatsApp Share'), label: const Text('Simulate WhatsApp Share'),
), ),
OutlinedButton.icon(
onPressed: () => Navigator.of(context).push<void>(
MaterialPageRoute<void>(
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( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
result.createdProfile result.isQueuedForResolution
? 'Imported to Share Inbox for profile resolution.'
: result.createdProfile
? 'Imported and created ${result.profileName}.' ? 'Imported and created ${result.profileName}.'
: 'Imported to ${result.profileName}.', : 'Imported to ${result.profileName}.',
), ),
@@ -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<ShareInboxView> createState() => _ShareInboxViewState();
}
class _ShareInboxViewState extends ConsumerState<ShareInboxView> {
bool _submitting = false;
@override
Widget build(BuildContext context) {
final AsyncValue<LocalDataState> 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: <Widget>[
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<PersonProfile> 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<PersonProfile> _orderedPeopleForEntry(
SharedInboxEntry entry,
LocalDataState state,
) {
final Map<String, PersonProfile> peopleById = <String, PersonProfile>{
for (final PersonProfile person in state.people) person.id: person,
};
final List<PersonProfile> candidates = <PersonProfile>[];
final Set<String> usedIds = <String>{};
for (final String id in entry.candidateProfileIds) {
final PersonProfile? person = peopleById[id];
if (person != null) {
candidates.add(person);
usedIds.add(person.id);
}
}
final List<PersonProfile> others = state.people
.where((PersonProfile person) => !usedIds.contains(person.id))
.toList(growable: false);
return <PersonProfile>[...candidates, ...others];
}
Future<void> _resolveToExisting(
SharedInboxEntry entry,
List<PersonProfile> options,
) async {
if (_submitting || options.isEmpty) {
return;
}
final String? selectedId = await showModalBottomSheet<String>(
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<void> _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<void> _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<void> _runRepositoryAction(Future<void> 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<String> 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<PersonProfile> 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: <Widget>[
Row(
children: <Widget>[
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: <Widget>[
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: <Widget>[
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: <Widget>[
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: <Widget>[
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)}';
}
@@ -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/core/config/app_config.dart';
import 'package:relationship_saver/features/local/local_repository.dart'; import 'package:relationship_saver/features/local/local_repository.dart';
import 'package:relationship_saver/features/people/people_view.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'; import 'package:relationship_saver/features/share_intake/whatsapp_share_parser.dart';
/// Listens for share intents and ingests WhatsApp text into local data. /// Listens for share intents and ingests WhatsApp text into local data.
@@ -132,16 +133,20 @@ class _WhatsAppShareListenerState extends ConsumerState<WhatsAppShareListener> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
result.createdProfile result.isQueuedForResolution
? 'WhatsApp share needs profile resolution.'
: result.createdProfile
? 'Imported from WhatsApp and created profile ${result.profileName}.' ? 'Imported from WhatsApp and created profile ${result.profileName}.'
: 'Imported from WhatsApp to ${result.profileName}.', : 'Imported from WhatsApp to ${result.profileName}.',
), ),
action: SnackBarAction( action: SnackBarAction(
label: 'Open People', label: result.isQueuedForResolution ? 'Open Inbox' : 'Open People',
onPressed: () { onPressed: () {
Navigator.of(context).push<void>( Navigator.of(context).push<void>(
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (BuildContext context) => const PeopleView(), builder: (BuildContext context) => result.isQueuedForResolution
? const ShareInboxView()
: const PeopleView(),
), ),
); );
}, },
+113 -1
View File
@@ -107,7 +107,9 @@ void main() {
expect(first.createdProfile, isTrue); expect(first.createdProfile, isTrue);
expect(afterFirst.people.length, before.people.length + 1); expect(afterFirst.people.length, before.people.length + 1);
expect( expect(
afterFirst.sourceLinks.any((link) => link.profileId == first.profileId), afterFirst.sourceLinks.any(
(SourceProfileLink link) => link.profileId == first.profileId,
),
isTrue, isTrue,
); );
expect( 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 <String>[],
);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Alex Morgan',
relationship: 'Cousin',
notes: '',
tags: const <String>[],
);
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 <String>[],
);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Alex Morgan',
relationship: 'Cousin',
notes: '',
tags: const <String>[],
);
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 { test('adds, archives, edits, and deletes ideas', () async {
final ProviderContainer container = _createContainer(); final ProviderContainer container = _createContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
@@ -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(<String, Object>{});
});
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 <String>[],
);
await container
.read(localRepositoryProvider.notifier)
.addPerson(
name: 'Alex Morgan',
relationship: 'Cousin',
notes: '',
tags: const <String>[],
);
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<void> clearAll() async {}
@override
Future<bool> requestPermissions() async => true;
@override
Future<void> reconcile(List<ReminderRule> reminders) async {}
}