From 02332885067e448514ce511c2eadea485017b0b4 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sun, 22 Feb 2026 23:04:04 +0100 Subject: [PATCH] Add responsive people profile editor flow --- docs/progress.md | 22 + lib/features/people/people_view.dart | 592 +++++++++++++++++++++++---- 2 files changed, 531 insertions(+), 83 deletions(-) diff --git a/docs/progress.md b/docs/progress.md index 280db95..f3a1952 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -7,6 +7,28 @@ 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): 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) Reworked the `People` page to feel closer to the graph long-press `Person diff --git a/lib/features/people/people_view.dart b/lib/features/people/people_view.dart index 4eb4240..70c759e 100644 --- a/lib/features/people/people_view.dart +++ b/lib/features/people/people_view.dart @@ -221,10 +221,9 @@ class PeopleView extends ConsumerWidget { } Future _handleAddPerson(BuildContext context, WidgetRef ref) async { - final _PersonDraft? draft = await showDialog<_PersonDraft>( - context: context, - builder: (BuildContext context) => - const _PersonEditorDialog(title: 'Add Person'), + final _PersonDraft? draft = await _showPersonEditor( + context, + title: 'Add Person', ); if (draft == null) { @@ -265,17 +264,15 @@ class PeopleView extends ConsumerWidget { WidgetRef ref, PersonProfile person, ) async { - final _PersonDraft? draft = await showDialog<_PersonDraft>( - context: context, - builder: (BuildContext context) => _PersonEditorDialog( - title: 'Edit Person', - initial: _PersonDraft( - name: person.name, - relationship: person.relationship, - notes: person.notes, - tags: person.tags, - location: person.location, - ), + final _PersonDraft? draft = await _showPersonEditor( + context, + title: 'Edit Person', + initial: _PersonDraft( + name: person.name, + relationship: person.relationship, + notes: person.notes, + tags: person.tags, + location: person.location, ), ); @@ -623,6 +620,32 @@ class PeopleView extends ConsumerWidget { .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 _showQuickTextCaptureDialog( BuildContext context, { 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}); final String title; final _PersonDraft? initial; @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 _relationshipController; late final TextEditingController _locationController; @@ -1735,99 +1813,447 @@ class _PersonEditorDialogState extends State<_PersonEditorDialog> { @override void initState() { super.initState(); - _nameController = TextEditingController(text: widget.initial?.name ?? ''); + _nameController = TextEditingController(text: widget.initial?.name ?? '') + ..addListener(_handlePreviewChanged); _relationshipController = TextEditingController( text: widget.initial?.relationship ?? '', - ); + )..addListener(_handlePreviewChanged); _locationController = TextEditingController( text: widget.initial?.location ?? '', - ); + )..addListener(_handlePreviewChanged); _tagsController = TextEditingController( text: widget.initial?.tags.join(', ') ?? '', - ); + )..addListener(_handlePreviewChanged); _notesController = TextEditingController(text: widget.initial?.notes ?? ''); } @override void dispose() { - _nameController.dispose(); - _relationshipController.dispose(); - _locationController.dispose(); - _tagsController.dispose(); + _nameController + ..removeListener(_handlePreviewChanged) + ..dispose(); + _relationshipController + ..removeListener(_handlePreviewChanged) + ..dispose(); + _locationController + ..removeListener(_handlePreviewChanged) + ..dispose(); + _tagsController + ..removeListener(_handlePreviewChanged) + ..dispose(); _notesController.dispose(); super.dispose(); } + void _handlePreviewChanged() { + if (mounted) { + setState(() {}); + } + } + @override Widget build(BuildContext context) { - return AlertDialog( - title: Text(widget.title), - content: SingleChildScrollView( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), + final String name = _nameController.text.trim(); + final String relationship = _relationshipController.text.trim(); + final String? location = _locationController.text.trim().isEmpty + ? null + : _locationController.text.trim(); + final List 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(0xFFF7FBFD), Color(0xFFEAF3F9)], + ), + border: Border.all(color: Colors.white.withValues(alpha: 0.95)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 26, + offset: const Offset(0, 14), + ), + ], + ), + child: ClipRRect( + borderRadius: radius, child: Column( - mainAxisSize: MainAxisSize.min, children: [ - TextField( - controller: _nameController, - decoration: const InputDecoration(labelText: 'Name'), - ), - TextField( - controller: _relationshipController, - decoration: const InputDecoration(labelText: 'Relationship'), - ), - TextField( - controller: _locationController, - decoration: const InputDecoration( - labelText: 'Location (optional)', + 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: [ + _PersonEditorHero( + title: widget.title, + compact: widget.compact, + previewName: name, + previewRelationship: relationship, + previewLocation: location, + previewTags: tags, + ), + const SizedBox(height: 12), + if (widget.compact) + Column( + children: [ + _PersonEditorSectionCard( + title: 'Identity', + icon: Icons.person_outline_rounded, + child: Column( + children: [ + 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)', + ), + ), + ], + ), + ), + const SizedBox(height: 10), + _PersonEditorSectionCard( + title: 'Context', + icon: Icons.auto_awesome_rounded, + child: Column( + children: [ + TextField( + controller: _tagsController, + decoration: const InputDecoration( + labelText: 'Tags (comma separated)', + ), + ), + const SizedBox(height: 10), + TextField( + controller: _notesController, + minLines: 3, + maxLines: 5, + decoration: const InputDecoration( + labelText: 'Notes', + hintText: + 'Preferences, boundaries, gift clues, recurring themes...', + ), + ), + ], + ), + ), + ], + ) + else + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _PersonEditorSectionCard( + title: 'Identity', + icon: Icons.person_outline_rounded, + child: Column( + children: [ + 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)', + ), + ), + ], + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _PersonEditorSectionCard( + title: 'Context', + icon: Icons.auto_awesome_rounded, + child: Column( + children: [ + 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...', + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), ), ), - TextField( - controller: _tagsController, - decoration: const InputDecoration( - labelText: 'Tags (comma separated)', + 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: [ + Expanded( + child: Text( + 'Required: name and relationship', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const SizedBox(width: 8), + FilledButton( + onPressed: () { + final String submittedName = _nameController.text + .trim(); + final String submittedRelationship = + _relationshipController.text.trim(); + if (submittedName.isEmpty || + submittedRelationship.isEmpty) { + return; + } + + final _PersonDraft draft = _PersonDraft( + name: submittedName, + relationship: submittedRelationship, + location: _locationController.text.trim().isEmpty + ? null + : _locationController.text.trim(), + notes: _notesController.text.trim(), + tags: _tagsController.text + .split(',') + .map((String tag) => tag.trim()) + .where((String tag) => tag.isNotEmpty) + .toList(growable: false), + ); + Navigator.of(context).pop(draft); + }, + child: const Text('Save'), + ), + ], ), - ), - TextField( - controller: _notesController, - maxLines: 3, - decoration: const InputDecoration(labelText: 'Notes'), ), ], ), ), ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - final String name = _nameController.text.trim(); - final String relationship = _relationshipController.text.trim(); - if (name.isEmpty || relationship.isEmpty) { - return; - } + ); + } +} - final _PersonDraft draft = _PersonDraft( - name: name, - relationship: relationship, - location: _locationController.text.trim().isEmpty - ? null - : _locationController.text.trim(), - notes: _notesController.text.trim(), - tags: _tagsController.text - .split(',') - .map((String tag) => tag.trim()) - .where((String tag) => tag.isNotEmpty) - .toList(growable: false), - ); - Navigator.of(context).pop(draft); - }, - 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 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: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AvatarSeed(name: displayName, size: compact ? 50 : 56), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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: [ + _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: [ + Row( + children: [ + 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, + ], + ), ); } }