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
+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
class DashboardSummary {
const DashboardSummary({
@@ -505,6 +605,7 @@ class LocalDataState {
required this.tasks,
this.sourceLinks = const <SourceProfileLink>[],
this.sharedMessages = const <SharedMessageEntry>[],
this.sharedInbox = const <SharedInboxEntry>[],
});
final List<PersonProfile> people;
@@ -514,6 +615,7 @@ class LocalDataState {
final List<DashboardTask> tasks;
final List<SourceProfileLink> sourceLinks;
final List<SharedMessageEntry> sharedMessages;
final List<SharedInboxEntry> sharedInbox;
LocalDataState copyWith({
List<PersonProfile>? people,
@@ -523,6 +625,7 @@ class LocalDataState {
List<DashboardTask>? tasks,
List<SourceProfileLink>? sourceLinks,
List<SharedMessageEntry>? sharedMessages,
List<SharedInboxEntry>? sharedInbox,
}) {
return LocalDataState(
people: people ?? this.people,
@@ -532,6 +635,7 @@ class LocalDataState {
tasks: tasks ?? this.tasks,
sourceLinks: sourceLinks ?? this.sourceLinks,
sharedMessages: sharedMessages ?? this.sharedMessages,
sharedInbox: sharedInbox ?? this.sharedInbox,
);
}
@@ -574,6 +678,9 @@ class LocalDataState {
'sharedMessages': sharedMessages
.map((SharedMessageEntry entry) => entry.toJson())
.toList(growable: false),
'sharedInbox': sharedInbox
.map((SharedInboxEntry entry) => entry.toJson())
.toList(growable: false),
};
}
@@ -621,6 +728,12 @@ class LocalDataState {
SharedMessageEntry.fromJson(entry as Map<String, dynamic>),
)
.toList(growable: false),
sharedInbox: (json['sharedInbox'] as List<dynamic>? ?? <dynamic>[])
.map(
(dynamic entry) =>
SharedInboxEntry.fromJson(entry as Map<String, dynamic>),
)
.toList(growable: false),
);
}
+395 -105
View File
@@ -32,20 +32,47 @@ class SharedMessageIngestInput {
}
/// Result of shared-message ingest with profile resolution metadata.
enum SharedMessageIngestStatus { imported, queuedForResolution }
class SharedMessageIngestResult {
const SharedMessageIngestResult({
required this.status,
required this.createdProfile,
required this.createdSourceLink,
this.profileId,
this.profileName,
this.momentId,
this.inboxEntryId,
});
const SharedMessageIngestResult.imported({
required this.profileId,
required this.profileName,
required this.createdProfile,
required this.createdSourceLink,
required this.momentId,
});
}) : status = SharedMessageIngestStatus.imported,
inboxEntryId = null;
final String profileId;
final String profileName;
const SharedMessageIngestResult.queuedForResolution({
required this.inboxEntryId,
}) : status = SharedMessageIngestStatus.queuedForResolution,
createdProfile = false,
createdSourceLink = false,
profileId = null,
profileName = null,
momentId = null;
final SharedMessageIngestStatus status;
final String? profileId;
final String? profileName;
final bool createdProfile;
final bool createdSourceLink;
final String momentId;
final String? momentId;
final String? inboxEntryId;
bool get isQueuedForResolution =>
status == SharedMessageIngestStatus.queuedForResolution;
}
/// Persisted local data source for offline-first product state.
@@ -170,10 +197,29 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
resolvedPerson = _findPersonById(current.people, matchedLink.profileId);
}
if (resolvedPerson == null && normalizedDisplayName.isNotEmpty) {
resolvedPerson = _findPersonByNormalizedName(
current.people,
normalizedDisplayName,
final List<PersonProfile> matchingPeople = normalizedDisplayName.isEmpty
? const <PersonProfile>[]
: _findPeopleByNormalizedName(current.people, normalizedDisplayName);
if (resolvedPerson == null && matchingPeople.length == 1) {
resolvedPerson = matchingPeople.first;
}
if (resolvedPerson == null && matchingPeople.length > 1) {
return _queueSharedInbox(
current: current,
sourceApp: sourceApp,
messageText: trimmedMessage,
sharedAt: sharedAt,
receivedAt: now,
sourceDisplayName: sourceDisplayName,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
normalizedDisplayName: normalizedDisplayName,
reason: SharedInboxReason.ambiguousProfileMatch,
candidateProfileIds: matchingPeople
.map((PersonProfile person) => person.id)
.toList(growable: false),
);
}
@@ -182,10 +228,30 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
growable: true,
);
if (resolvedPerson == null) {
final String? inferredName = _deriveAutoProfileName(
sourceDisplayName: sourceDisplayName,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
);
if (inferredName == null) {
return _queueSharedInbox(
current: current,
sourceApp: sourceApp,
messageText: trimmedMessage,
sharedAt: sharedAt,
receivedAt: now,
sourceDisplayName: sourceDisplayName,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
normalizedDisplayName: normalizedDisplayName,
reason: SharedInboxReason.missingIdentity,
candidateProfileIds: const <String>[],
);
}
createdProfile = true;
resolvedPerson = PersonProfile(
id: 'p-${_uuid.v4()}',
name: sourceDisplayName ?? 'WhatsApp Contact',
name: inferredName,
relationship: 'WhatsApp Contact',
affinityScore: 70,
nextMoment: DateTime.now().add(const Duration(days: 2)),
@@ -195,106 +261,142 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
nextPeople.insert(0, resolvedPerson);
}
final bool createdSourceLink = matchedLink == null;
final List<SourceProfileLink> nextLinks = current.sourceLinks.toList(
growable: true,
return _ingestIntoResolvedProfile(
current: current,
sourceApp: sourceApp,
messageText: trimmedMessage,
sharedAt: sharedAt,
importedAt: now,
sourceDisplayName: sourceDisplayName,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
normalizedDisplayName: normalizedDisplayName,
resolvedPerson: resolvedPerson,
matchedLink: matchedLink,
createdProfile: createdProfile,
people: nextPeople,
resolvedAutomatically: true,
);
if (matchedLink == null) {
nextLinks.insert(
0,
SourceProfileLink(
id: 'sl-${_uuid.v4()}',
sourceApp: sourceApp,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
normalizedDisplayName: normalizedDisplayName,
profileId: resolvedPerson.id,
firstSeenAt: sharedAt,
lastSeenAt: sharedAt,
),
);
} else {
final int index = nextLinks.indexWhere(
(SourceProfileLink link) => link.id == matchedLink.id,
);
if (index >= 0) {
nextLinks[index] = matchedLink.copyWith(
sourceUserId: sourceUserId ?? matchedLink.sourceUserId,
sourceThreadId: sourceThreadId ?? matchedLink.sourceThreadId,
normalizedDisplayName: normalizedDisplayName.isEmpty
? matchedLink.normalizedDisplayName
: normalizedDisplayName,
profileId: resolvedPerson.id,
lastSeenAt: sharedAt,
);
}
}
Future<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 String summary = trimmedMessage.length > 1800
? trimmedMessage.substring(0, 1800)
: trimmedMessage;
final RelationshipMoment moment = RelationshipMoment(
id: 'm-${_uuid.v4()}',
personId: resolvedPerson.id,
title: _titleFromSummary(summary),
summary: summary,
at: sharedAt,
type: sourceApp == 'whatsapp' ? 'whatsapp' : 'shared',
final SourceProfileLink? matchedLink = _findExistingSourceLink(
links: current.sourceLinks,
sourceApp: entry.sourceApp,
sourceUserId: entry.sourceUserId,
sourceThreadId: entry.sourceThreadId,
normalizedDisplayName: entry.normalizedDisplayName,
);
final List<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: now,
resolvedAutomatically: true,
sourceDisplayName: sourceDisplayName,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
),
...current.sharedMessages,
];
return _ingestIntoResolvedProfile(
current: current,
sourceApp: entry.sourceApp,
messageText: entry.messageText,
sharedAt: entry.sharedAt,
importedAt: DateTime.now(),
sourceDisplayName: entry.sourceDisplayName,
sourceUserId: entry.sourceUserId,
sourceThreadId: entry.sourceThreadId,
normalizedDisplayName: entry.normalizedDisplayName,
resolvedPerson: resolvedPerson,
matchedLink: matchedLink,
createdProfile: false,
people: current.people,
resolvedAutomatically: false,
consumedInboxEntryId: entry.id,
);
}
await _setState(
current.copyWith(
people: nextPeople,
moments: nextMoments,
sourceLinks: nextLinks,
sharedMessages: nextMessages,
),
Future<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 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,
createdSourceLink: createdSourceLink,
momentId: moment.id,
final SourceProfileLink? matchedLink = _findExistingSourceLink(
links: current.sourceLinks,
sourceApp: entry.sourceApp,
sourceUserId: entry.sourceUserId,
sourceThreadId: entry.sourceThreadId,
normalizedDisplayName: entry.normalizedDisplayName,
);
final List<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 {
@@ -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) {
final List<String> words = summary.split(RegExp(r'\s+'));
final int take = words.length < 5 ? words.length : 5;
@@ -1213,16 +1481,38 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
return null;
}
PersonProfile? _findPersonByNormalizedName(
List<PersonProfile> _findPeopleByNormalizedName(
List<PersonProfile> people,
String normalizedDisplayName,
) {
for (final PersonProfile person in people) {
if (_normalizeDisplayName(person.name) == normalizedDisplayName) {
return person;
return people
.where(
(PersonProfile person) =>
_normalizeDisplayName(person.name) == normalizedDisplayName,
)
.toList(growable: false);
}
SharedInboxEntry _requireSharedInboxEntry(
LocalDataState current,
String inboxEntryId,
) {
for (final SharedInboxEntry entry in current.sharedInbox) {
if (entry.id == inboxEntryId) {
return entry;
}
}
return null;
throw ArgumentError('Shared inbox entry not found');
}
String? _deriveAutoProfileName({
required String? sourceDisplayName,
required String? sourceUserId,
required String? sourceThreadId,
}) {
return _trimToNull(sourceDisplayName) ??
_trimToNull(sourceUserId) ??
_trimToNull(sourceThreadId);
}
String _normalizeDisplayName(String? raw) {