diff --git a/docs/progress.md b/docs/progress.md index f3a1952..c3e5819 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -7,6 +7,25 @@ Updated: 2026-02-22 - 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. +## 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) Aligned the People profile creation/edit flow with the newer insights-style UI diff --git a/lib/features/people/people_view.dart b/lib/features/people/people_view.dart index 70c759e..e2c0862 100644 --- a/lib/features/people/people_view.dart +++ b/lib/features/people/people_view.dart @@ -221,9 +221,13 @@ class PeopleView extends ConsumerWidget { } Future _handleAddPerson(BuildContext context, WidgetRef ref) async { + final List existingPeople = + ref.read(localRepositoryProvider).asData?.value.people ?? + const []; 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 existingPeople = + ref.read(localRepositoryProvider).asData?.value.people ?? + const []; 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 existingPeople = const [], + 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 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 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 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 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) ...[ + 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 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: [ + 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 _findDuplicateNameMatches({ + required String name, + required List people, + String? excludePersonId, +}) { + final String normalized = _normalizePersonNameKey(name); + if (normalized.isEmpty) { + return const []; + } + + 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,