feat(todo): Display's layouts as pictures with radio buttons, its rows grouped in rounded boxes

As Todoist's (the developer's review of 3 Nov 2026, ADR 0033, D22): each
layout a phone's outline with its picture, its name below on one line (no
"Calenda/r" split across two), a radio button marking the one in use; the
switches, the sorting and the filters each a group in a rounded box under a
plain heading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-03 21:44:52 +01:00
commit c7ad78f88d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 248 additions and 126 deletions

View file

@ -236,9 +236,7 @@ class const _DisplaySheet({required final DisplayTarget target})
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(
text,
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
),
style: theme.textTheme.titleSmall,
),
);
Widget choice(
@ -259,123 +257,135 @@ class const _DisplaySheet({required final DisplayTarget target})
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
heading(l10n.layoutTitle),
...target.layout(context, state, apply),
SwitchListTile(
secondary: const Icon(LucideIcons.circleCheck),
title: Text(l10n.displayCompleted),
value: view.completed,
onChanged: (on) => set(view.copyWith(completed: on)),
_Group(children: target.layout(context, state, apply)),
_Group(
children: [
SwitchListTile(
secondary: const Icon(LucideIcons.circleCheck),
title: Text(l10n.displayCompleted),
value: view.completed,
onChanged: (on) => set(view.copyWith(completed: on)),
),
if (target.occurrences(state))
SwitchListTile(
secondary: const Icon(LucideIcons.repeat),
title: Text(l10n.displayOccurrences),
value: view.occurrences,
onChanged: (on) => set(view.copyWith(occurrences: on)),
),
],
),
if (target.occurrences(state))
SwitchListTile(
secondary: const Icon(LucideIcons.repeat),
title: Text(l10n.displayOccurrences),
value: view.occurrences,
onChanged: (on) => set(view.copyWith(occurrences: on)),
),
heading(l10n.displaySort),
if (target.grouping(state))
choice(
LucideIcons.group,
l10n.displayGrouping,
groupingText(l10n, view.grouping),
() async {
if (await _pickOne(
context,
l10n.displayGrouping,
TaskGrouping.values,
view.grouping,
(grouping) => groupingText(l10n, grouping),
)
case final grouping?) {
await set(view.copyWith(grouping: grouping));
}
},
),
choice(
LucideIcons.arrowUpDown,
l10n.displaySorting,
sortingName(view.sorting),
() async {
if (await _pickOne(
context,
l10n.displaySorting,
target.sortings,
view.sorting,
sortingName,
)
case final sorting?) {
await set(view.copyWith(sorting: sorting));
}
},
_Group(
children: [
if (target.grouping(state))
choice(
LucideIcons.group,
l10n.displayGrouping,
groupingText(l10n, view.grouping),
() async {
if (await _pickOne(
context,
l10n.displayGrouping,
TaskGrouping.values,
view.grouping,
(grouping) => groupingText(l10n, grouping),
)
case final grouping?) {
await set(view.copyWith(grouping: grouping));
}
},
),
choice(
LucideIcons.arrowUpDown,
l10n.displaySorting,
sortingName(view.sorting),
() async {
if (await _pickOne(
context,
l10n.displaySorting,
target.sortings,
view.sorting,
sortingName,
)
case final sorting?) {
await set(view.copyWith(sorting: sorting));
}
},
),
],
),
heading(l10n.displayFilter),
if (target.dateFilter)
choice(
LucideIcons.calendar,
l10n.dateTitle,
rangeText(l10n, view.date),
() async {
if (await _pickOne(
context,
l10n.dateTitle,
DayRange.values,
view.date,
(range) => rangeText(l10n, range),
)
case final range?) {
await set(view.copyWith(date: range));
}
},
),
choice(
LucideIcons.target,
l10n.displayDeadline,
rangeText(l10n, view.deadline, deadline: true),
() async {
if (await _pickOne(
context,
l10n.displayDeadline,
DayRange.values,
view.deadline,
(range) => rangeText(l10n, range, deadline: true),
)
case final range?) {
await set(view.copyWith(deadline: range));
}
},
),
choice(
LucideIcons.flag,
l10n.priorityTitle,
view.priorities.isEmpty
? l10n.displayAll
: [
for (final priority in Priority.values)
if (view.priorities.contains(priority))
priorityText(l10n, priority),
].join(', '),
() async {
if (await _pickPriorities(context, view.priorities)
case final picked?) {
await set(view.copyWith(priorities: picked));
}
},
),
choice(
LucideIcons.tag,
l10n.displayLabel,
view.labelIds.isEmpty
? l10n.displayAny
: [
for (final id in view.labelIds) ?labels[id],
].join(', '),
() async {
if (await pickLabels(context, [...view.labelIds])
case final picked?) {
await set(view.copyWith(labelIds: {...picked}));
}
},
_Group(
children: [
if (target.dateFilter)
choice(
LucideIcons.calendar,
l10n.dateTitle,
rangeText(l10n, view.date),
() async {
if (await _pickOne(
context,
l10n.dateTitle,
DayRange.values,
view.date,
(range) => rangeText(l10n, range),
)
case final range?) {
await set(view.copyWith(date: range));
}
},
),
choice(
LucideIcons.target,
l10n.displayDeadline,
rangeText(l10n, view.deadline, deadline: true),
() async {
if (await _pickOne(
context,
l10n.displayDeadline,
DayRange.values,
view.deadline,
(range) => rangeText(l10n, range, deadline: true),
)
case final range?) {
await set(view.copyWith(deadline: range));
}
},
),
choice(
LucideIcons.flag,
l10n.priorityTitle,
view.priorities.isEmpty
? l10n.displayAll
: [
for (final priority in Priority.values)
if (view.priorities.contains(priority))
priorityText(l10n, priority),
].join(', '),
() async {
if (await _pickPriorities(context, view.priorities)
case final picked?) {
await set(view.copyWith(priorities: picked));
}
},
),
choice(
LucideIcons.tag,
l10n.displayLabel,
view.labelIds.isEmpty
? l10n.displayAny
: [
for (final id in view.labelIds) ?labels[id],
].join(', '),
() async {
if (await pickLabels(context, [...view.labelIds])
case final picked?) {
await set(view.copyWith(labelIds: {...picked}));
}
},
),
],
),
],
),
@ -398,23 +408,94 @@ class const _DisplaySheet({required final DisplayTarget target})
}
}
/// A Display's layouts side by side, [selected] marked.
/// A Display's layouts side by side, as Todoist's (the developer's review,
/// 3 Nov 2026): each a phone's outline with its layout's picture in it,
/// its name below on one line, and a radio button marking the one in use;
/// the picture, the name or the button picks it.
class const _LayoutPicker<T>({
required final T selected,
required final List<(T, IconData, String)> segments,
required final ValueChanged<T> onPicked,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return RadioGroup<T>(
groupValue: selected,
onChanged: (picked) {
if (picked != null) {
onPicked(picked);
}
},
child: Row(
children: [
for (final (value, icon, label) in segments)
Expanded(
child: Builder(
builder: (context) {
final on = value == selected;
final color = on ? scheme.primary : scheme.onSurfaceVariant;
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => onPicked(value),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
children: [
// A phone's outline, its layout's picture in it.
Container(
width: 44,
height: 72,
decoration: BoxDecoration(
color: on
? scheme.primary.withValues(alpha: 0.12)
: null,
border: Border.all(color: color, width: 2),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 24),
),
const SizedBox(height: 8),
Text(
label,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelLarge?.copyWith(
color: color,
),
),
Radio<T>(value: value),
],
),
),
);
},
),
),
],
),
);
}
}
/// [children] in a box rounded at its ends, as Todoist's Display groups
/// its rows (the developer's review, 3 Nov 2026).
class const _Group({required final List<Widget> children})
extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: SegmentedButton<T>(
showSelectedIcon: false,
segments: [
for (final (value, icon, label) in segments)
ButtonSegment(value: value, icon: Icon(icon), label: Text(label)),
],
selected: {selected},
onSelectionChanged: (picked) => onPicked(picked.single),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Material(
color: Theme.of(context).colorScheme.surfaceContainer,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
),
);
}

