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`)
|
||||
- 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
|
||||
- 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
|
||||
|
||||
|
||||
+39
-1
@@ -1,6 +1,44 @@
|
||||
# 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
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/local/local_repository.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/shared/frosted_card.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),
|
||||
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(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
result.createdProfile
|
||||
result.isQueuedForResolution
|
||||
? 'Imported to Share Inbox for profile resolution.'
|
||||
: result.createdProfile
|
||||
? 'Imported and created ${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/features/local/local_repository.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';
|
||||
|
||||
/// Listens for share intents and ingests WhatsApp text into local data.
|
||||
@@ -132,16 +133,20 @@ class _WhatsAppShareListenerState extends ConsumerState<WhatsAppShareListener> {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
result.createdProfile
|
||||
result.isQueuedForResolution
|
||||
? 'WhatsApp share needs profile resolution.'
|
||||
: result.createdProfile
|
||||
? 'Imported from WhatsApp and created profile ${result.profileName}.'
|
||||
: 'Imported from WhatsApp to ${result.profileName}.',
|
||||
),
|
||||
action: SnackBarAction(
|
||||
label: 'Open People',
|
||||
label: result.isQueuedForResolution ? 'Open Inbox' : 'Open People',
|
||||
onPressed: () {
|
||||
Navigator.of(context).push<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(afterFirst.people.length, before.people.length + 1);
|
||||
expect(
|
||||
afterFirst.sourceLinks.any((link) => link.profileId == first.profileId),
|
||||
afterFirst.sourceLinks.any(
|
||||
(SourceProfileLink link) => link.profileId == first.profileId,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
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 {
|
||||
final ProviderContainer container = _createContainer();
|
||||
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