feat(todo): a task's sheet partway up and its parts in rounded boxes, as Todoist's

The developer's review of 3 Nov 2026 (ADR 0033, D24): on a phone the sheet
opens as high as its content, at most 65 % of the window, the list above it
in view, and is dragged up to the whole window or down to close; its title,
description, date and reminders each in a rounded box, its priority, labels,
deadline and project as a row of chips (named "Priority", "Labels",
"Deadline" while unset), and its sub-tasks in a box ending in Add sub-task.
Where sheets are dialogs, and in the pane beside the list, the same parts.
Todo keeps no comments, so the sheet has no comment field.

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:53:04 +01:00
commit 140a283d0e
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 401 additions and 181 deletions

View file

@ -40,12 +40,111 @@ Future<void> openTaskSheet(BuildContext context, String id) async {
return;
}
}
await showAdaptiveSheet<void>(
context,
builder: (context) => _TaskSheetHost(id: id),
if (sheetShowsAsDialog(context)) {
await showAdaptiveSheet<void>(
context,
builder: (context) => _TaskSheetHost(id: id),
);
return;
}
// On a phone partway up, as high as its content (at most 65 % of the
// window), and dragged up to the whole window, as Todoist's (the
// developer's review, 3 Nov 2026).
await showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
elevation: 0,
builder: (context) => _DraggableSheet(
builder: (scroll) => _SheetScroll(
controller: scroll,
child: _TaskSheetHost(id: id),
),
),
);
}
/// The key of the handle atop a task's sheet on a phone.
const taskSheetHandleKey = ValueKey('task-sheet-handle');
/// A task's sheet on a phone: partway up as high as its content, at most
/// [_partway] of the window, and up to the whole window dragged.
class const _DraggableSheet({
required final Widget Function(ScrollController scroll) builder,
}) extends StatefulWidget {
/// How far up it opens at most.
static const _partway = 0.65;
@override
State<_DraggableSheet> createState() => _DraggableSheetState();
}
class _DraggableSheetState() extends State<_DraggableSheet> {
final _sheet = DraggableScrollableController();
ScrollController? _scroll;
@override
void initState() {
super.initState();
// Once laid out, down to its content's height where that is less.
WidgetsBinding.instance.addPostFrameCallback((_) {
final scroll = _scroll;
if (!mounted || scroll == null || !scroll.hasClients) {
return;
}
final position = scroll.position;
final window = MediaQuery.sizeOf(context).height;
final content = position.maxScrollExtent + position.viewportDimension;
final size = (content / window).clamp(0.25, _DraggableSheet._partway);
if (_sheet.isAttached && size < _sheet.size) {
_sheet.jumpTo(size);
}
});
}
@override
void dispose() {
_sheet.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => DraggableScrollableSheet(
controller: _sheet,
expand: false,
initialChildSize: _DraggableSheet._partway,
minChildSize: 0.2,
snap: true,
builder: (context, scroll) {
_scroll = scroll;
return Material(
color: Theme.of(context).colorScheme.surfaceContainerLow,
clipBehavior: Clip.antiAlias,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
child: widget.builder(scroll),
);
},
);
}
/// The scrolling of a [_DraggableSheet] for the task's sheet inside it,
/// whose whole content scrolls with it so that dragging it drags the sheet.
class const _SheetScroll({
required final ScrollController controller,
required super.child,
}) extends InheritedWidget {
static ScrollController? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_SheetScroll>()?.controller;
@override
bool updateShouldNotify(_SheetScroll oldWidget) =>
controller != oldWidget.controller;
}
/// A sheet's task, which a step of its path replaces in place.
class const _TaskSheetHost({required final String id}) extends StatefulWidget {
@override
@ -239,6 +338,47 @@ class _TaskSheetState() extends State<_TaskSheet> {
Widget? heading,
Widget? action,
}) {
if (_SheetScroll.maybeOf(context) case final scroll? when !widget.inPane) {
final scheme = Theme.of(context).colorScheme;
return ScaffoldMessenger(
child: Scaffold(
backgroundColor: Colors.transparent,
body: ListView(
controller: scroll,
padding: const EdgeInsets.fromLTRB(12, 0, 12, 24),
children: [
Center(
key: taskSheetHandleKey,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 12),
width: 32,
height: 4,
decoration: BoxDecoration(
color: scheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
Row(
children: [
Expanded(
child:
heading ??
Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
),
?action,
],
),
const SizedBox(height: 8),
body,
],
),
),
);
}
if (!widget.inPane) {
return SheetScaffold(
title: title,
@ -421,214 +561,271 @@ class _TaskSheetState() extends State<_TaskSheet> {
},
),
action: _menu(context, task, subTasks),
// Its parts in rounded boxes, as Todoist's (the developer's review,
// 3 Nov 2026): the title, the description, the date, the reminders,
// then the priority, labels, deadline and project as chips, and the
// sub-tasks with one more to add.
body: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
TaskCheck(task: task),
Expanded(
child: TextField(
controller: _title,
style: theme.textTheme.titleLarge,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
border: InputBorder.none,
hintText: l10n.taskTitleHint,
_Box(
child: Row(
children: [
TaskCheck(task: task),
Expanded(
child: TextField(
controller: _title,
style: theme.textTheme.titleLarge,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
hintText: l10n.taskTitleHint,
),
onSubmitted: (_) => _saveText(),
onTapOutside: (_) => unawaited(_saveText()),
),
onSubmitted: (_) => _saveText(),
onTapOutside: (_) => unawaited(_saveText()),
),
),
],
],
),
),
// The icon stays by the first line however long the
// description grows (the developer's, 1 Nov).
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(12, 12, 12, 0),
child: Icon(LucideIcons.text),
),
Expanded(
child: TextField(
controller: _description,
minLines: 1,
maxLines: 8,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
border: InputBorder.none,
hintText: l10n.taskDescriptionHint,
),
onTapOutside: (_) => unawaited(_saveText()),
_Box(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(12, 12, 12, 0),
child: Icon(LucideIcons.text),
),
),
],
),
ListTile(
leading: Icon(
LucideIcons.calendar,
color: switch (content.date) {
final date? => dateColor(context, date.day, today),
null => null,
},
),
title: Text(switch (content.date) {
final date? => dateText(context, date, today),
null => l10n.dateNoneYet,
}),
subtitle: switch (content.date) {
TaskDate(:final day, repeat: final repeat?) => Text(
repeatText(context, repeat, day),
),
_ => null,
},
trailing: content.date == null
? null
: clear(() => _change((c) => c.copyWith(date: () => null))),
onTap: () async {
if (await pickTaskDate(context, current: content.date) case (
:final date,
)) {
await _change((c) => c.copyWith(date: () => date));
}
},
),
ListTile(
leading: const Icon(LucideIcons.target),
title: Text(switch (content.deadline) {
final day? => l10n.deadlineOn(dayText(context, day, today)),
null => l10n.deadlineNone,
}),
trailing: content.deadline == null
? null
: clear(() => _change((c) => c.copyWith(deadline: () => null))),
onTap: () async {
if (await pickDeadline(context, current: content.deadline) case (
:final day,
)) {
await _change((c) => c.copyWith(deadline: () => day));
}
},
),
ListTile(
leading: const Icon(LucideIcons.bell),
title: Text(remindersText(context, content, today)),
onTap: () async {
final timed = content.date?.time != null;
final chosen = await pickReminders(
context,
before: content.reminders,
at: content.reminderTimes,
timed: timed,
day: content.date?.day,
);
if (chosen == null || !context.mounted) {
return;
}
// Asked once there is something to remind of, not before.
final had =
(timed && content.reminders.isNotEmpty) ||
content.reminderTimes.isNotEmpty;
if (!had && (chosen.before.isNotEmpty || chosen.at.isNotEmpty)) {
final reminders = PersonScope.of<TodoPerson>(
context,
).reminders;
final messenger = ScaffoldMessenger.of(context);
if (!await reminders.askPermission()) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.remindersNotAllowed)),
);
}
}
await _change(
(c) => c.copyWith(
reminders: chosen.before,
reminderTimes: chosen.at,
),
);
},
),
ListTile(
leading: Icon(
LucideIcons.flag,
color: priorityColor(context, content.priority),
),
title: Text(priorityText(l10n, content.priority)),
onTap: () async {
if (await pickPriority(context, content.priority)
case final priority?) {
await _change((c) => c.copyWith(priority: priority));
}
},
),
if (content.parentId == null)
ListTile(
leading: Icon(
project?.inbox ?? true ? LucideIcons.inbox : LucideIcons.hash,
),
title: Text(switch (project) {
final project? when !project.inbox => project.content!.name,
_ => l10n.shellTabInbox,
}),
onTap: () async {
if (await pickProject(context, content.projectId) case final id?
when id != content.projectId) {
await _move(id);
}
},
),
ListTile(
leading: const Icon(LucideIcons.tag),
title: Text(
content.labelIds.isEmpty
? l10n.labelsNoneSet
: [
for (final id in content.labelIds) ?labels[id],
].join(', '),
),
onTap: () async {
if (await pickLabels(context, content.labelIds)
case final chosen?) {
await _change((c) => c.copyWith(labelIds: chosen));
}
},
),
const Divider(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Text(
subTasks.isEmpty
? l10n.subTasks
: l10n.subTasksDone(
subTasks.where((t) => t.content.completed).length,
subTasks.length,
Expanded(
child: TextField(
controller: _description,
minLines: 1,
maxLines: 8,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
hintText: l10n.taskDescriptionHint,
),
style: theme.textTheme.titleSmall?.copyWith(color: muted),
onTapOutside: (_) => unawaited(_saveText()),
),
),
],
),
),
for (final sub in subTasks)
if (!(content.hideDoneSubTasks && sub.content.completed))
TaskTile(task: sub, selectable: false),
Align(
alignment: AlignmentDirectional.centerStart,
child: TextButton.icon(
icon: const Icon(LucideIcons.plus),
label: Text(l10n.subTaskAdd),
onPressed: () => showQuickAdd(
context,
projectId: content.projectId,
sectionId: content.sectionId,
parentId: task.id,
_Box(
child: ListTile(
leading: Icon(
LucideIcons.calendar,
color: switch (content.date) {
final date? => dateColor(context, date.day, today),
null => null,
},
),
title: Text(switch (content.date) {
final date? => dateText(context, date, today),
null => l10n.dateNoneYet,
}),
subtitle: switch (content.date) {
TaskDate(:final day, repeat: final repeat?) => Text(
repeatText(context, repeat, day),
),
_ => null,
},
trailing: content.date == null
? null
: clear(() => _change((c) => c.copyWith(date: () => null))),
onTap: () async {
if (await pickTaskDate(context, current: content.date) case (
:final date,
)) {
await _change((c) => c.copyWith(date: () => date));
}
},
),
),
_Box(
child: ListTile(
leading: const Icon(LucideIcons.bell),
title: Text(remindersText(context, content, today)),
onTap: () => _pickReminders(context, content),
),
),
_Box(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.all(12),
child: Row(
spacing: 8,
children: [
ActionChip(
avatar: Icon(
LucideIcons.flag,
color: priorityColor(context, content.priority),
),
label: Text(
content.priority == Priority.none
? l10n.priorityTitle
: priorityText(l10n, content.priority),
),
onPressed: () async {
if (await pickPriority(context, content.priority)
case final priority?) {
await _change((c) => c.copyWith(priority: priority));
}
},
),
ActionChip(
avatar: const Icon(LucideIcons.tag),
label: Text(
content.labelIds.isEmpty
? l10n.labelsTitle
: [
for (final id in content.labelIds) ?labels[id],
].join(', '),
),
onPressed: () async {
if (await pickLabels(context, content.labelIds)
case final chosen?) {
await _change((c) => c.copyWith(labelIds: chosen));
}
},
),
ActionChip(
avatar: const Icon(LucideIcons.target),
label: Text(switch (content.deadline) {
final day? => l10n.deadlineOn(
dayText(context, day, today),
),
null => l10n.deadlineTitle,
}),
onPressed: () async {
if (await pickDeadline(
context,
current: content.deadline,
)
case (:final day)) {
await _change((c) => c.copyWith(deadline: () => day));
}
},
),
if (content.parentId == null)
ActionChip(
avatar: Icon(
project?.inbox ?? true
? LucideIcons.inbox
: LucideIcons.hash,
),
label: Text(switch (project) {
final project? when !project.inbox =>
project.content!.name,
_ => l10n.shellTabInbox,
}),
onPressed: () async {
if (await pickProject(context, content.projectId)
case final id? when id != content.projectId) {
await _move(id);
}
},
),
],
),
),
),
_Box(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (subTasks.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
l10n.subTasksDone(
subTasks.where((t) => t.content.completed).length,
subTasks.length,
),
style: theme.textTheme.titleSmall?.copyWith(
color: muted,
),
),
),
for (final sub in subTasks)
if (!(content.hideDoneSubTasks && sub.content.completed))
TaskTile(task: sub, selectable: false),
],
TextButton.icon(
icon: const Icon(LucideIcons.plus),
label: Text(l10n.subTaskAdd),
onPressed: () => showQuickAdd(
context,
projectId: content.projectId,
sectionId: content.sectionId,
parentId: task.id,
),
),
],
),
),
],
),
);
}
/// Asks for the task's reminders and writes them, asking first to
/// remind where nothing was to be reminded of before.
Future<void> _pickReminders(BuildContext context, TaskContent content) async {
final l10n = AppLocalizations.of(context);
final timed = content.date?.time != null;
final chosen = await pickReminders(
context,
before: content.reminders,
at: content.reminderTimes,
timed: timed,
day: content.date?.day,
);
if (chosen == null || !context.mounted) {
return;
}
// Asked once there is something to remind of, not before.
final had =
(timed && content.reminders.isNotEmpty) ||
content.reminderTimes.isNotEmpty;
if (!had && (chosen.before.isNotEmpty || chosen.at.isNotEmpty)) {
final reminders = PersonScope.of<TodoPerson>(context).reminders;
final messenger = ScaffoldMessenger.of(context);
if (!await reminders.askPermission()) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.remindersNotAllowed)),
);
}
}
await _change(
(c) => c.copyWith(reminders: chosen.before, reminderTimes: chosen.at),
);
}
}
/// One part of a task's sheet in its rounded box (the developer's review,
/// 3 Nov 2026, as Todoist's).
class const _Box({required final Widget child}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Material(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: child,
),
);
}
/// The key of the path atop a task's sheet, for tests to find it by.

