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`) - 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:
+34
View File
@@ -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
+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({ 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,
+229 -1
View File
@@ -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,
+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/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);