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