feat(todo): the list scrolls to each task as quick add adds it, just above its bar

As Todoist's (the developer's review, 10 Nov 2026): the row of the task
added brings itself into view, just above quick add's bar on a phone,
within its list beside the dialog on the desktop; while quick add is
open, a screen's lists leave room at their end for the bar and the
keyboard, so that even the last task can rise above them, nothing in
view moving for it. A long list builds the rows near its view alone, so
the screen scrolls on down until the task's row is built.

Whats-New: Each task you add now scrolls into view, just above the add bar.
Whats-New-De: Jede neue Aufgabe rückt jetzt ins Bild, direkt über die Leiste zum Hinzufügen.

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-10 11:29:17 +01:00
commit 7f643aa00e
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 348 additions and 7 deletions

File diff suppressed because one or more lines are too long

View file

@ -7,6 +7,7 @@ import 'package:halkyon_todo_flutter/src/app/todo_languages.dart';
import 'package:halkyon_todo_flutter/src/app/todo_router.dart';
import 'package:halkyon_todo_flutter/src/feature/reminder/widget/reminder_scheduler.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_reveal.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
@ -70,9 +71,13 @@ class const TodoPersonApp({
class _TodoPersonAppState() extends State<TodoPersonApp> {
final _router = buildTodoRouter();
/// The task quick add just added, for the lists to bring into view.
final _reveal = TaskReveal();
@override
void dispose() {
_router.dispose();
_reveal.dispose();
super.dispose();
}
@ -120,10 +125,13 @@ class _TodoPersonAppState() extends State<TodoPersonApp> {
SyncStatusBloc(PersonScope.of<TodoPerson>(context).sync),
),
],
child: ScreenColor(
color: PaletteColor.strawberry.background,
// Their tasks' reminders on this device (ADR 0033, D5).
child: ReminderScheduler(child: child!),
child: TaskRevealScope(
notifier: _reveal,
child: ScreenColor(
color: PaletteColor.strawberry.background,
// Their tasks' reminders on this device (ADR 0033, D5).
child: ReminderScheduler(child: child!),
),
),
),
),

View file

@ -20,6 +20,7 @@ import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/date_picker.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_pickers.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_reveal.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/title_tokens.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -68,6 +69,11 @@ Future<void> showQuickAdd(
},
spot: spot,
);
final reveal = TaskRevealScope.read(context);
return _shown(context, bar).whenComplete(() => reveal?.closed());
}
Future<void> _shown(BuildContext context, Widget bar) {
if (sheetShowsAsDialog(context)) {
return showDialog<void>(
context: context,
@ -339,6 +345,22 @@ class _QuickAddState() extends State<_QuickAdd> {
);
}
/// Has the lists bring the task [id] into view, above the bar where it
/// is a sheet (the developer's review, 10 Nov 2026).
void _revealAdded(String id) {
final reveal = TaskRevealScope.read(context);
final box = context.findRenderObject();
if (reveal == null || box is! RenderBox) {
return;
}
final dialog = sheetShowsAsDialog(context);
reveal.added(
id,
barTop: dialog ? null : box.localToGlobal(Offset.zero).dy,
height: MediaQuery.sizeOf(context).height,
);
}
Future<void> _add() async {
final l10n = AppLocalizations.of(context);
final parsed = _parse();
@ -380,6 +402,7 @@ class _QuickAddState() extends State<_QuickAdd> {
: siblings.isEmpty
? 0.0
: last + 1;
final String id;
try {
final made = <String>[
for (final (index, name) in parsed.newLabels.indexed)
@ -387,7 +410,7 @@ class _QuickAddState() extends State<_QuickAdd> {
LabelContent(name: name, position: lastLabel + 1 + index),
),
];
await todo.tasks.add(
id = await todo.tasks.add(
TaskContent(
title: title,
projectId: projectId,
@ -418,6 +441,7 @@ class _QuickAddState() extends State<_QuickAdd> {
if (!mounted) {
return;
}
_revealAdded(id);
// Ready for the next one, in the same place and on the same day.
await _dictation.stop();
_title.clear();

View file

@ -0,0 +1,208 @@
import 'dart:async';
import 'dart:math';
import 'package:material_ui/material_ui.dart';
/// The task quick add has just added, brought into view where a list shows
/// it, just above quick add's bar while that is open, as Todoist's (the
/// developer's review, 10 Nov 2026).
class TaskReveal() extends ChangeNotifier {
String? _pending;
/// The task to be brought into view, until a row of it has been.
String? get pending => _pending;
/// How much of the window's foot quick add's bar and the keyboard cover,
/// from the first task it added on until it closes: the lists' ends
/// leave room for it, so that their last task can stand above the bar.
double get cover => _cover;
double _cover = 0;
/// The top of quick add's bar in the window as it added the task; none
/// for a dialog, which stands beside the list.
double? get barTop => _barTop;
double? _barTop;
/// Quick add added the task [id], its bar's top at [barTop] in a window
/// [height] high where it is a sheet.
void added(String id, {double? barTop, double height = 0}) {
_pending = id;
_barTop = barTop;
_cover = barTop == null ? 0 : max(_cover, height - barTop);
notifyListeners();
}
/// Takes the task [id] to bring into view, once: true where it was the
/// one pending.
bool claim(String id) {
if (_pending != id) {
return false;
}
_pending = null;
return true;
}
/// Quick add closed: the lists' ends as they were.
void closed() {
if (_pending == null && _cover == 0 && _barTop == null) {
return;
}
_pending = null;
_cover = 0;
_barTop = null;
notifyListeners();
}
}
/// Holds the person's [TaskReveal] for their lists and quick add, above the
/// navigators, as quick add opens on the root one.
class const TaskRevealScope({
required TaskReveal super.notifier,
required super.child,
super.key,
}) extends InheritedNotifier<TaskReveal> {
/// The [TaskReveal] above [context], which rebuilds as it changes; none
/// outside a person's app.
static TaskReveal? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<TaskRevealScope>()?.notifier;
/// The [TaskReveal] above [context], without depending on it.
static TaskReveal? read(BuildContext context) =>
context.getInheritedWidgetOfExactType<TaskRevealScope>()?.notifier;
}
/// Brings [context], a row of the task just added, into view in its list:
/// just above quick add's bar, or within the list where it is a dialog.
void revealRow(BuildContext context, TaskReveal reveal) {
final row = context.findRenderObject();
final scrollable = Scrollable.maybeOf(context, axis: Axis.vertical);
final view = scrollable?.context.findRenderObject();
if (row is! RenderBox ||
!row.hasSize ||
scrollable == null ||
view is! RenderBox) {
return;
}
final rect = row.localToGlobal(Offset.zero) & row.size;
final shown = view.localToGlobal(Offset.zero) & view.size;
final foot = min(
shown.bottom - MediaQuery.paddingOf(scrollable.context).bottom,
reveal.barTop ?? double.infinity,
);
const gap = 8.0;
final position = scrollable.position;
final by = rect.bottom > foot - gap
? rect.bottom - (foot - gap)
: rect.top < shown.top
? rect.top - shown.top
: 0.0;
if (by == 0) {
return;
}
final to = (position.pixels + by).clamp(
position.minScrollExtent,
position.maxScrollExtent,
);
if (to != position.pixels) {
unawaited(
position.animateTo(
to,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
),
);
}
}
/// [child], a screen's tasks, leaving room at their end for quick add's
/// bar while it is open, and scrolling on down, a screenful at a time,
/// while the task just added has no row built yet to bring itself into
/// view: a long list builds those near its view alone.
class const TaskRevealRoom({required final Widget child, super.key})
extends StatefulWidget {
@override
State<TaskRevealRoom> createState() => _TaskRevealRoomState();
}
class _TaskRevealRoomState() extends State<TaskRevealRoom> {
TaskReveal? _reveal;
String? _seeking;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final reveal = TaskRevealScope.maybeOf(context);
_reveal = reveal;
final pending = reveal?.pending;
if (pending != null && pending != _seeking) {
_seeking = pending;
_seek(pending, 0);
}
}
/// Looks for the task [id] after a frame, [steps] screenfuls down so far.
void _seek(String id, int steps) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final reveal = _reveal;
if (!mounted ||
reveal == null ||
reveal.pending != id ||
!TickerMode.valuesOf(context).enabled ||
steps > 40) {
_seeking = null;
return;
}
final position = _listOf(context)?.position;
if (position == null || position.pixels >= position.maxScrollExtent) {
_seeking = null;
return;
}
position.jumpTo(
min(
position.pixels + position.viewportDimension * 0.8,
position.maxScrollExtent,
),
);
_seek(id, steps + 1);
});
}
/// The first list going down in [context].
static ScrollableState? _listOf(BuildContext context) {
ScrollableState? found;
void visit(Element element) {
if (found != null) {
return;
}
if (element
case StatefulElement(
:final ScrollableState state,
)
when state.position.axis == Axis.vertical) {
found = state;
return;
}
element.visitChildren(visit);
}
context.visitChildElements(visit);
return found;
}
@override
Widget build(BuildContext context) {
final cover = _reveal?.cover ?? 0;
if (cover == 0) {
return widget.child;
}
final media = MediaQuery.of(context);
return MediaQuery(
data: media.copyWith(
padding: media.padding.copyWith(
bottom: max(media.padding.bottom, cover),
),
),
child: widget.child,
);
}
}

View file

@ -13,6 +13,7 @@ import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/date_picker.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_reveal.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_selection.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
@ -60,6 +61,20 @@ class const TaskTile({
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final state = context.watch<TodoCubit>().state;
// The task just added brings itself into view (the developer's
// review, 10 Nov 2026); a row on a screen out of sight leaves it to
// the one in view.
final reveal = TaskRevealScope.maybeOf(context);
if (reveal != null &&
reveal.pending == task.id &&
TickerMode.valuesOf(context).enabled &&
reveal.claim(task.id)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
revealRow(context, reveal);
}
});
}
final content = task.content;
final today = Day.of(clock.now());
final muted = theme.colorScheme.onSurfaceVariant;

View file

@ -6,6 +6,7 @@ import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/search_screen.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_reveal.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_selection.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_writes.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -108,7 +109,9 @@ class const TabScreen({
const SizedBox(width: 4),
],
),
body: body,
// Room at the tasks' end for quick add's bar, and the task it
// added brought into view (the developer's review, 10 Nov 2026).
body: TaskRevealRoom(child: body),
// On a phone the button stands over the fade above the
// navigation bar, which the screen paints beneath it, the tasks
// running on beneath both (the developer's review, 9 Nov 2026);

View file

@ -1025,6 +1025,84 @@ void main() {
await tester.pumpAndSettle();
}
group('the list scrolls to a task as quick add adds it, as '
"Todoist's (the developer's review, 10 Nov 2026)", () {
/// The Inbox holding [count] tasks.
Future<void> inboxOf(WidgetTester tester, int count) async {
await start(tester, usedBefore: true);
for (var index = 0; index < count; index++) {
await seed(
tester,
TaskContent(
title: 'Task $index',
projectId: inbox(),
position: index.toDouble(),
),
);
}
await goTab(tester, 'Inbox');
}
/// Adds [title] through the quick add bar open.
Future<void> add(WidgetTester tester, String title) async {
await tester.enterText(find.byKey(quickAddTitleKey), title);
await tester.pump();
await tester.tap(find.byTooltip('Add'));
await tester.pumpAndSettle();
}
double barTop(WidgetTester tester) =>
tester.getTopLeft(find.byType(QuickAddBar)).dy;
testWidgets('on a phone, each one just above the bar, however long '
'the list', (tester) async {
await inboxOf(tester, 25);
await tester.tap(find.byTooltip('Add task'));
await tester.pumpAndSettle();
for (final title in ['Newest', 'Newer still']) {
await add(tester, title);
final row = tester.getRect(find.text(title));
expect(row.bottom, lessThanOrEqualTo(barTop(tester)));
expect(row.bottom, greaterThan(barTop(tester) - 120));
}
});
testWidgets('a list that has room for it stays where it is', (
tester,
) async {
await inboxOf(tester, 2);
final top = tester.getTopLeft(find.text('Task 0'));
await tester.tap(find.byTooltip('Add task'));
await tester.pumpAndSettle();
await add(tester, 'Newest');
expect(tester.getTopLeft(find.text('Task 0')), top);
expect(find.text('Newest'), findsOneWidget);
});
testWidgets('on the desktop, beside the dialog, into view', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
await inboxOf(tester, 25);
await tester.tap(find.byTooltip('Add task'));
await tester.pumpAndSettle();
await add(tester, 'Newest');
final row = tester.getRect(find.text('Newest'));
expect(
row.bottom,
lessThanOrEqualTo(
tester.view.physicalSize.height / tester.view.devicePixelRatio,
),
);
expect(row.top, greaterThan(0));
debugDefaultTargetPlatformOverride = null;
});
});
testWidgets('a task added in the Inbox shows there, sealed on its way', (
tester,
) async {
@ -4857,6 +4935,11 @@ void main() {
await tester.tapAt(const Offset(20, 20));
await tester.pumpAndSettle();
expect(find.text('Sub-tasks 0/1'), findsOneWidget);
// The sub-task added was brought into view above quick add's bar.
await tester.ensureVisible(
find.byTooltip('Task options', skipOffstage: false),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Task options'));
await tester.pumpAndSettle();