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:
parent
753527aed3
commit
c7ad78f88d
2 changed files with 248 additions and 126 deletions
|
|
@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue