diff --git a/docs/SETUP.md b/docs/SETUP.md index 166fa66..4ee1156 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -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: diff --git a/docs/progress.md b/docs/progress.md index b8e08a0..fcf612d 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -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 diff --git a/lib/features/local/local_repository.dart b/lib/features/local/local_repository.dart index 4b2493d..d40c352 100644 --- a/lib/features/local/local_repository.dart +++ b/lib/features/local/local_repository.dart @@ -483,6 +483,179 @@ class LocalRepository extends AsyncNotifier { ]); } + /// Merge one person profile into another and rebind related records. + Future 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 nextPeople = current.people + .where((PersonProfile person) => person.id != sourcePersonId) + .map( + (PersonProfile person) => + person.id == targetPersonId ? mergedTarget : person, + ) + .toList(growable: false); + + final List updatedMoments = []; + final List 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 updatedIdeas = []; + final List 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 updatedReminders = []; + final List 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 nextLinks = current.sourceLinks + .map((SourceProfileLink link) { + if (link.profileId != sourcePersonId) { + return link; + } + return link.copyWith(profileId: targetPersonId); + }) + .toList(growable: false); + + final List nextMessages = current.sharedMessages + .map((SharedMessageEntry entry) { + if (entry.profileId != sourcePersonId) { + return entry; + } + return entry.copyWith(profileId: targetPersonId); + }) + .toList(growable: false); + + final List nextInbox = current.sharedInbox + .map((SharedInboxEntry entry) { + if (!entry.candidateProfileIds.contains(sourcePersonId)) { + return entry; + } + final List 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([ + _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 addMoment({ required String personId, required String summary, @@ -1264,6 +1437,49 @@ class LocalRepository extends AsyncNotifier { ); } + List _mergeTags(List primary, List secondary) { + final List merged = []; + final Set seen = {}; + for (final String raw in [...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 _queueSharedInbox({ required LocalDataState current, required String sourceApp, diff --git a/lib/features/people/people_view.dart b/lib/features/people/people_view.dart index ddb1b08..af4f41d 100644 --- a/lib/features/people/people_view.dart +++ b/lib/features/people/people_view.dart @@ -175,6 +175,7 @@ class PeopleView extends ConsumerWidget { children: [ _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: [ _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 _handleMergePeople( + BuildContext context, + WidgetRef ref, + List people, + ) async { + if (people.length < 2) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Need at least two profiles to merge.')), + ); + return; + } + + final List 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 _duplicateCandidates(List people) { + final Map> grouped = + >{}; + for (final PersonProfile person in people) { + final String key = _normalizeNameKey(person.name); + if (key.isEmpty) { + continue; + } + grouped.putIfAbsent(key, () => []).add(person); + } + final List result = []; + for (final List 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 _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 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: [ + 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( + key: ValueKey('target-$_targetPersonId'), + initialValue: _targetPersonId, + decoration: const InputDecoration( + labelText: 'Keep target profile', + ), + items: widget.people + .map( + (PersonProfile person) => DropdownMenuItem( + 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( + key: ValueKey('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( + value: person.id, + child: Text('${person.name} · ${person.relationship}'), + ), + ) + .toList(growable: false), + onChanged: (String? value) { + if (value == null) { + return; + } + setState(() { + _sourcePersonId = value; + }); + }, + ), + ], + ), + ), + actions: [ + 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, diff --git a/lib/features/settings/settings_view.dart b/lib/features/settings/settings_view.dart index 5cea5ab..0d383ce 100644 --- a/lib/features/settings/settings_view.dart +++ b/lib/features/settings/settings_view.dart @@ -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( + MaterialPageRoute( + builder: (BuildContext context) => const ShareInboxView(), + ), + ); + return; + } + + if (result.profileId != null) { + ref.read(selectedPersonIdProvider.notifier).select(result.profileId!); + } + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => const PeopleView(), ), ); } diff --git a/lib/features/share_intake/whatsapp_share_listener.dart b/lib/features/share_intake/whatsapp_share_listener.dart index eb5b60e..1d57fc0 100644 --- a/lib/features/share_intake/whatsapp_share_listener.dart +++ b/lib/features/share_intake/whatsapp_share_listener.dart @@ -48,26 +48,33 @@ class _WhatsAppShareListenerState extends ConsumerState { try { _mediaSubscription = ReceiveSharingIntent.instance .getMediaStream() - .listen(_handleMediaList, onError: (_) {}); + .listen( + (List files) => + _handleMediaList(files, autoOpenDestination: false), + onError: (_) {}, + ); final List 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 _handleMediaList(List files) async { + Future _handleMediaList( + List 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 { return null; } - Future _handleRawText(String rawText) async { + Future _handleRawText( + String rawText, { + required bool autoOpenDestination, + }) async { final String trimmed = rawText.trim(); if (trimmed.isEmpty) { return; @@ -130,6 +140,10 @@ class _WhatsAppShareListenerState extends ConsumerState { return; } + if (autoOpenDestination) { + _openShareDestination(result); + } + ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( @@ -140,16 +154,8 @@ class _WhatsAppShareListenerState extends ConsumerState { : 'Imported from WhatsApp to ${result.profileName}.', ), action: SnackBarAction( - label: result.isQueuedForResolution ? 'Open Inbox' : 'Open People', - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - 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 { // Best effort. } } + + void _openShareDestination(SharedMessageIngestResult result) { + if (!mounted) { + return; + } + if (result.isQueuedForResolution) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => const ShareInboxView(), + ), + ); + return; + } + + if (result.profileId != null) { + ref.read(selectedPersonIdProvider.notifier).select(result.profileId!); + } + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) => const PeopleView(), + ), + ); + } } diff --git a/test/features/local/local_repository_test.dart b/test/features/local/local_repository_test.dart index d7ae4ce..4c0a00d 100644 --- a/test/features/local/local_repository_test.dart +++ b/test/features/local/local_repository_test.dart @@ -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 ['coffee'], + location: 'Austin', + ); + await container + .read(localRepositoryProvider.notifier) + .addPerson( + name: 'Ava H.', + relationship: 'Friend', + notes: 'Secondary notes', + tags: const ['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(['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);