From 4d21c6df828683a6e6d78d2726dfd214eb67a99d Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Sun, 15 Feb 2026 17:06:23 +0100 Subject: [PATCH] Make people view responsive on mobile --- docs/progress.md | 15 + ios/Podfile.lock | 7 + lib/features/people/people_view.dart | 453 ++++++++++++++++++--------- 3 files changed, 330 insertions(+), 145 deletions(-) diff --git a/docs/progress.md b/docs/progress.md index 40f53ca..698000f 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -2,6 +2,21 @@ Updated: 2026-02-15 +## Latest Milestone (2026-02-15): People Mobile Layout Fix + +Fixed the `People` view so it is mobile-friendly and no longer overflows on iOS narrow screens: + +- `lib/features/people/people_view.dart` + - added responsive breakpoint logic (`LayoutBuilder`) + - desktop keeps split-pane list/detail layout + - mobile now uses a single-column flow (header, people list, selected person detail) + - improved text safety with `maxLines`/`ellipsis` and compact detail action row + +Validation for this milestone: + +- `flutter analyze` -> pass +- `flutter test` -> pass + ## Latest Milestone (2026-02-15): Sync Queue + Orchestrator Implemented the next sync phase so local mutations are now queued and synced through a real coordinator flow: diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 37cd4a7..6159edd 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3,20 +3,27 @@ PODS: - flutter_secure_storage_darwin (10.0.0): - Flutter - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS DEPENDENCIES: - Flutter (from `Flutter`) - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) EXTERNAL SOURCES: Flutter: :path: Flutter flutter_secure_storage_darwin: :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/lib/features/people/people_view.dart b/lib/features/people/people_view.dart index 608e55d..ee2fc8a 100644 --- a/lib/features/people/people_view.dart +++ b/lib/features/people/people_view.dart @@ -46,121 +46,14 @@ class PeopleView extends ConsumerWidget { orElse: () => people.first, ); - return Padding( - padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), - child: Row( - children: [ - Expanded( - flex: 5, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'People', - style: Theme.of( - context, - ).textTheme.headlineMedium, - ), - const SizedBox(height: 6), - Text( - 'Track what matters to each relationship and follow through.', - style: Theme.of(context).textTheme.bodyLarge - ?.copyWith(color: AppTheme.textSecondary), - ), - ], - ), - ), - FilledButton.icon( - onPressed: () => _handleAddPerson(context, ref), - icon: const Icon(Icons.person_add_alt_1_rounded), - label: const Text('Add'), - ), - ], - ), - const SizedBox(height: 20), - Expanded( - child: ListView.separated( - itemCount: people.length, - separatorBuilder: (_, _) => const SizedBox(height: 12), - itemBuilder: (BuildContext context, int index) { - final PersonProfile person = people[index]; - final bool isSelected = person.id == selected.id; - return InkWell( - onTap: () { - ref - .read(selectedPersonIdProvider.notifier) - .select(person.id); - }, - borderRadius: BorderRadius.circular(20), - child: FrostedCard( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - _AvatarSeed(name: person.name), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - person.name, - style: Theme.of( - context, - ).textTheme.titleMedium, - ), - const SizedBox(height: 4), - Text( - person.relationship, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - color: AppTheme.textSecondary, - ), - ), - ], - ), - ), - _ScorePill(score: person.affinityScore), - if (isSelected) - const Padding( - padding: EdgeInsets.only(left: 10), - child: Icon( - Icons.check_circle, - color: Color(0xFF1AB6C8), - ), - ), - ], - ), - ), - ); - }, - ), - ), - ], - ), - ), - const SizedBox(width: 18), - Expanded( - flex: 6, - child: FrostedCard( - child: _PersonDetail( - person: selected, - onEdit: () => _handleEditPerson(context, ref, selected), - onDelete: () => - _handleDeletePerson(context, ref, selected.id), - ), - ), - ), - ], - ), + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool isCompact = constraints.maxWidth < 860; + if (isCompact) { + return _buildCompactLayout(context, ref, people, selected); + } + return _buildWideLayout(context, ref, people, selected); + }, ); }, loading: () => const Center(child: CircularProgressIndicator()), @@ -259,6 +152,104 @@ class PeopleView extends ConsumerWidget { await ref.read(localRepositoryProvider.notifier).deletePerson(personId); ref.read(selectedPersonIdProvider.notifier).clear(); } + + Widget _buildWideLayout( + BuildContext context, + WidgetRef ref, + List people, + PersonProfile selected, + ) { + return Padding( + padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), + child: Row( + children: [ + Expanded( + flex: 5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _PeopleHeader( + onAdd: () => _handleAddPerson(context, ref), + compact: false, + ), + const SizedBox(height: 20), + Expanded( + child: ListView.separated( + itemCount: people.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (BuildContext context, int index) { + final PersonProfile person = people[index]; + return _PersonListItem( + person: person, + selectedId: selected.id, + onSelect: () { + ref + .read(selectedPersonIdProvider.notifier) + .select(person.id); + }, + ); + }, + ), + ), + ], + ), + ), + const SizedBox(width: 18), + Expanded( + flex: 6, + child: FrostedCard( + child: _PersonDetail( + person: selected, + onEdit: () => _handleEditPerson(context, ref, selected), + onDelete: () => _handleDeletePerson(context, ref, selected.id), + ), + ), + ), + ], + ), + ); + } + + Widget _buildCompactLayout( + BuildContext context, + WidgetRef ref, + List people, + PersonProfile selected, + ) { + return Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 20), + child: ListView( + children: [ + _PeopleHeader( + onAdd: () => _handleAddPerson(context, ref), + compact: true, + ), + const SizedBox(height: 14), + ...people.map( + (PersonProfile person) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _PersonListItem( + person: person, + selectedId: selected.id, + onSelect: () { + ref.read(selectedPersonIdProvider.notifier).select(person.id); + }, + ), + ), + ), + const SizedBox(height: 8), + FrostedCard( + child: _PersonDetail( + person: selected, + compact: true, + onEdit: () => _handleEditPerson(context, ref, selected), + onDelete: () => _handleDeletePerson(context, ref, selected.id), + ), + ), + ], + ), + ); + } } class _EmptyPeopleView extends StatelessWidget { @@ -303,52 +294,107 @@ class _PersonDetail extends StatelessWidget { required this.person, required this.onEdit, required this.onDelete, + this.compact = false, }); final PersonProfile person; final VoidCallback onEdit; final VoidCallback onDelete; + final bool compact; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - _AvatarSeed(name: person.name, size: 64), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + if (compact) + Column( + children: [ + Row( children: [ - Text( - person.name, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 4), - Text( - person.relationship, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - color: AppTheme.textSecondary, + _AvatarSeed(name: person.name, size: 56), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + person.name, + style: Theme.of(context).textTheme.titleLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + person.relationship, + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: AppTheme.textSecondary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ), ), ], ), - ), - IconButton( - onPressed: onEdit, - icon: const Icon(Icons.edit_rounded), - tooltip: 'Edit person', - ), - IconButton( - onPressed: onDelete, - icon: const Icon(Icons.delete_outline_rounded), - tooltip: 'Delete person', - ), - ], - ), - const SizedBox(height: 22), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + onPressed: onEdit, + icon: const Icon(Icons.edit_rounded), + tooltip: 'Edit person', + ), + IconButton( + onPressed: onDelete, + icon: const Icon(Icons.delete_outline_rounded), + tooltip: 'Delete person', + ), + ], + ), + ], + ) + else + Row( + children: [ + _AvatarSeed(name: person.name, size: 64), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + person.name, + style: Theme.of(context).textTheme.titleLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + person.relationship, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: AppTheme.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + IconButton( + onPressed: onEdit, + icon: const Icon(Icons.edit_rounded), + tooltip: 'Edit person', + ), + IconButton( + onPressed: onDelete, + icon: const Icon(Icons.delete_outline_rounded), + tooltip: 'Delete person', + ), + ], + ), + SizedBox(height: compact ? 14 : 22), Text('Next moment', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), _DetailTag( @@ -379,6 +425,123 @@ class _PersonDetail extends StatelessWidget { } } +class _PeopleHeader extends StatelessWidget { + const _PeopleHeader({required this.onAdd, required this.compact}); + + final VoidCallback onAdd; + final bool compact; + + @override + Widget build(BuildContext context) { + final TextTheme textTheme = Theme.of(context).textTheme; + if (compact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('People', style: textTheme.headlineMedium), + const SizedBox(height: 6), + Text( + 'Track what matters to each relationship and follow through.', + style: textTheme.bodyLarge?.copyWith(color: AppTheme.textSecondary), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.person_add_alt_1_rounded), + label: const Text('Add person'), + ), + ], + ); + } + + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('People', style: textTheme.headlineMedium), + const SizedBox(height: 6), + Text( + 'Track what matters to each relationship and follow through.', + style: textTheme.bodyLarge?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ], + ), + ), + FilledButton.icon( + onPressed: onAdd, + icon: const Icon(Icons.person_add_alt_1_rounded), + label: const Text('Add'), + ), + ], + ); + } +} + +class _PersonListItem extends StatelessWidget { + const _PersonListItem({ + required this.person, + required this.selectedId, + required this.onSelect, + }); + + final PersonProfile person; + final String selectedId; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + final bool isSelected = person.id == selectedId; + return InkWell( + onTap: onSelect, + borderRadius: BorderRadius.circular(20), + child: FrostedCard( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + _AvatarSeed(name: person.name), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + person.name, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + person.relationship, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppTheme.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + _ScorePill(score: person.affinityScore), + if (isSelected) + const Padding( + padding: EdgeInsets.only(left: 10), + child: Icon(Icons.check_circle, color: Color(0xFF1AB6C8)), + ), + ], + ), + ), + ); + } +} + class _PersonEditorDialog extends StatefulWidget { const _PersonEditorDialog({required this.title, this.initial});