Add duplicate profile merge and share deep-link routing
This commit is contained in:
@@ -101,10 +101,16 @@ Behavior notes:
|
|||||||
- moment history (`type=whatsapp`)
|
- moment history (`type=whatsapp`)
|
||||||
- ambiguous or missing-identity shares are queued in `Share Inbox` for manual
|
- ambiguous or missing-identity shares are queued in `Share Inbox` for manual
|
||||||
resolution
|
resolution
|
||||||
|
- app launch from initial share intent auto-opens the relevant destination:
|
||||||
|
- resolved import -> `People` with imported profile selected
|
||||||
|
- unresolved import -> `Share Inbox`
|
||||||
- Settings includes:
|
- Settings includes:
|
||||||
- `Simulate WhatsApp Share` for local/dev testing
|
- `Simulate WhatsApp Share` for local/dev testing
|
||||||
- `Open Share Inbox` to resolve queued items
|
- `Open Share Inbox` to resolve queued items
|
||||||
|
|
||||||
|
People view includes `Merge duplicates` action for resolving duplicate profile
|
||||||
|
records after import.
|
||||||
|
|
||||||
## Local Persistence Backend
|
## Local Persistence Backend
|
||||||
|
|
||||||
Default local store:
|
Default local store:
|
||||||
|
|||||||
@@ -7,6 +7,40 @@ Updated: 2026-02-18
|
|||||||
- After every sensible code/documentation change set, create a git commit as
|
- 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.
|
the last step so the next agent session can pick up from clean checkpoints.
|
||||||
|
|
||||||
|
## Latest Milestone (2026-02-18): Duplicate Profile Merge + Share Deep-Link Polish
|
||||||
|
|
||||||
|
Improved identity continuity from WhatsApp intake with merge tooling and direct
|
||||||
|
navigation:
|
||||||
|
|
||||||
|
- Added duplicate-profile merge operation in local repository:
|
||||||
|
- `lib/features/local/local_repository.dart`
|
||||||
|
- new `mergePersonProfiles(sourcePersonId, targetPersonId)`:
|
||||||
|
- merges target profile metadata (tags/notes/location/affinity/next moment)
|
||||||
|
- rebinds related records from source -> target:
|
||||||
|
- moments
|
||||||
|
- ideas
|
||||||
|
- reminders
|
||||||
|
- source links
|
||||||
|
- shared message history
|
||||||
|
- unresolved inbox candidate IDs
|
||||||
|
- enqueues sync envelopes for merged target + source delete + rebounded
|
||||||
|
related entities
|
||||||
|
- Added merge action in People UX:
|
||||||
|
- `lib/features/people/people_view.dart`
|
||||||
|
- new `Merge` / `Merge duplicates` action in People header
|
||||||
|
- duplicate-name detection and merge dialog for selecting source/target
|
||||||
|
- Share destination deep-link polish:
|
||||||
|
- `lib/features/share_intake/whatsapp_share_listener.dart`
|
||||||
|
- initial app launch from share intent now auto-opens destination context:
|
||||||
|
- `Share Inbox` for unresolved imports
|
||||||
|
- `People` with selected imported profile for resolved imports
|
||||||
|
- snackbar action updated to `Open Profile` / `Open Inbox`
|
||||||
|
- `lib/features/settings/settings_view.dart`
|
||||||
|
- simulated share snackbar now offers direct navigation to profile/inbox
|
||||||
|
- Added merge test coverage:
|
||||||
|
- `test/features/local/local_repository_test.dart`
|
||||||
|
- verifies merge rebinding across related records
|
||||||
|
|
||||||
## Latest Milestone (2026-02-18): Share Inbox Resolution Flow
|
## Latest Milestone (2026-02-18): Share Inbox Resolution Flow
|
||||||
|
|
||||||
Added manual resolution UX and data paths for ambiguous/unidentifiable WhatsApp
|
Added manual resolution UX and data paths for ambiguous/unidentifiable WhatsApp
|
||||||
|
|||||||
@@ -483,6 +483,179 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Merge one person profile into another and rebind related records.
|
||||||
|
Future<void> mergePersonProfiles({
|
||||||
|
required String sourcePersonId,
|
||||||
|
required String targetPersonId,
|
||||||
|
}) async {
|
||||||
|
if (sourcePersonId == targetPersonId) {
|
||||||
|
throw ArgumentError('Source and target profile must be different');
|
||||||
|
}
|
||||||
|
|
||||||
|
final LocalDataState current = _requireState();
|
||||||
|
final PersonProfile? source = _findPersonById(
|
||||||
|
current.people,
|
||||||
|
sourcePersonId,
|
||||||
|
);
|
||||||
|
final PersonProfile? target = _findPersonById(
|
||||||
|
current.people,
|
||||||
|
targetPersonId,
|
||||||
|
);
|
||||||
|
if (source == null || target == null) {
|
||||||
|
throw ArgumentError('Both source and target profiles must exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
final PersonProfile mergedTarget = target.copyWith(
|
||||||
|
affinityScore: target.affinityScore > source.affinityScore
|
||||||
|
? target.affinityScore
|
||||||
|
: source.affinityScore,
|
||||||
|
nextMoment: source.nextMoment.isBefore(target.nextMoment)
|
||||||
|
? source.nextMoment
|
||||||
|
: target.nextMoment,
|
||||||
|
tags: _mergeTags(target.tags, source.tags),
|
||||||
|
notes: _mergeNotes(
|
||||||
|
targetNotes: target.notes,
|
||||||
|
sourceName: source.name,
|
||||||
|
sourceNotes: source.notes,
|
||||||
|
),
|
||||||
|
location: _effectiveLocation(target.location, source.location),
|
||||||
|
);
|
||||||
|
|
||||||
|
final List<PersonProfile> nextPeople = current.people
|
||||||
|
.where((PersonProfile person) => person.id != sourcePersonId)
|
||||||
|
.map(
|
||||||
|
(PersonProfile person) =>
|
||||||
|
person.id == targetPersonId ? mergedTarget : person,
|
||||||
|
)
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
final List<RelationshipMoment> updatedMoments = <RelationshipMoment>[];
|
||||||
|
final List<RelationshipMoment> nextMoments = current.moments
|
||||||
|
.map((RelationshipMoment moment) {
|
||||||
|
if (moment.personId != sourcePersonId) {
|
||||||
|
return moment;
|
||||||
|
}
|
||||||
|
final RelationshipMoment merged = moment.copyWith(
|
||||||
|
personId: targetPersonId,
|
||||||
|
);
|
||||||
|
updatedMoments.add(merged);
|
||||||
|
return merged;
|
||||||
|
})
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
final List<RelationshipIdea> updatedIdeas = <RelationshipIdea>[];
|
||||||
|
final List<RelationshipIdea> nextIdeas = current.ideas
|
||||||
|
.map((RelationshipIdea idea) {
|
||||||
|
if (idea.personId != sourcePersonId) {
|
||||||
|
return idea;
|
||||||
|
}
|
||||||
|
final RelationshipIdea merged = idea.copyWith(
|
||||||
|
personId: targetPersonId,
|
||||||
|
);
|
||||||
|
updatedIdeas.add(merged);
|
||||||
|
return merged;
|
||||||
|
})
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
final List<ReminderRule> updatedReminders = <ReminderRule>[];
|
||||||
|
final List<ReminderRule> nextReminders = current.reminders
|
||||||
|
.map((ReminderRule reminder) {
|
||||||
|
if (reminder.personId != sourcePersonId) {
|
||||||
|
return reminder;
|
||||||
|
}
|
||||||
|
final ReminderRule merged = reminder.copyWith(
|
||||||
|
personId: targetPersonId,
|
||||||
|
);
|
||||||
|
updatedReminders.add(merged);
|
||||||
|
return merged;
|
||||||
|
})
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
final List<SourceProfileLink> nextLinks = current.sourceLinks
|
||||||
|
.map((SourceProfileLink link) {
|
||||||
|
if (link.profileId != sourcePersonId) {
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
return link.copyWith(profileId: targetPersonId);
|
||||||
|
})
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
final List<SharedMessageEntry> nextMessages = current.sharedMessages
|
||||||
|
.map((SharedMessageEntry entry) {
|
||||||
|
if (entry.profileId != sourcePersonId) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
return entry.copyWith(profileId: targetPersonId);
|
||||||
|
})
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
final List<SharedInboxEntry> nextInbox = current.sharedInbox
|
||||||
|
.map((SharedInboxEntry entry) {
|
||||||
|
if (!entry.candidateProfileIds.contains(sourcePersonId)) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
final List<String> candidates = entry.candidateProfileIds
|
||||||
|
.map(
|
||||||
|
(String profileId) =>
|
||||||
|
profileId == sourcePersonId ? targetPersonId : profileId,
|
||||||
|
)
|
||||||
|
.toSet()
|
||||||
|
.toList(growable: false);
|
||||||
|
return entry.copyWith(candidateProfileIds: candidates);
|
||||||
|
})
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
await _setState(
|
||||||
|
current.copyWith(
|
||||||
|
people: nextPeople,
|
||||||
|
moments: nextMoments,
|
||||||
|
ideas: nextIdeas,
|
||||||
|
reminders: nextReminders,
|
||||||
|
sourceLinks: nextLinks,
|
||||||
|
sharedMessages: nextMessages,
|
||||||
|
sharedInbox: nextInbox,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _enqueueChanges(<ChangeEnvelope>[
|
||||||
|
_buildEnvelope(
|
||||||
|
entityType: 'person',
|
||||||
|
entityId: mergedTarget.id,
|
||||||
|
op: ChangeOperation.upsert,
|
||||||
|
payload: _personPayload(mergedTarget),
|
||||||
|
),
|
||||||
|
_buildEnvelope(
|
||||||
|
entityType: 'person',
|
||||||
|
entityId: sourcePersonId,
|
||||||
|
op: ChangeOperation.delete,
|
||||||
|
),
|
||||||
|
...updatedMoments.map(
|
||||||
|
(RelationshipMoment moment) => _buildEnvelope(
|
||||||
|
entityType: 'capture',
|
||||||
|
entityId: moment.id,
|
||||||
|
op: ChangeOperation.upsert,
|
||||||
|
payload: _momentPayload(moment),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...updatedIdeas.map(
|
||||||
|
(RelationshipIdea idea) => _buildEnvelope(
|
||||||
|
entityType: _ideaEntityType(idea.type),
|
||||||
|
entityId: idea.id,
|
||||||
|
op: ChangeOperation.upsert,
|
||||||
|
payload: _ideaPayload(idea),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...updatedReminders.map(
|
||||||
|
(ReminderRule reminder) => _buildEnvelope(
|
||||||
|
entityType: 'reminderRule',
|
||||||
|
entityId: reminder.id,
|
||||||
|
op: ChangeOperation.upsert,
|
||||||
|
payload: _reminderPayload(reminder),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> addMoment({
|
Future<void> addMoment({
|
||||||
required String personId,
|
required String personId,
|
||||||
required String summary,
|
required String summary,
|
||||||
@@ -1264,6 +1437,49 @@ class LocalRepository extends AsyncNotifier<LocalDataState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<String> _mergeTags(List<String> primary, List<String> secondary) {
|
||||||
|
final List<String> merged = <String>[];
|
||||||
|
final Set<String> seen = <String>{};
|
||||||
|
for (final String raw in <String>[...primary, ...secondary]) {
|
||||||
|
final String tag = raw.trim();
|
||||||
|
if (tag.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final String normalized = tag.toLowerCase();
|
||||||
|
if (seen.add(normalized)) {
|
||||||
|
merged.add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _mergeNotes({
|
||||||
|
required String targetNotes,
|
||||||
|
required String sourceName,
|
||||||
|
required String sourceNotes,
|
||||||
|
}) {
|
||||||
|
final String target = targetNotes.trim();
|
||||||
|
final String source = sourceNotes.trim();
|
||||||
|
if (target.isEmpty) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
if (source.isEmpty) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
if (target == source) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
return '$target\n\nMerged from $sourceName:\n$source';
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _effectiveLocation(String? target, String? source) {
|
||||||
|
final String? targetValue = _trimToNull(target);
|
||||||
|
if (targetValue != null) {
|
||||||
|
return targetValue;
|
||||||
|
}
|
||||||
|
return _trimToNull(source);
|
||||||
|
}
|
||||||
|
|
||||||
Future<SharedMessageIngestResult> _queueSharedInbox({
|
Future<SharedMessageIngestResult> _queueSharedInbox({
|
||||||
required LocalDataState current,
|
required LocalDataState current,
|
||||||
required String sourceApp,
|
required String sourceApp,
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ class PeopleView extends ConsumerWidget {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
_PeopleHeader(
|
_PeopleHeader(
|
||||||
onAdd: () => _handleAddPerson(context, ref),
|
onAdd: () => _handleAddPerson(context, ref),
|
||||||
|
onMerge: () => _handleMergePeople(context, ref, people),
|
||||||
compact: false,
|
compact: false,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
@@ -229,6 +230,7 @@ class PeopleView extends ConsumerWidget {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
_PeopleHeader(
|
_PeopleHeader(
|
||||||
onAdd: () => _handleAddPerson(context, ref),
|
onAdd: () => _handleAddPerson(context, ref),
|
||||||
|
onMerge: () => _handleMergePeople(context, ref, people),
|
||||||
compact: true,
|
compact: true,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
@@ -331,6 +333,83 @@ class PeopleView extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleMergePeople(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
List<PersonProfile> people,
|
||||||
|
) async {
|
||||||
|
if (people.length < 2) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Need at least two profiles to merge.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<PersonProfile> duplicateCandidates = _duplicateCandidates(
|
||||||
|
people,
|
||||||
|
);
|
||||||
|
if (duplicateCandidates.length < 2) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'No duplicate-name profiles found. Edit names first if needed.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final _MergePeopleDraft? draft = await showDialog<_MergePeopleDraft>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) =>
|
||||||
|
_MergePeopleDialog(people: duplicateCandidates),
|
||||||
|
);
|
||||||
|
if (draft == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ref
|
||||||
|
.read(localRepositoryProvider.notifier)
|
||||||
|
.mergePersonProfiles(
|
||||||
|
sourcePersonId: draft.sourcePersonId,
|
||||||
|
targetPersonId: draft.targetPersonId,
|
||||||
|
);
|
||||||
|
ref.read(selectedPersonIdProvider.notifier).select(draft.targetPersonId);
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Profiles merged successfully.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<PersonProfile> _duplicateCandidates(List<PersonProfile> people) {
|
||||||
|
final Map<String, List<PersonProfile>> grouped =
|
||||||
|
<String, List<PersonProfile>>{};
|
||||||
|
for (final PersonProfile person in people) {
|
||||||
|
final String key = _normalizeNameKey(person.name);
|
||||||
|
if (key.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
grouped.putIfAbsent(key, () => <PersonProfile>[]).add(person);
|
||||||
|
}
|
||||||
|
final List<PersonProfile> result = <PersonProfile>[];
|
||||||
|
for (final List<PersonProfile> group in grouped.values) {
|
||||||
|
if (group.length > 1) {
|
||||||
|
result.addAll(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizeNameKey(String value) {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(RegExp(r'[^a-z0-9]+'), ' ')
|
||||||
|
.replaceAll(RegExp(r'\s+'), ' ');
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> _showQuickTextCaptureDialog(
|
Future<String?> _showQuickTextCaptureDialog(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required String title,
|
required String title,
|
||||||
@@ -602,9 +681,14 @@ class _PersonDetail extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _PeopleHeader extends StatelessWidget {
|
class _PeopleHeader extends StatelessWidget {
|
||||||
const _PeopleHeader({required this.onAdd, required this.compact});
|
const _PeopleHeader({
|
||||||
|
required this.onAdd,
|
||||||
|
required this.onMerge,
|
||||||
|
required this.compact,
|
||||||
|
});
|
||||||
|
|
||||||
final VoidCallback onAdd;
|
final VoidCallback onAdd;
|
||||||
|
final VoidCallback onMerge;
|
||||||
final bool compact;
|
final bool compact;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -628,6 +712,12 @@ class _PeopleHeader extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.person_add_alt_1_rounded),
|
icon: const Icon(Icons.person_add_alt_1_rounded),
|
||||||
label: const Text('Add person'),
|
label: const Text('Add person'),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: onMerge,
|
||||||
|
icon: const Icon(Icons.merge_type_rounded),
|
||||||
|
label: const Text('Merge duplicates'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -654,6 +744,12 @@ class _PeopleHeader extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.person_add_alt_1_rounded),
|
icon: const Icon(Icons.person_add_alt_1_rounded),
|
||||||
label: const Text('Add'),
|
label: const Text('Add'),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: onMerge,
|
||||||
|
icon: const Icon(Icons.merge_type_rounded),
|
||||||
|
label: const Text('Merge'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -851,6 +947,138 @@ class _PersonDraft {
|
|||||||
final String? location;
|
final String? location;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _MergePeopleDraft {
|
||||||
|
const _MergePeopleDraft({
|
||||||
|
required this.sourcePersonId,
|
||||||
|
required this.targetPersonId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String sourcePersonId;
|
||||||
|
final String targetPersonId;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MergePeopleDialog extends StatefulWidget {
|
||||||
|
const _MergePeopleDialog({required this.people});
|
||||||
|
|
||||||
|
final List<PersonProfile> people;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_MergePeopleDialog> createState() => _MergePeopleDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MergePeopleDialogState extends State<_MergePeopleDialog> {
|
||||||
|
late String _sourcePersonId;
|
||||||
|
late String _targetPersonId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_targetPersonId = widget.people.first.id;
|
||||||
|
_sourcePersonId = widget.people.length > 1
|
||||||
|
? widget.people[1].id
|
||||||
|
: widget.people.first.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Merge Duplicate Profiles'),
|
||||||
|
content: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
'Move captures, ideas, reminders, and share links from source into target profile.',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(color: AppTheme.textSecondary),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
key: ValueKey<String>('target-$_targetPersonId'),
|
||||||
|
initialValue: _targetPersonId,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Keep target profile',
|
||||||
|
),
|
||||||
|
items: widget.people
|
||||||
|
.map(
|
||||||
|
(PersonProfile person) => DropdownMenuItem<String>(
|
||||||
|
value: person.id,
|
||||||
|
child: Text('${person.name} · ${person.relationship}'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(growable: false),
|
||||||
|
onChanged: (String? value) {
|
||||||
|
if (value == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_targetPersonId = value;
|
||||||
|
if (_targetPersonId == _sourcePersonId) {
|
||||||
|
_sourcePersonId = widget.people
|
||||||
|
.firstWhere(
|
||||||
|
(PersonProfile person) =>
|
||||||
|
person.id != _targetPersonId,
|
||||||
|
orElse: () => widget.people.first,
|
||||||
|
)
|
||||||
|
.id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
key: ValueKey<String>('source-$_targetPersonId-$_sourcePersonId'),
|
||||||
|
initialValue: _sourcePersonId,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Merge and remove source profile',
|
||||||
|
),
|
||||||
|
items: widget.people
|
||||||
|
.where((PersonProfile person) => person.id != _targetPersonId)
|
||||||
|
.map(
|
||||||
|
(PersonProfile person) => DropdownMenuItem<String>(
|
||||||
|
value: person.id,
|
||||||
|
child: Text('${person.name} · ${person.relationship}'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(growable: false),
|
||||||
|
onChanged: (String? value) {
|
||||||
|
if (value == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_sourcePersonId = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (_sourcePersonId == _targetPersonId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop(
|
||||||
|
_MergePeopleDraft(
|
||||||
|
sourcePersonId: _sourcePersonId,
|
||||||
|
targetPersonId: _targetPersonId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Merge'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _QuickIdeaDraft {
|
class _QuickIdeaDraft {
|
||||||
const _QuickIdeaDraft({
|
const _QuickIdeaDraft({
|
||||||
required this.type,
|
required this.type,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:relationship_saver/core/config/app_config.dart';
|
|||||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||||
import 'package:relationship_saver/features/auth/session_controller.dart';
|
import 'package:relationship_saver/features/auth/session_controller.dart';
|
||||||
import 'package:relationship_saver/features/local/local_repository.dart';
|
import 'package:relationship_saver/features/local/local_repository.dart';
|
||||||
|
import 'package:relationship_saver/features/people/people_view.dart';
|
||||||
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart';
|
import 'package:relationship_saver/features/reminders/scheduling/reminder_scheduler_provider.dart';
|
||||||
import 'package:relationship_saver/features/share_intake/share_inbox_view.dart';
|
import 'package:relationship_saver/features/share_intake/share_inbox_view.dart';
|
||||||
import 'package:relationship_saver/features/share_intake/whatsapp_share_parser.dart';
|
import 'package:relationship_saver/features/share_intake/whatsapp_share_parser.dart';
|
||||||
@@ -240,6 +241,34 @@ class SettingsView extends ConsumerWidget {
|
|||||||
? 'Imported and created ${result.profileName}.'
|
? 'Imported and created ${result.profileName}.'
|
||||||
: 'Imported to ${result.profileName}.',
|
: 'Imported to ${result.profileName}.',
|
||||||
),
|
),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: result.isQueuedForResolution ? 'Open Inbox' : 'Open Profile',
|
||||||
|
onPressed: () => _openShareDestination(context, ref, result),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openShareDestination(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
SharedMessageIngestResult result,
|
||||||
|
) {
|
||||||
|
if (result.isQueuedForResolution) {
|
||||||
|
Navigator.of(context).push<void>(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (BuildContext context) => const ShareInboxView(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.profileId != null) {
|
||||||
|
ref.read(selectedPersonIdProvider.notifier).select(result.profileId!);
|
||||||
|
}
|
||||||
|
Navigator.of(context).push<void>(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (BuildContext context) => const PeopleView(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,26 +48,33 @@ class _WhatsAppShareListenerState extends ConsumerState<WhatsAppShareListener> {
|
|||||||
try {
|
try {
|
||||||
_mediaSubscription = ReceiveSharingIntent.instance
|
_mediaSubscription = ReceiveSharingIntent.instance
|
||||||
.getMediaStream()
|
.getMediaStream()
|
||||||
.listen(_handleMediaList, onError: (_) {});
|
.listen(
|
||||||
|
(List<SharedMediaFile> files) =>
|
||||||
|
_handleMediaList(files, autoOpenDestination: false),
|
||||||
|
onError: (_) {},
|
||||||
|
);
|
||||||
|
|
||||||
final List<SharedMediaFile> initialMedia = await ReceiveSharingIntent
|
final List<SharedMediaFile> initialMedia = await ReceiveSharingIntent
|
||||||
.instance
|
.instance
|
||||||
.getInitialMedia();
|
.getInitialMedia();
|
||||||
if (initialMedia.isNotEmpty) {
|
if (initialMedia.isNotEmpty) {
|
||||||
await _handleMediaList(initialMedia);
|
await _handleMediaList(initialMedia, autoOpenDestination: true);
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Unsupported platform/plugin state should not break app usage.
|
// Unsupported platform/plugin state should not break app usage.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleMediaList(List<SharedMediaFile> files) async {
|
Future<void> _handleMediaList(
|
||||||
|
List<SharedMediaFile> files, {
|
||||||
|
required bool autoOpenDestination,
|
||||||
|
}) async {
|
||||||
for (final SharedMediaFile file in files) {
|
for (final SharedMediaFile file in files) {
|
||||||
final String? rawText = _extractText(file);
|
final String? rawText = _extractText(file);
|
||||||
if (rawText == null) {
|
if (rawText == null) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await _handleRawText(rawText);
|
await _handleRawText(rawText, autoOpenDestination: autoOpenDestination);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +102,10 @@ class _WhatsAppShareListenerState extends ConsumerState<WhatsAppShareListener> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleRawText(String rawText) async {
|
Future<void> _handleRawText(
|
||||||
|
String rawText, {
|
||||||
|
required bool autoOpenDestination,
|
||||||
|
}) async {
|
||||||
final String trimmed = rawText.trim();
|
final String trimmed = rawText.trim();
|
||||||
if (trimmed.isEmpty) {
|
if (trimmed.isEmpty) {
|
||||||
return;
|
return;
|
||||||
@@ -130,6 +140,10 @@ class _WhatsAppShareListenerState extends ConsumerState<WhatsAppShareListener> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (autoOpenDestination) {
|
||||||
|
_openShareDestination(result);
|
||||||
|
}
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
@@ -140,16 +154,8 @@ class _WhatsAppShareListenerState extends ConsumerState<WhatsAppShareListener> {
|
|||||||
: 'Imported from WhatsApp to ${result.profileName}.',
|
: 'Imported from WhatsApp to ${result.profileName}.',
|
||||||
),
|
),
|
||||||
action: SnackBarAction(
|
action: SnackBarAction(
|
||||||
label: result.isQueuedForResolution ? 'Open Inbox' : 'Open People',
|
label: result.isQueuedForResolution ? 'Open Inbox' : 'Open Profile',
|
||||||
onPressed: () {
|
onPressed: () => _openShareDestination(result),
|
||||||
Navigator.of(context).push<void>(
|
|
||||||
MaterialPageRoute<void>(
|
|
||||||
builder: (BuildContext context) => result.isQueuedForResolution
|
|
||||||
? const ShareInboxView()
|
|
||||||
: const PeopleView(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -160,4 +166,27 @@ class _WhatsAppShareListenerState extends ConsumerState<WhatsAppShareListener> {
|
|||||||
// Best effort.
|
// Best effort.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openShareDestination(SharedMessageIngestResult result) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.isQueuedForResolution) {
|
||||||
|
Navigator.of(context).push<void>(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (BuildContext context) => const ShareInboxView(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.profileId != null) {
|
||||||
|
ref.read(selectedPersonIdProvider.notifier).select(result.profileId!);
|
||||||
|
}
|
||||||
|
Navigator.of(context).push<void>(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (BuildContext context) => const PeopleView(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,6 +254,125 @@ void main() {
|
|||||||
expect(after.sharedMessages.first.resolvedAutomatically, isFalse);
|
expect(after.sharedMessages.first.resolvedAutomatically, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'mergePersonProfiles rebinds related records to target profile',
|
||||||
|
() async {
|
||||||
|
final ProviderContainer container = _createContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await container.read(localRepositoryProvider.future);
|
||||||
|
await container
|
||||||
|
.read(localRepositoryProvider.notifier)
|
||||||
|
.addPerson(
|
||||||
|
name: 'Ava Hart',
|
||||||
|
relationship: 'Partner',
|
||||||
|
notes: 'Primary notes',
|
||||||
|
tags: const <String>['coffee'],
|
||||||
|
location: 'Austin',
|
||||||
|
);
|
||||||
|
await container
|
||||||
|
.read(localRepositoryProvider.notifier)
|
||||||
|
.addPerson(
|
||||||
|
name: 'Ava H.',
|
||||||
|
relationship: 'Friend',
|
||||||
|
notes: 'Secondary notes',
|
||||||
|
tags: const <String>['books'],
|
||||||
|
);
|
||||||
|
|
||||||
|
LocalDataState state = await container.read(
|
||||||
|
localRepositoryProvider.future,
|
||||||
|
);
|
||||||
|
final String targetId = state.people
|
||||||
|
.firstWhere((PersonProfile person) => person.name == 'Ava Hart')
|
||||||
|
.id;
|
||||||
|
final String sourceId = state.people
|
||||||
|
.firstWhere((PersonProfile person) => person.name == 'Ava H.')
|
||||||
|
.id;
|
||||||
|
|
||||||
|
await container
|
||||||
|
.read(localRepositoryProvider.notifier)
|
||||||
|
.addMoment(personId: sourceId, summary: 'Source profile moment');
|
||||||
|
await container
|
||||||
|
.read(localRepositoryProvider.notifier)
|
||||||
|
.addIdea(
|
||||||
|
type: IdeaType.gift,
|
||||||
|
title: 'Source profile idea',
|
||||||
|
details: 'idea',
|
||||||
|
personId: sourceId,
|
||||||
|
);
|
||||||
|
await container
|
||||||
|
.read(localRepositoryProvider.notifier)
|
||||||
|
.addReminder(
|
||||||
|
title: 'Source profile reminder',
|
||||||
|
cadence: ReminderCadence.weekly,
|
||||||
|
nextAt: DateTime.now().add(const Duration(days: 2)),
|
||||||
|
personId: sourceId,
|
||||||
|
);
|
||||||
|
await container
|
||||||
|
.read(localRepositoryProvider.notifier)
|
||||||
|
.ingestSharedMessage(
|
||||||
|
const SharedMessageIngestInput(
|
||||||
|
sourceApp: 'whatsapp',
|
||||||
|
sourceDisplayName: 'Ava H.',
|
||||||
|
sourceUserId: 'wa:ava_h',
|
||||||
|
messageText: 'Source-linked shared message',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await container
|
||||||
|
.read(localRepositoryProvider.notifier)
|
||||||
|
.mergePersonProfiles(
|
||||||
|
sourcePersonId: sourceId,
|
||||||
|
targetPersonId: targetId,
|
||||||
|
);
|
||||||
|
|
||||||
|
state = await container.read(localRepositoryProvider.future);
|
||||||
|
expect(
|
||||||
|
state.people.any((PersonProfile person) => person.id == sourceId),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
|
||||||
|
final PersonProfile merged = state.people.firstWhere(
|
||||||
|
(PersonProfile person) => person.id == targetId,
|
||||||
|
);
|
||||||
|
expect(merged.tags, containsAll(<String>['coffee', 'books']));
|
||||||
|
expect(merged.notes, contains('Primary notes'));
|
||||||
|
expect(merged.notes, contains('Secondary notes'));
|
||||||
|
expect(merged.location, 'Austin');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
state.moments.any(
|
||||||
|
(RelationshipMoment moment) => moment.personId == sourceId,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
state.ideas.any((RelationshipIdea idea) => idea.personId == sourceId),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
state.reminders.any(
|
||||||
|
(ReminderRule reminder) => reminder.personId == sourceId,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
state.sourceLinks.any(
|
||||||
|
(SourceProfileLink link) =>
|
||||||
|
link.sourceUserId == 'wa:ava_h' && link.profileId == targetId,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
state.sharedMessages.any(
|
||||||
|
(SharedMessageEntry entry) =>
|
||||||
|
entry.sourceUserId == 'wa:ava_h' && entry.profileId == targetId,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
test('adds, archives, edits, and deletes ideas', () async {
|
test('adds, archives, edits, and deletes ideas', () async {
|
||||||
final ProviderContainer container = _createContainer();
|
final ProviderContainer container = _createContainer();
|
||||||
addTearDown(container.dispose);
|
addTearDown(container.dispose);
|
||||||
|
|||||||
Reference in New Issue
Block a user