View file

@ -472,6 +472,8 @@ void main() {
String setting,
String value,
) async {
await tester.ensureVisible(find.widgetWithText(ListTile, setting));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, setting));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, value).last);
@ -511,6 +513,39 @@ void main() {
.compareTo(tester.getCenter(row(b)).dy),
);
testWidgets("the layouts as Todoist's: a picture of each above its "
'name, on one line each, and a radio button marking the one in '
"use (the developer's review, 3 Nov 2026)", (tester) async {
await inboxOf(tester);
await display(tester);
final line = tester.getSize(find.text('List')).height;
for (final name in ['List', 'Board', 'Calendar']) {
expect(tester.getSize(find.text(name)).height, line, reason: name);
}
final radios = find.byType(Radio<ProjectLayout>);
expect(radios, findsNWidgets(3));
expect(
tester
.widget<RadioGroup<ProjectLayout>>(
find.byType(RadioGroup<ProjectLayout>),
)
.groupValue,
ProjectLayout.list,
);
await tester.tap(find.text('Board'));
await tester.pumpAndSettle();
expect(
tester
.widget<RadioGroup<ProjectLayout>>(
find.byType(RadioGroup<ProjectLayout>),
)
.groupValue,
ProjectLayout.board,
);
});
testWidgets('sorting by name orders the list, kept in the project for '
'every device; a dot marks the button; Reset all puts it back', (
tester,
@ -569,6 +604,9 @@ void main() {
await inboxOf(tester);
await display(tester);
// Below the layouts' pictures: scrolled to on a phone.
await tester.ensureVisible(find.widgetWithText(ListTile, 'Priority'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, 'Priority'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(CheckboxListTile, 'Priority 1'));
@ -1183,6 +1221,9 @@ void main() {
expect(find.text('Someday'), findsOneWidget);
await display(tester);
// Below the layouts' pictures: scrolled to on a phone.
await tester.ensureVisible(find.widgetWithText(ListTile, 'Priority'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, 'Priority'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(CheckboxListTile, 'Priority 1'));