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
+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,