Implement WhatsApp share intake and person quick actions

This commit is contained in:
Rijad Zuzo
2026-02-19 00:09:28 +01:00
parent 818c1046f3
commit d2205bd3d9
15 changed files with 1463 additions and 4 deletions
+292 -1
View File
@@ -12,9 +12,45 @@ import 'package:relationship_saver/features/sync/sync_queue_repository.dart';
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
import 'package:uuid/uuid.dart';
/// Input payload for shared-message ingest flow (e.g. WhatsApp share intent).
class SharedMessageIngestInput {
const SharedMessageIngestInput({
required this.sourceApp,
required this.messageText,
this.sourceDisplayName,
this.sourceUserId,
this.sourceThreadId,
this.sharedAt,
});
final String sourceApp;
final String messageText;
final String? sourceDisplayName;
final String? sourceUserId;
final String? sourceThreadId;
final DateTime? sharedAt;
}
/// Result of shared-message ingest with profile resolution metadata.
class SharedMessageIngestResult {
const SharedMessageIngestResult({
required this.profileId,
required this.profileName,
required this.createdProfile,
required this.createdSourceLink,
required this.momentId,
});
final String profileId;
final String profileName;
final bool createdProfile;
final bool createdSourceLink;
final String momentId;
}
/// Persisted local data source for offline-first product state.
class LocalRepository extends AsyncNotifier<LocalDataState> {
static const int _schemaVersion = 2;
static const int _schemaVersion = 3;
static const int _syncSchemaVersion = 1;
static const Uuid _uuid = Uuid();
@@ -67,6 +103,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
required String notes,
required List<String> tags,
DateTime? nextMoment,
String? location,
}) async {
final String normalizedName = name.trim();
final String normalizedRelationship = relationship.trim();
@@ -83,6 +120,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
nextMoment: nextMoment ?? DateTime.now().add(const Duration(days: 3)),
tags: tags,
notes: notes.trim(),
location: location?.trim().isEmpty == true ? null : location?.trim(),
);
final List<PersonProfile> people = <PersonProfile>[
@@ -100,6 +138,165 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
);
}
Future<SharedMessageIngestResult> ingestSharedMessage(
SharedMessageIngestInput input,
) async {
final String trimmedMessage = input.messageText.trim();
if (trimmedMessage.isEmpty) {
throw ArgumentError('Shared message text cannot be empty');
}
final LocalDataState current = _requireState();
final DateTime now = DateTime.now();
final DateTime sharedAt = input.sharedAt ?? now;
final String sourceApp = input.sourceApp.trim().toLowerCase();
final String? sourceDisplayName = _trimToNull(input.sourceDisplayName);
final String normalizedDisplayName = _normalizeDisplayName(
sourceDisplayName,
);
final String? sourceUserId = _trimToNull(input.sourceUserId);
final String? sourceThreadId = _trimToNull(input.sourceThreadId);
final SourceProfileLink? matchedLink = _findExistingSourceLink(
links: current.sourceLinks,
sourceApp: sourceApp,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
normalizedDisplayName: normalizedDisplayName,
);
PersonProfile? resolvedPerson;
if (matchedLink != null) {
resolvedPerson = _findPersonById(current.people, matchedLink.profileId);
}
if (resolvedPerson == null && normalizedDisplayName.isNotEmpty) {
resolvedPerson = _findPersonByNormalizedName(
current.people,
normalizedDisplayName,
);
}
bool createdProfile = false;
final List<PersonProfile> nextPeople = current.people.toList(
growable: true,
);
if (resolvedPerson == null) {
createdProfile = true;
resolvedPerson = PersonProfile(
id: 'p-${_uuid.v4()}',
name: sourceDisplayName ?? 'WhatsApp Contact',
relationship: 'WhatsApp Contact',
affinityScore: 70,
nextMoment: DateTime.now().add(const Duration(days: 2)),
tags: const <String>['whatsapp'],
notes: 'Auto-created from shared message.',
);
nextPeople.insert(0, resolvedPerson);
}
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 = 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,
importedAt: now,
resolvedAutomatically: true,
sourceDisplayName: sourceDisplayName,
sourceUserId: sourceUserId,
sourceThreadId: sourceThreadId,
),
...current.sharedMessages,
];
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,
createdSourceLink: createdSourceLink,
momentId: moment.id,
);
}
Future<void> updatePerson(PersonProfile person) async {
if (person.name.trim().isEmpty || person.relationship.trim().isEmpty) {
throw ArgumentError('Name and relationship are required');
@@ -546,6 +743,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
),
tags: _stringListField(payload, 'tags', existing?.tags ?? <String>[]),
notes: _stringField(payload, 'notes', existing?.notes ?? ''),
location: _stringOrNullField(payload, 'location', existing?.location),
);
final List<PersonProfile> people = _upsertItem(
items: current.people,
@@ -729,6 +927,7 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
'nextMoment': person.nextMoment.toUtc().toIso8601String(),
'tags': person.tags,
'notes': person.notes,
'location': person.location,
};
}
@@ -844,6 +1043,22 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
return fallback;
}
String? _stringOrNullField(
Map<String, dynamic> payload,
String key,
String? fallback,
) {
final dynamic value = payload[key];
if (value is String) {
final String trimmed = value.trim();
if (trimmed.isNotEmpty) {
return trimmed;
}
return null;
}
return fallback;
}
int _intField(Map<String, dynamic> payload, String key, int fallback) {
final dynamic value = payload[key];
if (value is int) {
@@ -952,6 +1167,82 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
final int take = words.length < 5 ? words.length : 5;
return words.take(take).join(' ');
}
SourceProfileLink? _findExistingSourceLink({
required List<SourceProfileLink> links,
required String sourceApp,
required String? sourceUserId,
required String? sourceThreadId,
required String normalizedDisplayName,
}) {
if (sourceUserId != null && sourceUserId.isNotEmpty) {
for (final SourceProfileLink link in links) {
if (link.sourceApp == sourceApp && link.sourceUserId == sourceUserId) {
return link;
}
}
}
if (sourceThreadId != null && sourceThreadId.isNotEmpty) {
for (final SourceProfileLink link in links) {
if (link.sourceApp == sourceApp &&
link.sourceThreadId == sourceThreadId) {
return link;
}
}
}
if (normalizedDisplayName.isNotEmpty) {
for (final SourceProfileLink link in links) {
if (link.sourceApp == sourceApp &&
link.normalizedDisplayName == normalizedDisplayName) {
return link;
}
}
}
return null;
}
PersonProfile? _findPersonById(List<PersonProfile> people, String id) {
for (final PersonProfile person in people) {
if (person.id == id) {
return person;
}
}
return null;
}
PersonProfile? _findPersonByNormalizedName(
List<PersonProfile> people,
String normalizedDisplayName,
) {
for (final PersonProfile person in people) {
if (_normalizeDisplayName(person.name) == normalizedDisplayName) {
return person;
}
}
return null;
}
String _normalizeDisplayName(String? raw) {
if (raw == null) {
return '';
}
final String compact = raw
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9]+'), ' ')
.trim();
return compact.replaceAll(RegExp(r'\s+'), ' ');
}
String? _trimToNull(String? value) {
if (value == null) {
return null;
}
final String trimmed = value.trim();
return trimmed.isEmpty ? null : trimmed;
}
}
final AsyncNotifierProvider<LocalRepository, LocalDataState>