Add person editor validation and duplicate warnings
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user