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), padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text( child: Text(
text, text,
style: theme.textTheme.titleSmall?.copyWith( style: theme.textTheme.titleSmall,
color: theme.colorScheme.primary,
),
), ),
); );
Widget choice( Widget choice(
@ -259,123 +257,135 @@ class const _DisplaySheet({required final DisplayTarget target})
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
heading(l10n.layoutTitle), heading(l10n.layoutTitle),
...target.layout(context, state, apply), _Group(children: target.layout(context, state, apply)),
SwitchListTile( _Group(
secondary: const Icon(LucideIcons.circleCheck), children: [
title: Text(l10n.displayCompleted), SwitchListTile(
value: view.completed, secondary: const Icon(LucideIcons.circleCheck),
onChanged: (on) => set(view.copyWith(completed: on)), 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), heading(l10n.displaySort),
if (target.grouping(state)) _Group(
choice( children: [
LucideIcons.group, if (target.grouping(state))
l10n.displayGrouping, choice(
groupingText(l10n, view.grouping), LucideIcons.group,
() async { l10n.displayGrouping,
if (await _pickOne( groupingText(l10n, view.grouping),
context, () async {
l10n.displayGrouping, if (await _pickOne(
TaskGrouping.values, context,
view.grouping, l10n.displayGrouping,
(grouping) => groupingText(l10n, grouping), TaskGrouping.values,
) view.grouping,
case final grouping?) { (grouping) => groupingText(l10n, grouping),
await set(view.copyWith(grouping: grouping)); )
} case final grouping?) {
}, await set(view.copyWith(grouping: grouping));
), }
choice( },
LucideIcons.arrowUpDown, ),
l10n.displaySorting, choice(
sortingName(view.sorting), LucideIcons.arrowUpDown,
() async { l10n.displaySorting,
if (await _pickOne( sortingName(view.sorting),
context, () async {
l10n.displaySorting, if (await _pickOne(
target.sortings, context,
view.sorting, l10n.displaySorting,
sortingName, target.sortings,
) view.sorting,
case final sorting?) { sortingName,
await set(view.copyWith(sorting: sorting)); )
} case final sorting?) {
}, await set(view.copyWith(sorting: sorting));
}
},
),
],
), ),
heading(l10n.displayFilter), heading(l10n.displayFilter),
if (target.dateFilter) _Group(
choice( children: [
LucideIcons.calendar, if (target.dateFilter)
l10n.dateTitle, choice(
rangeText(l10n, view.date), LucideIcons.calendar,
() async { l10n.dateTitle,
if (await _pickOne( rangeText(l10n, view.date),
context, () async {
l10n.dateTitle, if (await _pickOne(
DayRange.values, context,
view.date, l10n.dateTitle,
(range) => rangeText(l10n, range), DayRange.values,
) view.date,
case final range?) { (range) => rangeText(l10n, range),
await set(view.copyWith(date: range)); )
} case final range?) {
}, await set(view.copyWith(date: range));
), }
choice( },
LucideIcons.target, ),
l10n.displayDeadline, choice(
rangeText(l10n, view.deadline, deadline: true), LucideIcons.target,
() async { l10n.displayDeadline,
if (await _pickOne( rangeText(l10n, view.deadline, deadline: true),
context, () async {
l10n.displayDeadline, if (await _pickOne(
DayRange.values, context,
view.deadline, l10n.displayDeadline,
(range) => rangeText(l10n, range, deadline: true), DayRange.values,
) view.deadline,
case final range?) { (range) => rangeText(l10n, range, deadline: true),
await set(view.copyWith(deadline: range)); )
} case final range?) {
}, await set(view.copyWith(deadline: range));
), }
choice( },
LucideIcons.flag, ),
l10n.priorityTitle, choice(
view.priorities.isEmpty LucideIcons.flag,
? l10n.displayAll l10n.priorityTitle,
: [ view.priorities.isEmpty
for (final priority in Priority.values) ? l10n.displayAll
if (view.priorities.contains(priority)) : [
priorityText(l10n, priority), for (final priority in Priority.values)
].join(', '), if (view.priorities.contains(priority))
() async { priorityText(l10n, priority),
if (await _pickPriorities(context, view.priorities) ].join(', '),
case final picked?) { () async {
await set(view.copyWith(priorities: picked)); if (await _pickPriorities(context, view.priorities)
} case final picked?) {
}, await set(view.copyWith(priorities: picked));
), }
choice( },
LucideIcons.tag, ),
l10n.displayLabel, choice(
view.labelIds.isEmpty LucideIcons.tag,
? l10n.displayAny l10n.displayLabel,
: [ view.labelIds.isEmpty
for (final id in view.labelIds) ?labels[id], ? l10n.displayAny
].join(', '), : [
() async { for (final id in view.labelIds) ?labels[id],
if (await pickLabels(context, [...view.labelIds]) ].join(', '),
case final picked?) { () async {
await set(view.copyWith(labelIds: {...picked})); 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>({ class const _LayoutPicker<T>({
required final T selected, required final T selected,
required final List<(T, IconData, String)> segments, required final List<(T, IconData, String)> segments,
required final ValueChanged<T> onPicked, required final ValueChanged<T> onPicked,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @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( Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: SegmentedButton<T>( child: Material(
showSelectedIcon: false, color: Theme.of(context).colorScheme.surfaceContainer,
segments: [ clipBehavior: Clip.antiAlias,
for (final (value, icon, label) in segments) shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
ButtonSegment(value: value, icon: Icon(icon), label: Text(label)), child: Column(
], mainAxisSize: MainAxisSize.min,
selected: {selected}, crossAxisAlignment: CrossAxisAlignment.stretch,
onSelectionChanged: (picked) => onPicked(picked.single), children: children,
),
), ),
); );
} }

View file

@ -472,6 +472,8 @@ void main() {
String setting, String setting,
String value, String value,
) async { ) async {
await tester.ensureVisible(find.widgetWithText(ListTile, setting));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, setting)); await tester.tap(find.widgetWithText(ListTile, setting));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(ListTile, value).last); await tester.tap(find.widgetWithText(ListTile, value).last);
@ -511,6 +513,39 @@ void main() {
.compareTo(tester.getCenter(row(b)).dy), .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 ' testWidgets('sorting by name orders the list, kept in the project for '
'every device; a dot marks the button; Reset all puts it back', ( 'every device; a dot marks the button; Reset all puts it back', (
tester, tester,
@ -569,6 +604,9 @@ void main() {
await inboxOf(tester); await inboxOf(tester);
await display(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.tap(find.widgetWithText(ListTile, 'Priority'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(CheckboxListTile, 'Priority 1')); await tester.tap(find.widgetWithText(CheckboxListTile, 'Priority 1'));
@ -1183,6 +1221,9 @@ void main() {
expect(find.text('Someday'), findsOneWidget); expect(find.text('Someday'), findsOneWidget);
await display(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.tap(find.widgetWithText(ListTile, 'Priority'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(CheckboxListTile, 'Priority 1')); await tester.tap(find.widgetWithText(CheckboxListTile, 'Priority 1'));