Implement WhatsApp share intake and person quick actions
This commit is contained in:
@@ -16,6 +16,7 @@ class PersonProfile {
|
||||
required this.nextMoment,
|
||||
required this.tags,
|
||||
required this.notes,
|
||||
this.location,
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -25,6 +26,7 @@ class PersonProfile {
|
||||
final DateTime nextMoment;
|
||||
final List<String> tags;
|
||||
final String notes;
|
||||
final String? location;
|
||||
|
||||
PersonProfile copyWith({
|
||||
String? id,
|
||||
@@ -34,6 +36,7 @@ class PersonProfile {
|
||||
DateTime? nextMoment,
|
||||
List<String>? tags,
|
||||
String? notes,
|
||||
String? location,
|
||||
}) {
|
||||
return PersonProfile(
|
||||
id: id ?? this.id,
|
||||
@@ -43,6 +46,7 @@ class PersonProfile {
|
||||
nextMoment: nextMoment ?? this.nextMoment,
|
||||
tags: tags ?? this.tags,
|
||||
notes: notes ?? this.notes,
|
||||
location: location ?? this.location,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,6 +59,7 @@ class PersonProfile {
|
||||
'nextMoment': nextMoment.toUtc().toIso8601String(),
|
||||
'tags': tags,
|
||||
'notes': notes,
|
||||
'location': location,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,6 +74,7 @@ class PersonProfile {
|
||||
.map((dynamic tag) => '$tag')
|
||||
.toList(growable: false),
|
||||
notes: json['notes'] as String? ?? '',
|
||||
location: json['location'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -318,6 +324,162 @@ class DashboardTask {
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SourceProfileLink {
|
||||
const SourceProfileLink({
|
||||
required this.id,
|
||||
required this.sourceApp,
|
||||
required this.normalizedDisplayName,
|
||||
required this.profileId,
|
||||
required this.firstSeenAt,
|
||||
required this.lastSeenAt,
|
||||
this.sourceUserId,
|
||||
this.sourceThreadId,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String sourceApp;
|
||||
final String? sourceUserId;
|
||||
final String? sourceThreadId;
|
||||
final String normalizedDisplayName;
|
||||
final String profileId;
|
||||
final DateTime firstSeenAt;
|
||||
final DateTime lastSeenAt;
|
||||
|
||||
SourceProfileLink copyWith({
|
||||
String? id,
|
||||
String? sourceApp,
|
||||
String? sourceUserId,
|
||||
String? sourceThreadId,
|
||||
String? normalizedDisplayName,
|
||||
String? profileId,
|
||||
DateTime? firstSeenAt,
|
||||
DateTime? lastSeenAt,
|
||||
}) {
|
||||
return SourceProfileLink(
|
||||
id: id ?? this.id,
|
||||
sourceApp: sourceApp ?? this.sourceApp,
|
||||
sourceUserId: sourceUserId ?? this.sourceUserId,
|
||||
sourceThreadId: sourceThreadId ?? this.sourceThreadId,
|
||||
normalizedDisplayName:
|
||||
normalizedDisplayName ?? this.normalizedDisplayName,
|
||||
profileId: profileId ?? this.profileId,
|
||||
firstSeenAt: firstSeenAt ?? this.firstSeenAt,
|
||||
lastSeenAt: lastSeenAt ?? this.lastSeenAt,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'id': id,
|
||||
'sourceApp': sourceApp,
|
||||
'sourceUserId': sourceUserId,
|
||||
'sourceThreadId': sourceThreadId,
|
||||
'normalizedDisplayName': normalizedDisplayName,
|
||||
'profileId': profileId,
|
||||
'firstSeenAt': firstSeenAt.toUtc().toIso8601String(),
|
||||
'lastSeenAt': lastSeenAt.toUtc().toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
factory SourceProfileLink.fromJson(Map<String, dynamic> json) {
|
||||
return SourceProfileLink(
|
||||
id: json['id'] as String,
|
||||
sourceApp: json['sourceApp'] as String,
|
||||
sourceUserId: json['sourceUserId'] as String?,
|
||||
sourceThreadId: json['sourceThreadId'] as String?,
|
||||
normalizedDisplayName: json['normalizedDisplayName'] as String? ?? '',
|
||||
profileId: json['profileId'] as String,
|
||||
firstSeenAt: DateTime.parse(json['firstSeenAt'] as String).toLocal(),
|
||||
lastSeenAt: DateTime.parse(json['lastSeenAt'] as String).toLocal(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class SharedMessageEntry {
|
||||
const SharedMessageEntry({
|
||||
required this.id,
|
||||
required this.sourceApp,
|
||||
required this.profileId,
|
||||
required this.messageText,
|
||||
required this.sharedAt,
|
||||
required this.importedAt,
|
||||
required this.resolvedAutomatically,
|
||||
this.sourceDisplayName,
|
||||
this.sourceUserId,
|
||||
this.sourceThreadId,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String sourceApp;
|
||||
final String profileId;
|
||||
final String messageText;
|
||||
final DateTime sharedAt;
|
||||
final DateTime importedAt;
|
||||
final bool resolvedAutomatically;
|
||||
final String? sourceDisplayName;
|
||||
final String? sourceUserId;
|
||||
final String? sourceThreadId;
|
||||
|
||||
SharedMessageEntry copyWith({
|
||||
String? id,
|
||||
String? sourceApp,
|
||||
String? profileId,
|
||||
String? messageText,
|
||||
DateTime? sharedAt,
|
||||
DateTime? importedAt,
|
||||
bool? resolvedAutomatically,
|
||||
String? sourceDisplayName,
|
||||
String? sourceUserId,
|
||||
String? sourceThreadId,
|
||||
}) {
|
||||
return SharedMessageEntry(
|
||||
id: id ?? this.id,
|
||||
sourceApp: sourceApp ?? this.sourceApp,
|
||||
profileId: profileId ?? this.profileId,
|
||||
messageText: messageText ?? this.messageText,
|
||||
sharedAt: sharedAt ?? this.sharedAt,
|
||||
importedAt: importedAt ?? this.importedAt,
|
||||
resolvedAutomatically:
|
||||
resolvedAutomatically ?? this.resolvedAutomatically,
|
||||
sourceDisplayName: sourceDisplayName ?? this.sourceDisplayName,
|
||||
sourceUserId: sourceUserId ?? this.sourceUserId,
|
||||
sourceThreadId: sourceThreadId ?? this.sourceThreadId,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'id': id,
|
||||
'sourceApp': sourceApp,
|
||||
'profileId': profileId,
|
||||
'messageText': messageText,
|
||||
'sharedAt': sharedAt.toUtc().toIso8601String(),
|
||||
'importedAt': importedAt.toUtc().toIso8601String(),
|
||||
'resolvedAutomatically': resolvedAutomatically,
|
||||
'sourceDisplayName': sourceDisplayName,
|
||||
'sourceUserId': sourceUserId,
|
||||
'sourceThreadId': sourceThreadId,
|
||||
};
|
||||
}
|
||||
|
||||
factory SharedMessageEntry.fromJson(Map<String, dynamic> json) {
|
||||
return SharedMessageEntry(
|
||||
id: json['id'] as String,
|
||||
sourceApp: json['sourceApp'] as String,
|
||||
profileId: json['profileId'] as String,
|
||||
messageText: json['messageText'] as String? ?? '',
|
||||
sharedAt: DateTime.parse(json['sharedAt'] as String).toLocal(),
|
||||
importedAt: DateTime.parse(json['importedAt'] as String).toLocal(),
|
||||
resolvedAutomatically: json['resolvedAutomatically'] as bool? ?? true,
|
||||
sourceDisplayName: json['sourceDisplayName'] as String?,
|
||||
sourceUserId: json['sourceUserId'] as String?,
|
||||
sourceThreadId: json['sourceThreadId'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@immutable
|
||||
class DashboardSummary {
|
||||
const DashboardSummary({
|
||||
@@ -341,6 +503,8 @@ class LocalDataState {
|
||||
required this.ideas,
|
||||
required this.reminders,
|
||||
required this.tasks,
|
||||
this.sourceLinks = const <SourceProfileLink>[],
|
||||
this.sharedMessages = const <SharedMessageEntry>[],
|
||||
});
|
||||
|
||||
final List<PersonProfile> people;
|
||||
@@ -348,6 +512,8 @@ class LocalDataState {
|
||||
final List<RelationshipIdea> ideas;
|
||||
final List<ReminderRule> reminders;
|
||||
final List<DashboardTask> tasks;
|
||||
final List<SourceProfileLink> sourceLinks;
|
||||
final List<SharedMessageEntry> sharedMessages;
|
||||
|
||||
LocalDataState copyWith({
|
||||
List<PersonProfile>? people,
|
||||
@@ -355,6 +521,8 @@ class LocalDataState {
|
||||
List<RelationshipIdea>? ideas,
|
||||
List<ReminderRule>? reminders,
|
||||
List<DashboardTask>? tasks,
|
||||
List<SourceProfileLink>? sourceLinks,
|
||||
List<SharedMessageEntry>? sharedMessages,
|
||||
}) {
|
||||
return LocalDataState(
|
||||
people: people ?? this.people,
|
||||
@@ -362,6 +530,8 @@ class LocalDataState {
|
||||
ideas: ideas ?? this.ideas,
|
||||
reminders: reminders ?? this.reminders,
|
||||
tasks: tasks ?? this.tasks,
|
||||
sourceLinks: sourceLinks ?? this.sourceLinks,
|
||||
sharedMessages: sharedMessages ?? this.sharedMessages,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -398,6 +568,12 @@ class LocalDataState {
|
||||
'tasks': tasks
|
||||
.map((DashboardTask task) => task.toJson())
|
||||
.toList(growable: false),
|
||||
'sourceLinks': sourceLinks
|
||||
.map((SourceProfileLink link) => link.toJson())
|
||||
.toList(growable: false),
|
||||
'sharedMessages': sharedMessages
|
||||
.map((SharedMessageEntry entry) => entry.toJson())
|
||||
.toList(growable: false),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -433,6 +609,18 @@ class LocalDataState {
|
||||
DashboardTask.fromJson(task as Map<String, dynamic>),
|
||||
)
|
||||
.toList(growable: false),
|
||||
sourceLinks: (json['sourceLinks'] as List<dynamic>? ?? <dynamic>[])
|
||||
.map(
|
||||
(dynamic link) =>
|
||||
SourceProfileLink.fromJson(link as Map<String, dynamic>),
|
||||
)
|
||||
.toList(growable: false),
|
||||
sharedMessages: (json['sharedMessages'] as List<dynamic>? ?? <dynamic>[])
|
||||
.map(
|
||||
(dynamic entry) =>
|
||||
SharedMessageEntry.fromJson(entry as Map<String, dynamic>),
|
||||
)
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -447,6 +635,7 @@ class LocalDataState {
|
||||
nextMoment: DateTime(2026, 2, 16, 19, 30),
|
||||
tags: <String>['coffee', 'books', 'slow mornings'],
|
||||
notes: 'Prefers thoughtful plans over expensive plans.',
|
||||
location: 'Austin, TX',
|
||||
),
|
||||
PersonProfile(
|
||||
id: 'p-jordan',
|
||||
@@ -456,6 +645,7 @@ class LocalDataState {
|
||||
nextMoment: DateTime(2026, 2, 18, 18, 0),
|
||||
tags: <String>['running', 'street food'],
|
||||
notes: 'Has a race on Sunday; ask how training is going.',
|
||||
location: 'Chicago, IL',
|
||||
),
|
||||
PersonProfile(
|
||||
id: 'p-mila',
|
||||
@@ -465,6 +655,7 @@ class LocalDataState {
|
||||
nextMoment: DateTime(2026, 2, 20, 12, 0),
|
||||
tags: <String>['plants', 'retro music'],
|
||||
notes: 'Birthday prep should start this week.',
|
||||
location: 'Seattle, WA',
|
||||
),
|
||||
],
|
||||
moments: <RelationshipMoment>[
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user