Add Share Inbox flow for unresolved WhatsApp imports
This commit is contained in:
+6
-2
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,106 +261,142 @@ 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,
|
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,
|
Future<SharedMessageIngestResult> resolveSharedInboxToExistingProfile({
|
||||||
SourceProfileLink(
|
required String inboxEntryId,
|
||||||
id: 'sl-${_uuid.v4()}',
|
required String profileId,
|
||||||
sourceApp: sourceApp,
|
}) async {
|
||||||
sourceUserId: sourceUserId,
|
final LocalDataState current = _requireState();
|
||||||
sourceThreadId: sourceThreadId,
|
final SharedInboxEntry entry = _requireSharedInboxEntry(
|
||||||
normalizedDisplayName: normalizedDisplayName,
|
current,
|
||||||
profileId: resolvedPerson.id,
|
inboxEntryId,
|
||||||
firstSeenAt: sharedAt,
|
);
|
||||||
lastSeenAt: sharedAt,
|
final PersonProfile? resolvedPerson = _findPersonById(
|
||||||
),
|
current.people,
|
||||||
);
|
profileId,
|
||||||
} else {
|
);
|
||||||
final int index = nextLinks.indexWhere(
|
if (resolvedPerson == null) {
|
||||||
(SourceProfileLink link) => link.id == matchedLink.id,
|
throw ArgumentError('Profile not found for inbox entry');
|
||||||
);
|
|
||||||
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
|
final SourceProfileLink? matchedLink = _findExistingSourceLink(
|
||||||
? trimmedMessage.substring(0, 1800)
|
links: current.sourceLinks,
|
||||||
: trimmedMessage;
|
sourceApp: entry.sourceApp,
|
||||||
final RelationshipMoment moment = RelationshipMoment(
|
sourceUserId: entry.sourceUserId,
|
||||||
id: 'm-${_uuid.v4()}',
|
sourceThreadId: entry.sourceThreadId,
|
||||||
personId: resolvedPerson.id,
|
normalizedDisplayName: entry.normalizedDisplayName,
|
||||||
title: _titleFromSummary(summary),
|
|
||||||
summary: summary,
|
|
||||||
at: sharedAt,
|
|
||||||
type: sourceApp == 'whatsapp' ? 'whatsapp' : 'shared',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final List<RelationshipMoment> nextMoments = <RelationshipMoment>[
|
return _ingestIntoResolvedProfile(
|
||||||
moment,
|
current: current,
|
||||||
...current.moments,
|
sourceApp: entry.sourceApp,
|
||||||
];
|
messageText: entry.messageText,
|
||||||
final List<SharedMessageEntry> nextMessages = <SharedMessageEntry>[
|
sharedAt: entry.sharedAt,
|
||||||
SharedMessageEntry(
|
importedAt: DateTime.now(),
|
||||||
id: 'sm-${_uuid.v4()}',
|
sourceDisplayName: entry.sourceDisplayName,
|
||||||
sourceApp: sourceApp,
|
sourceUserId: entry.sourceUserId,
|
||||||
profileId: resolvedPerson.id,
|
sourceThreadId: entry.sourceThreadId,
|
||||||
messageText: summary,
|
normalizedDisplayName: entry.normalizedDisplayName,
|
||||||
sharedAt: sharedAt,
|
resolvedPerson: resolvedPerson,
|
||||||
importedAt: now,
|
matchedLink: matchedLink,
|
||||||
resolvedAutomatically: true,
|
createdProfile: false,
|
||||||
sourceDisplayName: sourceDisplayName,
|
people: current.people,
|
||||||
sourceUserId: sourceUserId,
|
resolvedAutomatically: false,
|
||||||
sourceThreadId: sourceThreadId,
|
consumedInboxEntryId: entry.id,
|
||||||
),
|
);
|
||||||
...current.sharedMessages,
|
}
|
||||||
];
|
|
||||||
|
|
||||||
await _setState(
|
Future<SharedMessageIngestResult> resolveSharedInboxByCreatingProfile({
|
||||||
current.copyWith(
|
required String inboxEntryId,
|
||||||
people: nextPeople,
|
String? name,
|
||||||
moments: nextMoments,
|
String relationship = 'WhatsApp Contact',
|
||||||
sourceLinks: nextLinks,
|
String? location,
|
||||||
sharedMessages: nextMessages,
|
}) 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 List<ChangeEnvelope> outbound = <ChangeEnvelope>[
|
final SourceProfileLink? matchedLink = _findExistingSourceLink(
|
||||||
if (createdProfile)
|
links: current.sourceLinks,
|
||||||
_buildEnvelope(
|
sourceApp: entry.sourceApp,
|
||||||
entityType: 'person',
|
sourceUserId: entry.sourceUserId,
|
||||||
entityId: resolvedPerson.id,
|
sourceThreadId: entry.sourceThreadId,
|
||||||
op: ChangeOperation.upsert,
|
normalizedDisplayName: entry.normalizedDisplayName,
|
||||||
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 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 {
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user