feat(todo): a sub-task opens in its task's sheet, back returning to it

A sub-task tapped in a task's sheet opened a sheet of its own over it,
so Grocery List > Dairy > Milk stacked three. It now takes the sheet's
place, as the steps of the sheet's path already did (TaskTile's onOpen),
and the sheet keeps the tasks it showed: the system's back returns to
the last. Back is caught with a BackButtonListener, not a PopScope,
which would also hold back a tap beside the sheet, a swipe and Escape:
those close the sheet whole. A picker open over the sheet is closed by
back first.

Whats-New: A sub-task opens in its task's sheet instead of over it; back returns to the task.
Whats-New-De: Eine Unteraufgabe öffnet sich im Blatt ihrer Aufgabe statt darüber; Zurück führt zur Aufgabe.

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-04 20:47:07 +01:00
commit e11ed481ce
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 94 additions and 8 deletions

View file

@ -145,7 +145,11 @@ class const _SheetScroll({
controller != oldWidget.controller; controller != oldWidget.controller;
} }
/// A sheet's task, which a step of its path replaces in place. /// A sheet's task, which a step of its path or a sub-task replaces in place
/// (the developer's review, 4 Nov 2026: a sub-task had opened a sheet over
/// the sheet). The tasks it showed before are kept, so the system's back
/// returns to the last; every other way of closing it (a tap beside it, a
/// swipe down, Escape) closes it whole.
class const _TaskSheetHost({required final String id}) extends StatefulWidget { class const _TaskSheetHost({required final String id}) extends StatefulWidget {
@override @override
State<_TaskSheetHost> createState() => _TaskSheetHostState(); State<_TaskSheetHost> createState() => _TaskSheetHostState();
@ -154,12 +158,45 @@ class const _TaskSheetHost({required final String id}) extends StatefulWidget {
class _TaskSheetHostState() extends State<_TaskSheetHost> { class _TaskSheetHostState() extends State<_TaskSheetHost> {
late var _id = widget.id; late var _id = widget.id;
/// The tasks shown before [_id], the last most recently.
final _history = <String>[];
/// Shows [id]; one shown before is gone back to, the steps after it
/// forgotten.
void _show(String id) {
if (id == _id) {
return;
}
setState(() {
final before = _history.indexOf(id);
if (before >= 0) {
_history.removeRange(before, _history.length);
} else {
_history.add(_id);
}
_id = id;
});
}
/// Back from the system: the task shown before, while there is one and
/// nothing is open over the sheet (a picker, which back closes first).
Future<bool> _back() async {
if (_history.isEmpty || !(ModalRoute.of(context)?.isCurrent ?? false)) {
return false;
}
setState(() => _id = _history.removeLast());
return true;
}
@override @override
Widget build(BuildContext context) => _TaskSheet( Widget build(BuildContext context) {
key: ValueKey(_id), final sheet = _TaskSheet(key: ValueKey(_id), id: _id, onOpen: _show);
id: _id, // Not a PopScope: that would hold back a tap beside the sheet and
onOpen: (id) => setState(() => _id = id), // Escape too, which close it whole. Back alone goes through the router.
); return Router.maybeOf(context) == null
? sheet
: BackButtonListener(onBackButtonPressed: _back, child: sheet);
}
} }
/// The task open beside the list from expanded windows on (ADR 0033, D2, /// The task open beside the list from expanded windows on (ADR 0033, D2,
@ -759,7 +796,11 @@ class _TaskSheetState() extends State<_TaskSheet> {
), ),
for (final sub in subTasks) for (final sub in subTasks)
if (!(content.hideDoneSubTasks && sub.content.completed)) if (!(content.hideDoneSubTasks && sub.content.completed))
TaskTile(task: sub, selectable: false), TaskTile(
task: sub,
selectable: false,
onOpen: (id) => _open(context, id),
),
], ],
TextButton.icon( TextButton.icon(
icon: const Icon(LucideIcons.plus), icon: const Icon(LucideIcons.plus),

View file

@ -46,6 +46,10 @@ class const TaskTile({
final bool selectable = true, final bool selectable = true,
final bool later = false, final bool later = false,
final TaskRowPosition position = TaskRowPosition.alone, final TaskRowPosition position = TaskRowPosition.alone,
/// Opens the task in place of the one showing, as a sheet's sub-tasks
/// do; by default a tap opens the task's sheet.
final ValueChanged<String>? onOpen,
super.key, super.key,
}) extends StatelessWidget { }) extends StatelessWidget {
@override @override
@ -252,7 +256,10 @@ class const TaskTile({
), ),
onTap: selection != null && selection.active onTap: selection != null && selection.active
? () => selection.toggle(task.id) ? () => selection.toggle(task.id)
: () => openTaskSheet(context, task.id), : switch (onOpen) {
final open? => () => open(task.id),
null => () => openTaskSheet(context, task.id),
},
); );
if (selection == null) { if (selection == null) {
return _boxed(context, tile); return _boxed(context, tile);

View file

@ -2184,6 +2184,44 @@ void main() {
); );
}); });
testWidgets("a sub-task opens in its task's sheet, not in another over "
'it; back returns to the task, a tap beside closes it (the '
"developer's review, 4 Nov 2026)", (tester) async {
await start(tester, usedBefore: true);
final list = await tester.runAsync(
() =>
tasks().add(TaskContent(title: 'Grocery List', projectId: inbox())),
);
final dairy = await tester.runAsync(
() => tasks().add(
TaskContent(title: 'Dairy', projectId: inbox(), parentId: list),
),
);
await seed(
tester,
TaskContent(title: 'Milk', projectId: inbox(), parentId: dairy),
);
await goTab(tester, 'Inbox');
await tester.tap(find.text('Grocery List').first);
await tester.pumpAndSettle();
await tester.tap(find.text('Dairy').last);
await tester.pumpAndSettle();
await tester.tap(find.text('Milk').last);
await tester.pumpAndSettle();
expect(find.byType(BottomSheet), findsOneWidget);
expect(find.widgetWithText(TextField, 'Milk'), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.byType(BottomSheet), findsOneWidget);
expect(find.widgetWithText(TextField, 'Dairy'), findsOneWidget);
await tester.tapAt(const Offset(20, 20));
await tester.pumpAndSettle();
expect(find.byType(BottomSheet), findsNothing);
});
testWidgets("a task's menu says when it was added, duplicates it with its " testWidgets("a task's menu says when it was added, duplicates it with its "
'sub-tasks and hides its done ones, on every device', (tester) async { 'sub-tasks and hides its done ones, on every device', (tester) async {
await start(tester, usedBefore: true); await start(tester, usedBefore: true);