View file

@ -1877,6 +1877,29 @@ void main() {
);
});
testWidgets("a task's sheet opens partway up, as Todoist's, its parts in "
'rounded boxes and its priority, labels and deadline as chips; '
"dragged up, it fills the window (the developer's review, 3 Nov "
'2026)', (tester) async {
await start(tester, usedBefore: true);
await seed(tester, TaskContent(title: 'Pack', projectId: inbox()));
await goTab(tester, 'Inbox');
await tester.tap(find.text('Pack'));
await tester.pumpAndSettle();
final sheet = find.byType(DraggableScrollableSheet);
expect(tester.getRect(sheet).top, greaterThan(900 * 0.3));
for (final chip in ['Priority', 'Labels', 'Deadline']) {
expect(find.widgetWithText(ActionChip, chip), findsOneWidget);
}
expect(find.text('Add sub-task'), findsOneWidget);
await tester.drag(find.byKey(taskSheetHandleKey), const Offset(0, -700));
await tester.pumpAndSettle();
expect(tester.getRect(sheet).top, lessThan(80));
});
testWidgets("a task's sheet sets its priority, adds a sub-task the row "
'counts, and deletes it with its sub-tasks after asking', (
tester,
@ -1888,7 +1911,7 @@ void main() {
await tester.tap(find.text('Pack'));
await tester.pumpAndSettle();
await tester.tap(find.text('No priority'));
await tester.tap(find.widgetWithText(ActionChip, 'Priority'));
await tester.pumpAndSettle();
await tester.tap(find.text('Priority 1'));
await tester.pumpAndSettle();