Make people view responsive on mobile

This commit is contained in:
Rijad Zuzo
2026-02-15 17:06:23 +01:00
parent d711f270dd
commit 4d21c6df82
3 changed files with 330 additions and 145 deletions
+15
View File
@@ -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:
+7
View File
@@ -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
+308 -145
View File
@@ -46,121 +46,14 @@ class PeopleView extends ConsumerWidget {
orElse: () => people.first,
);
return Padding(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
child: Row(
children: <Widget>[
Expanded(
flex: 5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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: <Widget>[
_AvatarSeed(name: person.name),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: <Widget>[
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<PersonProfile> people,
PersonProfile selected,
) {
return Padding(
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
child: Row(
children: <Widget>[
Expanded(
flex: 5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_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<PersonProfile> people,
PersonProfile selected,
) {
return Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 20),
child: ListView(
children: <Widget>[
_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: <Widget>[
Row(
children: <Widget>[
_AvatarSeed(name: person.name, size: 64),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
if (compact)
Column(
children: <Widget>[
Row(
children: <Widget>[
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: <Widget>[
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: <Widget>[
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: <Widget>[
_AvatarSeed(name: person.name, size: 64),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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: <Widget>[
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: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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: <Widget>[
_AvatarSeed(name: person.name),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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});