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