Make people view responsive on mobile
This commit is contained in:
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
Updated: 2026-02-15
|
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
|
## 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:
|
Implemented the next sync phase so local mutations are now queued and synced through a real coordinator flow:
|
||||||
|
|||||||
@@ -3,20 +3,27 @@ PODS:
|
|||||||
- flutter_secure_storage_darwin (10.0.0):
|
- flutter_secure_storage_darwin (10.0.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- shared_preferences_foundation (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
||||||
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
flutter_secure_storage_darwin:
|
flutter_secure_storage_darwin:
|
||||||
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
|
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
|
||||||
|
shared_preferences_foundation:
|
||||||
|
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
|
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
|
||||||
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
|
|
||||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||||
|
|
||||||
|
|||||||
@@ -46,121 +46,14 @@ class PeopleView extends ConsumerWidget {
|
|||||||
orElse: () => people.first,
|
orElse: () => people.first,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Padding(
|
return LayoutBuilder(
|
||||||
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
|
builder: (BuildContext context, BoxConstraints constraints) {
|
||||||
child: Row(
|
final bool isCompact = constraints.maxWidth < 860;
|
||||||
children: <Widget>[
|
if (isCompact) {
|
||||||
Expanded(
|
return _buildCompactLayout(context, ref, people, selected);
|
||||||
flex: 5,
|
}
|
||||||
child: Column(
|
return _buildWideLayout(context, ref, people, selected);
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
@@ -259,6 +152,104 @@ class PeopleView extends ConsumerWidget {
|
|||||||
await ref.read(localRepositoryProvider.notifier).deletePerson(personId);
|
await ref.read(localRepositoryProvider.notifier).deletePerson(personId);
|
||||||
ref.read(selectedPersonIdProvider.notifier).clear();
|
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 {
|
class _EmptyPeopleView extends StatelessWidget {
|
||||||
@@ -303,17 +294,68 @@ class _PersonDetail extends StatelessWidget {
|
|||||||
required this.person,
|
required this.person,
|
||||||
required this.onEdit,
|
required this.onEdit,
|
||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
|
this.compact = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final PersonProfile person;
|
final PersonProfile person;
|
||||||
final VoidCallback onEdit;
|
final VoidCallback onEdit;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
|
final bool compact;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
|
if (compact)
|
||||||
|
Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
_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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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(
|
Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
_AvatarSeed(name: person.name, size: 64),
|
_AvatarSeed(name: person.name, size: 64),
|
||||||
@@ -325,6 +367,8 @@ class _PersonDetail extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
person.name,
|
person.name,
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
@@ -332,6 +376,8 @@ class _PersonDetail extends StatelessWidget {
|
|||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: AppTheme.textSecondary,
|
color: AppTheme.textSecondary,
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -348,7 +394,7 @@ class _PersonDetail extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 22),
|
SizedBox(height: compact ? 14 : 22),
|
||||||
Text('Next moment', style: Theme.of(context).textTheme.titleMedium),
|
Text('Next moment', style: Theme.of(context).textTheme.titleMedium),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_DetailTag(
|
_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 {
|
class _PersonEditorDialog extends StatefulWidget {
|
||||||
const _PersonEditorDialog({required this.title, this.initial});
|
const _PersonEditorDialog({required this.title, this.initial});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user