Add duplicate profile merge and share deep-link routing

This commit is contained in:
Rijad Zuzo
2026-02-19 00:33:30 +01:00
parent d70edb4c4c
commit d8d96e53e8
7 changed files with 677 additions and 16 deletions
+6
View File
@@ -101,10 +101,16 @@ Behavior notes:
- moment history (`type=whatsapp`)
- ambiguous or missing-identity shares are queued in `Share Inbox` for manual
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:
- `Simulate WhatsApp Share` for local/dev testing
- `Open Share Inbox` to resolve queued items
People view includes `Merge duplicates` action for resolving duplicate profile
records after import.
## Local Persistence Backend
Default local store:
+34
View File
@@ -7,6 +7,40 @@ Updated: 2026-02-18
- 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): 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
Added manual resolution UX and data paths for ambiguous/unidentifiable WhatsApp
+216
View File
@@ -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({
required String personId,
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({
required LocalDataState current,
required String sourceApp,
+229 -1
View File
@@ -175,6 +175,7 @@ class PeopleView extends ConsumerWidget {
children: <Widget>[
_PeopleHeader(
onAdd: () => _handleAddPerson(context, ref),
onMerge: () => _handleMergePeople(context, ref, people),
compact: false,
),
const SizedBox(height: 20),
@@ -229,6 +230,7 @@ class PeopleView extends ConsumerWidget {
children: <Widget>[
_PeopleHeader(
onAdd: () => _handleAddPerson(context, ref),
onMerge: () => _handleMergePeople(context, ref, people),
compact: true,
),
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(
BuildContext context, {
required String title,
@@ -602,9 +681,14 @@ class _PersonDetail 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 onMerge;
final bool compact;
@override
@@ -628,6 +712,12 @@ class _PeopleHeader extends StatelessWidget {
icon: const Icon(Icons.person_add_alt_1_rounded),
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),
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;
}
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 {
const _QuickIdeaDraft({
required this.type,
+29
View File
@@ -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/features/auth/session_controller.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/share_intake/share_inbox_view.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 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 {
_mediaSubscription = ReceiveSharingIntent.instance
.getMediaStream()
.listen(_handleMediaList, onError: (_) {});
.listen(
(List<SharedMediaFile> files) =>
_handleMediaList(files, autoOpenDestination: false),
onError: (_) {},
);
final List<SharedMediaFile> initialMedia = await ReceiveSharingIntent
.instance
.getInitialMedia();
if (initialMedia.isNotEmpty) {
await _handleMediaList(initialMedia);
await _handleMediaList(initialMedia, autoOpenDestination: true);
}
} catch (_) {
// 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) {
final String? rawText = _extractText(file);
if (rawText == null) {
continue;
}
await _handleRawText(rawText);
await _handleRawText(rawText, autoOpenDestination: autoOpenDestination);
}
}
@@ -95,7 +102,10 @@ class _WhatsAppShareListenerState extends ConsumerState<WhatsAppShareListener> {
return null;
}
Future<void> _handleRawText(String rawText) async {
Future<void> _handleRawText(
String rawText, {
required bool autoOpenDestination,
}) async {
final String trimmed = rawText.trim();
if (trimmed.isEmpty) {
return;
@@ -130,6 +140,10 @@ class _WhatsAppShareListenerState extends ConsumerState<WhatsAppShareListener> {
return;
}
if (autoOpenDestination) {
_openShareDestination(result);
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
@@ -140,16 +154,8 @@ class _WhatsAppShareListenerState extends ConsumerState<WhatsAppShareListener> {
: 'Imported from WhatsApp to ${result.profileName}.',
),
action: SnackBarAction(
label: result.isQueuedForResolution ? 'Open Inbox' : 'Open People',
onPressed: () {
Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (BuildContext context) => result.isQueuedForResolution
? const ShareInboxView()
: const PeopleView(),
),
);
},
label: result.isQueuedForResolution ? 'Open Inbox' : 'Open Profile',
onPressed: () => _openShareDestination(result),
),
),
);
@@ -160,4 +166,27 @@ class _WhatsAppShareListenerState extends ConsumerState<WhatsAppShareListener> {
// 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);
});
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 {
final ProviderContainer container = _createContainer();
addTearDown(container.dispose);