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
+19
View File
@@ -7,6 +7,25 @@ Updated: 2026-02-22
- After every sensible code/documentation change set, create a git commit as - 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. the last step so the next agent session can pick up from clean checkpoints.
## Latest Milestone (2026-02-22): Person Editor Inline Validation + Duplicate Warning
Improved the People add/edit profile experience to make validation explicit and
support safer profile creation in a sharing-first workflow.
- `lib/features/people/people_view.dart`
- added inline required-field validation for `Name` and `Relationship`
(no more silent no-op when save is pressed with empty required fields)
- added live duplicate-name warning banner in add/edit form:
- checks normalized profile names against existing local profiles
- excludes the current profile during edit
- warns before save while still allowing save (with follow-up merge flow)
- routed existing profile list into the responsive editor panel/sheet so
duplicate detection works on mobile + desktop/web
- Validation
- `flutter analyze` -> pass
- `flutter test` -> pass
## Latest Milestone (2026-02-22): Responsive People Add/Edit Form (Sheet + Dialog) ## Latest Milestone (2026-02-22): Responsive People Add/Edit Form (Sheet + Dialog)
Aligned the People profile creation/edit flow with the newer insights-style UI Aligned the People profile creation/edit flow with the newer insights-style UI
+157 -8
View File
@@ -221,9 +221,13 @@ class PeopleView extends ConsumerWidget {
} }
Future<void> _handleAddPerson(BuildContext context, WidgetRef ref) async { 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( final _PersonDraft? draft = await _showPersonEditor(
context, context,
title: 'Add Person', title: 'Add Person',
existingPeople: existingPeople,
); );
if (draft == null) { if (draft == null) {
@@ -264,6 +268,9 @@ class PeopleView extends ConsumerWidget {
WidgetRef ref, WidgetRef ref,
PersonProfile person, PersonProfile person,
) async { ) async {
final List<PersonProfile> existingPeople =
ref.read(localRepositoryProvider).asData?.value.people ??
const <PersonProfile>[];
final _PersonDraft? draft = await _showPersonEditor( final _PersonDraft? draft = await _showPersonEditor(
context, context,
title: 'Edit Person', title: 'Edit Person',
@@ -274,6 +281,8 @@ class PeopleView extends ConsumerWidget {
tags: person.tags, tags: person.tags,
location: person.location, location: person.location,
), ),
existingPeople: existingPeople,
editingPersonId: person.id,
); );
if (draft == null) { if (draft == null) {
@@ -624,6 +633,8 @@ class PeopleView extends ConsumerWidget {
BuildContext context, { BuildContext context, {
required String title, required String title,
_PersonDraft? initial, _PersonDraft? initial,
List<PersonProfile> existingPeople = const <PersonProfile>[],
String? editingPersonId,
}) { }) {
final bool compact = MediaQuery.sizeOf(context).width < 720; final bool compact = MediaQuery.sizeOf(context).width < 720;
if (compact) { if (compact) {
@@ -633,7 +644,12 @@ class PeopleView extends ConsumerWidget {
useSafeArea: true, useSafeArea: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (BuildContext context) { 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>( return showDialog<_PersonDraft>(
context: context, context: context,
builder: (BuildContext 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 { 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 String title;
final List<PersonProfile> existingPeople;
final _PersonDraft? initial; final _PersonDraft? initial;
final String? editingPersonId;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -1754,6 +1782,8 @@ class _PersonEditorDialog extends StatelessWidget {
child: _PersonEditorPanel( child: _PersonEditorPanel(
title: title, title: title,
initial: initial, initial: initial,
existingPeople: existingPeople,
editingPersonId: editingPersonId,
compact: false, compact: false,
sheetStyle: false, sheetStyle: false,
), ),
@@ -1763,10 +1793,17 @@ class _PersonEditorDialog extends StatelessWidget {
} }
class _PersonEditorBottomSheet 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 String title;
final List<PersonProfile> existingPeople;
final _PersonDraft? initial; final _PersonDraft? initial;
final String? editingPersonId;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -1779,6 +1816,8 @@ class _PersonEditorBottomSheet extends StatelessWidget {
child: _PersonEditorPanel( child: _PersonEditorPanel(
title: title, title: title,
initial: initial, initial: initial,
existingPeople: existingPeople,
editingPersonId: editingPersonId,
compact: true, compact: true,
sheetStyle: true, sheetStyle: true,
), ),
@@ -1791,13 +1830,17 @@ class _PersonEditorPanel extends StatefulWidget {
required this.title, required this.title,
required this.compact, required this.compact,
required this.sheetStyle, required this.sheetStyle,
required this.existingPeople,
this.initial, this.initial,
this.editingPersonId,
}); });
final String title; final String title;
final bool compact; final bool compact;
final bool sheetStyle; final bool sheetStyle;
final List<PersonProfile> existingPeople;
final _PersonDraft? initial; final _PersonDraft? initial;
final String? editingPersonId;
@override @override
State<_PersonEditorPanel> createState() => _PersonEditorPanelState(); State<_PersonEditorPanel> createState() => _PersonEditorPanelState();
@@ -1809,6 +1852,7 @@ class _PersonEditorPanelState extends State<_PersonEditorPanel> {
late final TextEditingController _locationController; late final TextEditingController _locationController;
late final TextEditingController _tagsController; late final TextEditingController _tagsController;
late final TextEditingController _notesController; late final TextEditingController _notesController;
bool _attemptedSubmit = false;
@override @override
void initState() { void initState() {
@@ -1855,6 +1899,8 @@ class _PersonEditorPanelState extends State<_PersonEditorPanel> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final String name = _nameController.text.trim(); final String name = _nameController.text.trim();
final String relationship = _relationshipController.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 final String? location = _locationController.text.trim().isEmpty
? null ? null
: _locationController.text.trim(); : _locationController.text.trim();
@@ -1863,6 +1909,12 @@ class _PersonEditorPanelState extends State<_PersonEditorPanel> {
.map((String tag) => tag.trim()) .map((String tag) => tag.trim())
.where((String tag) => tag.isNotEmpty) .where((String tag) => tag.isNotEmpty)
.toList(growable: false); .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 BorderRadius radius = BorderRadius.circular(widget.compact ? 24 : 22);
final double maxHeight = widget.compact ? 720 : 760; final double maxHeight = widget.compact ? 720 : 760;
@@ -1925,6 +1977,10 @@ class _PersonEditorPanelState extends State<_PersonEditorPanel> {
previewLocation: location, previewLocation: location,
previewTags: tags, previewTags: tags,
), ),
if (hasDuplicateWarning) ...<Widget>[
const SizedBox(height: 10),
_PersonEditorWarningBanner(matches: duplicateMatches),
],
const SizedBox(height: 12), const SizedBox(height: 12),
if (widget.compact) if (widget.compact)
Column( Column(
@@ -1937,15 +1993,21 @@ class _PersonEditorPanelState extends State<_PersonEditorPanel> {
TextField( TextField(
controller: _nameController, controller: _nameController,
autofocus: true, autofocus: true,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Name', labelText: 'Name',
errorText: nameMissing
? 'Name is required'
: null,
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
TextField( TextField(
controller: _relationshipController, controller: _relationshipController,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Relationship', labelText: 'Relationship',
errorText: relationshipMissing
? 'Relationship is required'
: null,
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
@@ -1999,15 +2061,21 @@ class _PersonEditorPanelState extends State<_PersonEditorPanel> {
TextField( TextField(
controller: _nameController, controller: _nameController,
autofocus: true, autofocus: true,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Name', labelText: 'Name',
errorText: nameMissing
? 'Name is required'
: null,
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
TextField( TextField(
controller: _relationshipController, controller: _relationshipController,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Relationship', labelText: 'Relationship',
errorText: relationshipMissing
? 'Relationship is required'
: null,
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
@@ -2092,6 +2160,9 @@ class _PersonEditorPanelState extends State<_PersonEditorPanel> {
_relationshipController.text.trim(); _relationshipController.text.trim();
if (submittedName.isEmpty || if (submittedName.isEmpty ||
submittedRelationship.isEmpty) { submittedRelationship.isEmpty) {
setState(() {
_attemptedSubmit = true;
});
return; 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 { class _PersonDraft {
const _PersonDraft({ const _PersonDraft({
required this.name, required this.name,
@@ -2274,6 +2395,34 @@ class _PersonDraft {
final String? location; 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 { class _MergePeopleDraft {
const _MergePeopleDraft({ const _MergePeopleDraft({
required this.sourcePersonId, required this.sourcePersonId,