Add responsive people profile editor flow

This commit is contained in:
Rijad Zuzo
2026-02-22 23:04:04 +01:00
parent c281fd8d3d
commit 0233288506
2 changed files with 531 additions and 83 deletions
+22
View File
@@ -7,6 +7,28 @@ 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): Responsive People Add/Edit Form (Sheet + Dialog)
Aligned the People profile creation/edit flow with the newer insights-style UI
language and improved platform-specific presentation behavior.
- `lib/features/people/people_view.dart`
- replaced the old `AlertDialog` person editor with a shared responsive form
panel:
- mobile (`<720px`): `showModalBottomSheet` with a rounded sheet layout
- desktop/web/tablet: larger custom dialog panel
- added richer form UX with:
- hero preview (avatar + live preview chips for name/relationship/location/tags)
- grouped sections (`Identity`, `Context`) matching the People/Insights card
style
- preserved existing labels and `Save` action text for test compatibility
- kept add/edit repository behavior unchanged (local-first)
- fixed narrow-width section header overflow discovered by widget test
- Validation
- `flutter analyze` -> pass
- `flutter test` -> pass
## Latest Milestone (2026-02-22): People Page UX Redesign (Insights-Aligned) ## Latest Milestone (2026-02-22): People Page UX Redesign (Insights-Aligned)
Reworked the `People` page to feel closer to the graph long-press `Person Reworked the `People` page to feel closer to the graph long-press `Person
+461 -35
View File
@@ -221,10 +221,9 @@ class PeopleView extends ConsumerWidget {
} }
Future<void> _handleAddPerson(BuildContext context, WidgetRef ref) async { Future<void> _handleAddPerson(BuildContext context, WidgetRef ref) async {
final _PersonDraft? draft = await showDialog<_PersonDraft>( final _PersonDraft? draft = await _showPersonEditor(
context: context, context,
builder: (BuildContext context) => title: 'Add Person',
const _PersonEditorDialog(title: 'Add Person'),
); );
if (draft == null) { if (draft == null) {
@@ -265,9 +264,8 @@ class PeopleView extends ConsumerWidget {
WidgetRef ref, WidgetRef ref,
PersonProfile person, PersonProfile person,
) async { ) async {
final _PersonDraft? draft = await showDialog<_PersonDraft>( final _PersonDraft? draft = await _showPersonEditor(
context: context, context,
builder: (BuildContext context) => _PersonEditorDialog(
title: 'Edit Person', title: 'Edit Person',
initial: _PersonDraft( initial: _PersonDraft(
name: person.name, name: person.name,
@@ -276,7 +274,6 @@ class PeopleView extends ConsumerWidget {
tags: person.tags, tags: person.tags,
location: person.location, location: person.location,
), ),
),
); );
if (draft == null) { if (draft == null) {
@@ -623,6 +620,32 @@ class PeopleView extends ConsumerWidget {
.replaceAll(RegExp(r'\s+'), ' '); .replaceAll(RegExp(r'\s+'), ' ');
} }
Future<_PersonDraft?> _showPersonEditor(
BuildContext context, {
required String title,
_PersonDraft? initial,
}) {
final bool compact = MediaQuery.sizeOf(context).width < 720;
if (compact) {
return showModalBottomSheet<_PersonDraft>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (BuildContext context) {
return _PersonEditorBottomSheet(title: title, initial: initial);
},
);
}
return showDialog<_PersonDraft>(
context: context,
builder: (BuildContext context) {
return _PersonEditorDialog(title: title, initial: initial);
},
);
}
Future<String?> _showQuickTextCaptureDialog( Future<String?> _showQuickTextCaptureDialog(
BuildContext context, { BuildContext context, {
required String title, required String title,
@@ -1715,17 +1738,72 @@ class _PersonListItem extends StatelessWidget {
} }
} }
class _PersonEditorDialog extends StatefulWidget { class _PersonEditorDialog extends StatelessWidget {
const _PersonEditorDialog({required this.title, this.initial}); const _PersonEditorDialog({required this.title, this.initial});
final String title; final String title;
final _PersonDraft? initial; final _PersonDraft? initial;
@override @override
State<_PersonEditorDialog> createState() => _PersonEditorDialogState(); Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 720),
child: _PersonEditorPanel(
title: title,
initial: initial,
compact: false,
sheetStyle: false,
),
),
);
}
} }
class _PersonEditorDialogState extends State<_PersonEditorDialog> { class _PersonEditorBottomSheet extends StatelessWidget {
const _PersonEditorBottomSheet({required this.title, this.initial});
final String title;
final _PersonDraft? initial;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
left: 8,
right: 8,
bottom: MediaQuery.viewInsetsOf(context).bottom + 8,
),
child: _PersonEditorPanel(
title: title,
initial: initial,
compact: true,
sheetStyle: true,
),
);
}
}
class _PersonEditorPanel extends StatefulWidget {
const _PersonEditorPanel({
required this.title,
required this.compact,
required this.sheetStyle,
this.initial,
});
final String title;
final bool compact;
final bool sheetStyle;
final _PersonDraft? initial;
@override
State<_PersonEditorPanel> createState() => _PersonEditorPanelState();
}
class _PersonEditorPanelState extends State<_PersonEditorPanel> {
late final TextEditingController _nameController; late final TextEditingController _nameController;
late final TextEditingController _relationshipController; late final TextEditingController _relationshipController;
late final TextEditingController _locationController; late final TextEditingController _locationController;
@@ -1735,84 +1813,291 @@ class _PersonEditorDialogState extends State<_PersonEditorDialog> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_nameController = TextEditingController(text: widget.initial?.name ?? ''); _nameController = TextEditingController(text: widget.initial?.name ?? '')
..addListener(_handlePreviewChanged);
_relationshipController = TextEditingController( _relationshipController = TextEditingController(
text: widget.initial?.relationship ?? '', text: widget.initial?.relationship ?? '',
); )..addListener(_handlePreviewChanged);
_locationController = TextEditingController( _locationController = TextEditingController(
text: widget.initial?.location ?? '', text: widget.initial?.location ?? '',
); )..addListener(_handlePreviewChanged);
_tagsController = TextEditingController( _tagsController = TextEditingController(
text: widget.initial?.tags.join(', ') ?? '', text: widget.initial?.tags.join(', ') ?? '',
); )..addListener(_handlePreviewChanged);
_notesController = TextEditingController(text: widget.initial?.notes ?? ''); _notesController = TextEditingController(text: widget.initial?.notes ?? '');
} }
@override @override
void dispose() { void dispose() {
_nameController.dispose(); _nameController
_relationshipController.dispose(); ..removeListener(_handlePreviewChanged)
_locationController.dispose(); ..dispose();
_tagsController.dispose(); _relationshipController
..removeListener(_handlePreviewChanged)
..dispose();
_locationController
..removeListener(_handlePreviewChanged)
..dispose();
_tagsController
..removeListener(_handlePreviewChanged)
..dispose();
_notesController.dispose(); _notesController.dispose();
super.dispose(); super.dispose();
} }
void _handlePreviewChanged() {
if (mounted) {
setState(() {});
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( final String name = _nameController.text.trim();
title: Text(widget.title), final String relationship = _relationshipController.text.trim();
content: SingleChildScrollView( final String? location = _locationController.text.trim().isEmpty
child: ConstrainedBox( ? null
constraints: const BoxConstraints(maxWidth: 420), : _locationController.text.trim();
final List<String> tags = _tagsController.text
.split(',')
.map((String tag) => tag.trim())
.where((String tag) => tag.isNotEmpty)
.toList(growable: false);
final BorderRadius radius = BorderRadius.circular(widget.compact ? 24 : 22);
final double maxHeight = widget.compact ? 720 : 760;
return Material(
color: Colors.transparent,
child: Container(
constraints: BoxConstraints(
maxHeight: maxHeight,
minHeight: widget.compact ? 360 : 420,
),
decoration: BoxDecoration(
borderRadius: radius,
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[Color(0xFFF7FBFD), Color(0xFFEAF3F9)],
),
border: Border.all(color: Colors.white.withValues(alpha: 0.95)),
boxShadow: <BoxShadow>[
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 26,
offset: const Offset(0, 14),
),
],
),
child: ClipRRect(
borderRadius: radius,
child: Column(
children: <Widget>[
if (widget.sheetStyle)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Container(
width: 42,
height: 4,
decoration: BoxDecoration(
color: const Color(0xFFCCD6DD),
borderRadius: BorderRadius.circular(99),
),
),
),
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
widget.compact ? 14 : 18,
widget.compact ? 10 : 16,
widget.compact ? 14 : 18,
12,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_PersonEditorHero(
title: widget.title,
compact: widget.compact,
previewName: name,
previewRelationship: relationship,
previewLocation: location,
previewTags: tags,
),
const SizedBox(height: 12),
if (widget.compact)
Column(
children: <Widget>[
_PersonEditorSectionCard(
title: 'Identity',
icon: Icons.person_outline_rounded,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
TextField( TextField(
controller: _nameController, controller: _nameController,
decoration: const InputDecoration(labelText: 'Name'), autofocus: true,
decoration: const InputDecoration(
labelText: 'Name',
), ),
),
const SizedBox(height: 10),
TextField( TextField(
controller: _relationshipController, controller: _relationshipController,
decoration: const InputDecoration(labelText: 'Relationship'), decoration: const InputDecoration(
labelText: 'Relationship',
), ),
),
const SizedBox(height: 10),
TextField( TextField(
controller: _locationController, controller: _locationController,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Location (optional)', labelText: 'Location (optional)',
), ),
), ),
],
),
),
const SizedBox(height: 10),
_PersonEditorSectionCard(
title: 'Context',
icon: Icons.auto_awesome_rounded,
child: Column(
children: <Widget>[
TextField( TextField(
controller: _tagsController, controller: _tagsController,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Tags (comma separated)', labelText: 'Tags (comma separated)',
), ),
), ),
const SizedBox(height: 10),
TextField( TextField(
controller: _notesController, controller: _notesController,
maxLines: 3, minLines: 3,
decoration: const InputDecoration(labelText: 'Notes'), maxLines: 5,
decoration: const InputDecoration(
labelText: 'Notes',
hintText:
'Preferences, boundaries, gift clues, recurring themes...',
),
),
],
),
),
],
)
else
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: _PersonEditorSectionCard(
title: 'Identity',
icon: Icons.person_outline_rounded,
child: Column(
children: <Widget>[
TextField(
controller: _nameController,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Name',
),
),
const SizedBox(height: 10),
TextField(
controller: _relationshipController,
decoration: const InputDecoration(
labelText: 'Relationship',
),
),
const SizedBox(height: 10),
TextField(
controller: _locationController,
decoration: const InputDecoration(
labelText: 'Location (optional)',
),
), ),
], ],
), ),
), ),
), ),
actions: <Widget>[ const SizedBox(width: 12),
Expanded(
child: _PersonEditorSectionCard(
title: 'Context',
icon: Icons.auto_awesome_rounded,
child: Column(
children: <Widget>[
TextField(
controller: _tagsController,
decoration: const InputDecoration(
labelText: 'Tags (comma separated)',
),
),
const SizedBox(height: 10),
TextField(
controller: _notesController,
minLines: 5,
maxLines: 8,
decoration: const InputDecoration(
labelText: 'Notes',
hintText:
'Preferences, boundaries, gift clues, recurring themes...',
),
),
],
),
),
),
],
),
],
),
),
),
Container(
padding: EdgeInsets.fromLTRB(
widget.compact ? 14 : 18,
10,
widget.compact ? 14 : 18,
widget.compact ? 14 : 16,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.75),
border: Border(
top: BorderSide(color: Colors.white.withValues(alpha: 0.9)),
),
),
child: Row(
children: <Widget>[
Expanded(
child: Text(
'Required: name and relationship',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppTheme.textSecondary,
),
),
),
const SizedBox(width: 8),
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
const SizedBox(width: 8),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
final String name = _nameController.text.trim(); final String submittedName = _nameController.text
final String relationship = _relationshipController.text.trim(); .trim();
if (name.isEmpty || relationship.isEmpty) { final String submittedRelationship =
_relationshipController.text.trim();
if (submittedName.isEmpty ||
submittedRelationship.isEmpty) {
return; return;
} }
final _PersonDraft draft = _PersonDraft( final _PersonDraft draft = _PersonDraft(
name: name, name: submittedName,
relationship: relationship, relationship: submittedRelationship,
location: _locationController.text.trim().isEmpty location: _locationController.text.trim().isEmpty
? null ? null
: _locationController.text.trim(), : _locationController.text.trim(),
@@ -1828,6 +2113,147 @@ class _PersonEditorDialogState extends State<_PersonEditorDialog> {
child: const Text('Save'), child: const Text('Save'),
), ),
], ],
),
),
],
),
),
),
);
}
}
class _PersonEditorHero extends StatelessWidget {
const _PersonEditorHero({
required this.title,
required this.compact,
required this.previewName,
required this.previewRelationship,
required this.previewLocation,
required this.previewTags,
});
final String title;
final bool compact;
final String previewName;
final String previewRelationship;
final String? previewLocation;
final List<String> previewTags;
@override
Widget build(BuildContext context) {
final String displayName = previewName.isEmpty ? 'New person' : previewName;
final String displayRelationship = previewRelationship.isEmpty
? 'Relationship type'
: previewRelationship;
return Container(
width: double.infinity,
padding: EdgeInsets.all(compact ? 14 : 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: Colors.white.withValues(alpha: 0.72),
border: Border.all(color: Colors.white),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_AvatarSeed(name: displayName, size: compact ? 50 : 56),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 4),
Text(
'Shape the profile first, then use quick-add from the People workspace.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppTheme.textSecondary,
),
),
],
),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
_InlineMetaPill(
icon: Icons.person_outline_rounded,
label: displayName,
),
_InlineMetaPill(
icon: Icons.favorite_outline_rounded,
label: displayRelationship,
),
if (previewLocation != null)
_InlineMetaPill(
icon: Icons.location_on_outlined,
label: previewLocation!,
),
...previewTags
.take(compact ? 2 : 4)
.map(
(String tag) =>
_InlineMetaPill(icon: Icons.sell_outlined, label: tag),
),
],
),
],
),
);
}
}
class _PersonEditorSectionCard extends StatelessWidget {
const _PersonEditorSectionCard({
required this.title,
required this.icon,
required this.child,
});
final String title;
final IconData icon;
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.78),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Icon(icon, size: 18, color: AppTheme.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
const SizedBox(height: 10),
child,
],
),
); );
} }
} }