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:
parent
607ac71233
commit
7f643aa00e
7 changed files with 348 additions and 7 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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!),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue