Add person editor validation and duplicate warnings

This commit is contained in:
Rijad Zuzo
2026-02-22 23:15:18 +01:00
parent 0233288506
commit 058e2c1787
2 changed files with 176 additions and 8 deletions
+157 -8
View File
@@ -221,9 +221,13 @@ class PeopleView extends ConsumerWidget {
}
Future<void> _handleAddPerson(BuildContext context, WidgetRef ref) async {
final List<PersonProfile> existingPeople =
ref.read(localRepositoryProvider).asData?.value.people ??
const <PersonProfile>[];
final _PersonDraft? draft = await _showPersonEditor(
context,
title: 'Add Person',
existingPeople: existingPeople,
);
if (draft == null) {
@@ -264,6 +268,9 @@ class PeopleView extends ConsumerWidget {
WidgetRef ref,
PersonProfile person,
) async {
final List<PersonProfile> existingPeople =
ref.read(localRepositoryProvider).asData?.value.people ??
const <PersonProfile>[];
final _PersonDraft? draft = await _showPersonEditor(
context,
title: 'Edit Person',
@@ -274,6 +281,8 @@ class PeopleView extends ConsumerWidget {
tags: person.tags,
location: person.location,
),
existingPeople: existingPeople,
editingPersonId: person.id,
);
if (draft == null) {
@@ -624,6 +633,8 @@ class PeopleView extends ConsumerWidget {
BuildContext context, {
required String title,
_PersonDraft? initial,
List<PersonProfile> existingPeople = const <PersonProfile>[],
String? editingPersonId,
}) {
final bool compact = MediaQuery.sizeOf(context).width < 720;
if (compact) {
@@ -633,7 +644,12 @@ class PeopleView extends ConsumerWidget {
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (BuildContext context) {
return _PersonEditorBottomSheet(title: title, initial: initial);
return _PersonEditorBottomSheet(
title: title,
initial: initial,
existingPeople: existingPeople,
editingPersonId: editingPersonId,
);
},
);
}
@@ -641,7 +657,12 @@ class PeopleView extends ConsumerWidget {
return showDialog<_PersonDraft>(
context: context,
builder: (BuildContext context) {
return _PersonEditorDialog(title: title, initial: initial);
return _PersonEditorDialog(
title: title,
initial: initial,
existingPeople: existingPeople,
editingPersonId: editingPersonId,
);
},
);
}
@@ -1739,10 +1760,17 @@ class _PersonListItem extends StatelessWidget {
}
class _PersonEditorDialog extends StatelessWidget {
const _PersonEditorDialog({required this.title, this.initial});
const _PersonEditorDialog({
required this.title,
required this.existingPeople,
this.initial,
this.editingPersonId,
});
final String title;
final List<PersonProfile> existingPeople;
final _PersonDraft? initial;
final String? editingPersonId;
@override
Widget build(BuildContext context) {
@@ -1754,6 +1782,8 @@ class _PersonEditorDialog extends StatelessWidget {
child: _PersonEditorPanel(
title: title,
initial: initial,
existingPeople: existingPeople,
editingPersonId: editingPersonId,
compact: false,
sheetStyle: false,
),
@@ -1763,10 +1793,17 @@ class _PersonEditorDialog extends StatelessWidget {
}
class _PersonEditorBottomSheet extends StatelessWidget {
const _PersonEditorBottomSheet({required this.title, this.initial});
const _PersonEditorBottomSheet({
required this.title,
required this.existingPeople,
this.initial,
this.editingPersonId,
});
final String title;
final List<PersonProfile> existingPeople;
final _PersonDraft? initial;
final String? editingPersonId;
@override
Widget build(BuildContext context) {
@@ -1779,6 +1816,8 @@ class _PersonEditorBottomSheet extends StatelessWidget {
child: _PersonEditorPanel(
title: title,
initial: initial,
existingPeople: existingPeople,
editingPersonId: editingPersonId,
compact: true,
sheetStyle: true,
),
@@ -1791,13 +1830,17 @@ class _PersonEditorPanel extends StatefulWidget {
required this.title,
required this.compact,
required this.sheetStyle,
required this.existingPeople,
this.initial,
this.editingPersonId,
});
final String title;
final bool compact;
final bool sheetStyle;
final List<PersonProfile> existingPeople;
final _PersonDraft? initial;
final String? editingPersonId;
@override
State<_PersonEditorPanel> createState() => _PersonEditorPanelState();
@@ -1809,6 +1852,7 @@ class _PersonEditorPanelState extends State<_PersonEditorPanel> {
late final TextEditingController _locationController;
late final TextEditingController _tagsController;
late final TextEditingController _notesController;
bool _attemptedSubmit = false;
@override
void initState() {
@@ -1855,6 +1899,8 @@ class _PersonEditorPanelState extends State<_PersonEditorPanel> {
Widget build(BuildContext context) {
final String name = _nameController.text.trim();
final String relationship = _relationshipController.text.trim();
final bool nameMissing = _attemptedSubmit && name.isEmpty;
final bool relationshipMissing = _attemptedSubmit && relationship.isEmpty;
final String? location = _locationController.text.trim().isEmpty
? null
: _locationController.text.trim();
@@ -1863,6 +1909,12 @@ class _PersonEditorPanelState extends State<_PersonEditorPanel> {
.map((String tag) => tag.trim())
.where((String tag) => tag.isNotEmpty)
.toList(growable: false);
final List<PersonProfile> duplicateMatches = _findDuplicateNameMatches(
name: name,
people: widget.existingPeople,
excludePersonId: widget.editingPersonId,
);
final bool hasDuplicateWarning = duplicateMatches.isNotEmpty;
final BorderRadius radius = BorderRadius.circular(widget.compact ? 24 : 22);
final double maxHeight = widget.compact ? 720 : 760;
@@ -1925,6 +1977,10 @@ class _PersonEditorPanelState extends State<_PersonEditorPanel> {
previewLocation: location,
previewTags: tags,
),
if (hasDuplicateWarning) ...<Widget>[
const SizedBox(height: 10),
_PersonEditorWarningBanner(matches: duplicateMatches),
],
const SizedBox(height: 12),
if (widget.compact)
Column(
@@ -1937,15 +1993,21 @@ class _PersonEditorPanelState extends State<_PersonEditorPanel> {
TextField(
controller: _nameController,
autofocus: true,
decoration: const InputDecoration(
decoration: InputDecoration(
labelText: 'Name',
errorText: nameMissing
? 'Name is required'
: null,
),
),
const SizedBox(height: 10),
TextField(
controller: _relationshipController,
decoration: const InputDecoration(
decoration: InputDecoration(
labelText: 'Relationship',
errorText: relationshipMissing
? 'Relationship is required'
: null,
),
),
const SizedBox(height: 10),
@@ -1999,15 +2061,21 @@ class _PersonEditorPanelState extends State<_PersonEditorPanel> {
TextField(
controller: _nameController,
autofocus: true,
decoration: const InputDecoration(
decoration: InputDecoration(
labelText: 'Name',
errorText: nameMissing
? 'Name is required'
: null,
),
),
const SizedBox(height: 10),
TextField(
controller: _relationshipController,
decoration: const InputDecoration(
decoration: InputDecoration(
labelText: 'Relationship',
errorText: relationshipMissing
? 'Relationship is required'
: null,
),
),
const SizedBox(height: 10),
@@ -2092,6 +2160,9 @@ class _PersonEditorPanelState extends State<_PersonEditorPanel> {
_relationshipController.text.trim();
if (submittedName.isEmpty ||
submittedRelationship.isEmpty) {
setState(() {
_attemptedSubmit = true;
});
return;
}
@@ -2258,6 +2329,56 @@ class _PersonEditorSectionCard extends StatelessWidget {
}
}
class _PersonEditorWarningBanner extends StatelessWidget {
const _PersonEditorWarningBanner({required this.matches});
final List<PersonProfile> matches;
@override
Widget build(BuildContext context) {
final String names = matches
.take(2)
.map(
(PersonProfile person) => '${person.name} (${person.relationship})',
)
.join(', ');
final int extraCount = matches.length - 2;
final String suffix = extraCount > 0 ? ' +$extraCount more' : '';
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFFFF7E8),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFFFE2A8)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Padding(
padding: EdgeInsets.only(top: 1),
child: Icon(
Icons.warning_amber_rounded,
size: 18,
color: Color(0xFFA56A00),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Possible duplicate profile name detected. Existing: $names$suffix. You can still save, then merge duplicates from People.',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: const Color(0xFF6F5200)),
),
),
],
),
);
}
}
class _PersonDraft {
const _PersonDraft({
required this.name,
@@ -2274,6 +2395,34 @@ class _PersonDraft {
final String? location;
}
List<PersonProfile> _findDuplicateNameMatches({
required String name,
required List<PersonProfile> people,
String? excludePersonId,
}) {
final String normalized = _normalizePersonNameKey(name);
if (normalized.isEmpty) {
return const <PersonProfile>[];
}
return people
.where((PersonProfile person) {
if (excludePersonId != null && person.id == excludePersonId) {
return false;
}
return _normalizePersonNameKey(person.name) == normalized;
})
.toList(growable: false);
}
String _normalizePersonNameKey(String value) {
return value
.trim()
.toLowerCase()
.replaceAll(RegExp(r'[^a-z0-9]+'), ' ')
.replaceAll(RegExp(r'\s+'), ' ');
}
class _MergePeopleDraft {
const _MergePeopleDraft({
required this.sourcePersonId,