Add duplicate profile merge and share deep-link routing
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user