feat(todo): Upcoming's 3-day grid becomes a grid of any number of days, with a line at the time now

Today's Calendar (ADR 0033, D30) is one day of the same hours, so the
grid takes its number of days (`HourGrid`, `days`; D17's `ThreeDayGrid`
renamed): with one day it has no day names, no arrows nor swipe and no
Overdue button, which Today's Plan holds instead. It also takes the hour
it opens at and the room below its last hour, and draws a line at the
time it is now across today, its time in cerise in the hours' column,
again each minute, as Todoist's does.

Whats-New: Upcoming's 3-day view shows a line at the time it is now.
Whats-New-De: Die Ansicht „3 Tage“ in Demnächst zeigt eine Linie bei der aktuellen Uhrzeit.

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 22:39:26 +01:00
commit c53ce30ff5
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 198 additions and 85 deletions

View file

@ -0,0 +1,784 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_calendar.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/day_grid.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.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';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
import 'package:halkyon_todo_flutter/src/tabs/today_tab.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// How tall an hour is in the grid.
const _hourHeight = 60.0;
/// How wide the hours' column is.
const _gutter = 56.0;
/// Days of hours (ADR 0033, D17, D30, as Todoist's): Upcoming's 3-day
/// layout, or Today's Calendar, one day ([days] 1). Several days stand side
/// by side from [onStart]'s day on, never before today, swiped or stepped
/// as many days at a time, each named above its column; their tasks
/// without a time *All day* above, and below the day's hours with each
/// timed task a block as long as it lasts (half an hour at least),
/// overlapping ones side by side, coloured by [coloring], a line at the
/// time it is now across today. A long press on a free half hour marks it
/// and adds a task there for half an hour; a block tapped opens its task,
/// and held and dragged moves it to another of the days shown and another
/// time, its top snapping to a quarter hour, or onto a day's *All day*,
/// taking its time away ([moveTask], which can be undone); a task's row
/// dragged onto an hour from elsewhere (Today's Plan) goes there the same
/// way. *Overdue* opens what is overdue, with Reschedule, where several
/// days show: Today's Plan holds it beside one.
class const HourGrid({
required final ProjectCalendar Function(int days) calendarFor,
required final TaskColoring coloring,
final int days = 3,
/// The hour at the top of the hours at first.
final int firstHour = 7,
/// The room below the last hour, clear of the add button (and of Today's
/// Plan over a phone's foot), above the navigation bar's cover.
final double foot = 88,
final ValueChanged<Day>? onStart,
super.key,
}) extends StatefulWidget {
/// The key of the block of [taskId] on [day].
static Key blockKey(String taskId, Day day) =>
ValueKey('grid-block-$taskId-$day');
/// The key of [day]'s column of hours.
static Key columnKey(Day day) => ValueKey('grid-day-$day');
/// The key of [day]'s tasks without a time.
static Key allDayKey(Day day) => ValueKey('grid-all-day-$day');
/// The key of the button opening the overdue tasks.
static const overdueKey = ValueKey('grid-overdue');
/// The key of the hours' scroll view.
static const hoursKey = ValueKey('grid-hours');
/// The key of the line at the time it is now.
static const nowKey = ValueKey('grid-now');
@override
State<HourGrid> createState() => _HourGridState();
}
class _HourGridState() extends State<HourGrid> {
late final _scroll = ScrollController(
initialScrollOffset: widget.firstHour.clamp(0, 23) * _hourHeight,
);
Day? _start;
/// Minutes after midnight now, drawn again as each minute begins.
int _now = _minutesOf(clock.now());
Timer? _minute;
static int _minutesOf(DateTime time) => time.hour * 60 + time.minute;
@override
void initState() {
super.initState();
_wait();
}
/// Draws the line again just after the next minute begins, and waits again.
void _wait() {
final now = clock.now();
final next = DateTime(
now.year,
now.month,
now.day,
now.hour,
now.minute + 1,
);
_minute = Timer(next.difference(now), () {
if (mounted) {
setState(() => _now = _minutesOf(clock.now()));
_wait();
}
});
}
void _go(Day day) {
final today = Day.of(clock.now());
final start = day < today ? today : day;
setState(() => _start = start);
widget.onStart?.call(start);
}
@override
void dispose() {
_minute?.cancel();
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final locale = Localizations.localeOf(context).toString();
final today = Day.of(clock.now());
final start = switch (_start) {
final day? when !(day < today) => day,
_ => today,
};
final count = widget.days;
final several = count > 1;
final (:overdue, :days, busy: _) = widget.calendarFor(
today.daysUntil(start) + count,
);
final shown = [
for (final (day, entries) in days)
if (!(day < start)) (day, layOutDay(entries)),
];
final nowColor = paletteText(context, PaletteColor.cerise);
return Column(
children: [
if (several)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
Flexible(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
DateFormat.yMMMM(locale).format(start.toDate()),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium,
),
),
),
if (start != today)
IconButton(
tooltip: l10n.weekToday,
icon: const Icon(LucideIcons.calendar1),
color: theme.colorScheme.primary,
onPressed: () => _go(today),
),
const Spacer(),
if (overdue.isNotEmpty)
TextButton(
key: HourGrid.overdueKey,
onPressed: () => _showOverdue(context),
child: Text(l10n.overdue),
),
IconButton(
tooltip: l10n.daysPrevious,
icon: const Icon(LucideIcons.chevronLeft),
onPressed: today < start
? () => _go(start.plus(-count))
: null,
),
IconButton(
tooltip: l10n.daysNext,
icon: const Icon(LucideIcons.chevronRight),
onPressed: () => _go(start.plus(count)),
),
],
),
),
// The days' names, and their tasks without a time.
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: _gutter,
child: Padding(
padding: EdgeInsets.only(left: 8, top: several ? 36 : 8),
child: Text(l10n.allDay, style: theme.textTheme.labelSmall),
),
),
for (final (day, (:allDay, blocks: _)) in shown)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (several)
_DayName(day: day, today: today, locale: locale),
_AllDayDropArea(
key: HourGrid.allDayKey(day),
day: day,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final entry in allDay)
Padding(
padding: const EdgeInsets.all(2),
child: SizedBox(
height: 26,
child: _TaskBlock(
key: HourGrid.blockKey(
entry.task.id,
day,
),
entry: entry,
coloring: widget.coloring,
),
),
),
],
),
),
),
],
),
),
],
),
const Divider(height: 1),
Expanded(
child: GestureDetector(
// Swiped across, as many days on or back.
onHorizontalDragEnd: several
? (details) {
final velocity = details.primaryVelocity ?? 0;
if (velocity < -200) {
_go(start.plus(count));
} else if (velocity > 200 && today < start) {
_go(start.plus(-count));
}
}
: null,
child: SingleChildScrollView(
key: HourGrid.hoursKey,
controller: _scroll,
padding: EdgeInsets.only(
bottom: widget.foot + FloatingNavigationBar.coverOf(context),
),
child: SizedBox(
height: 24 * _hourHeight,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: _gutter,
child: Stack(
children: [
for (var hour = 1; hour < 24; hour++)
Positioned(
top: hour * _hourHeight - 8,
left: 8,
child: Text(
timeText(context, hour * 60),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
if (shown.any((shown) => shown.$1 == today))
Positioned(
top: _now / 60 * _hourHeight - 9,
left: 2,
child: DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(4),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
child: Text(
timeText(context, _now),
style: theme.textTheme.labelSmall?.copyWith(
color: nowColor,
fontWeight: FontWeight.w700,
),
),
),
),
),
],
),
),
for (final (day, (allDay: _, :blocks)) in shown)
Expanded(
child: _DayColumn(
key: HourGrid.columnKey(day),
day: day,
blocks: blocks,
coloring: widget.coloring,
now: day == today ? _now : null,
nowColor: nowColor,
),
),
],
),
),
),
),
),
],
);
}
/// The overdue tasks in a sheet, with Reschedule.
Future<void> _showOverdue(BuildContext context) => showAdaptiveSheet<void>(
context,
builder: (context) {
final l10n = AppLocalizations.of(context);
final today = Day.of(clock.now());
final overdue = widget.calendarFor(1).overdue;
// Watched, so a task done or moved leaves the sheet.
context.watch<TodoCubit>();
return SheetScaffold(
title: l10n.overdue,
body: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final task in overdue) TaskTile(task: task, showProject: true),
],
),
footer: Row(
children: [
Text(dayText(context, today, today)),
const Spacer(),
FilledButton(
onPressed: overdue.isEmpty
? null
: () => reschedule(context, overdue),
child: Text(l10n.reschedule),
),
],
),
);
},
);
}
/// A day's name above its column: its weekday and day, today marked.
class const _DayName({
required final Day day,
required final Day today,
required final String locale,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final isToday = day == today;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
DateFormat.E(locale).format(day.toDate()),
style: theme.textTheme.labelLarge,
),
const SizedBox(width: 6),
DecoratedBox(
decoration: BoxDecoration(
color: isToday ? scheme.primary : null,
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
child: Text(
'${day.day}',
style: theme.textTheme.labelLarge?.copyWith(
color: isToday ? scheme.onPrimary : null,
fontWeight: isToday ? FontWeight.w700 : null,
),
),
),
),
],
),
);
}
}
/// A day's hours: a line each, its timed tasks as blocks, a line at [now]
/// (minutes after midnight) where it is today, and a long press on a half
/// hour adding a task there.
class const _DayColumn({
required final Day day,
required final List<GridBlock> blocks,
required final TaskColoring coloring,
required final Color nowColor,
final int? now,
super.key,
}) extends StatefulWidget {
@override
State<_DayColumn> createState() => _DayColumnState();
}
class _DayColumnState() extends State<_DayColumn> {
/// The half hour held, in minutes after midnight, while quick add is open.
int? _held;
Future<void> _add(double dy) async {
final minutes = (dy / (_hourHeight / 2)).floor().clamp(0, 47) * 30;
setState(() => _held = minutes);
await showQuickAdd(
context,
day: widget.day,
time: minutes,
duration: const Duration(minutes: minBlockMinutes),
);
if (mounted) {
setState(() => _held = null);
}
}
/// Where a block dragged over the column would start, in minutes after
/// midnight.
int? _hover;
/// Where a block dragged by [drag] would start with the pointer at
/// [position], across the screen: its top a quarter hour, all of it on
/// the day.
int _startAt(TaskDrag drag, Offset position) {
final box = context.findRenderObject()! as RenderBox;
final top = box.globalToLocal(position).dy - drag.grabY;
final quarters = (top / _hourHeight * 4).round();
return quarters.clamp(0, 24 * 4 - 1) * 15;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final line = scheme.outlineVariant;
return DragTarget<TaskDrag>(
onMove: (details) {
final start = _startAt(details.data, details.offset);
if (start != _hover) {
setState(() => _hover = start);
}
},
onLeave: (_) => setState(() => _hover = null),
onAcceptWithDetails: (details) {
final start = _startAt(details.data, details.offset);
setState(() => _hover = null);
unawaited(moveTask(context, details.data.id, widget.day, time: start));
},
builder: (context, _, _) => _hours(theme, line),
);
}
Widget _hours(ThemeData theme, Color line) {
final scheme = theme.colorScheme;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPressStart: (details) => unawaited(_add(details.localPosition.dy)),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border(left: BorderSide(color: line)),
),
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
double top(int minutes) => minutes / 60 * _hourHeight;
return Stack(
children: [
for (var hour = 1; hour < 24; hour++)
Positioned(
top: top(hour * 60),
left: 0,
right: 0,
child: Divider(height: 1, color: line),
),
if (_held case final held?)
Positioned(
top: top(held),
height: _hourHeight / 2,
left: 2,
right: 2,
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
),
),
if (_hover case final hover?)
Positioned(
top: top(hover),
height: _hourHeight / 2,
left: 2,
right: 2,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.primary.withValues(alpha: 0.12),
border: Border.all(color: scheme.primary),
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
timeText(context, hover),
style: theme.textTheme.labelSmall?.copyWith(
color: scheme.primary,
),
),
),
),
),
),
for (final block in widget.blocks)
Positioned(
top: top(block.start) + 1,
height: top(block.end) - top(block.start) - 2,
left: block.lane * width / block.lanes + 1,
width: width / block.lanes - 2,
child: _TaskBlock(
key: HourGrid.blockKey(
block.entry.task.id,
widget.day,
),
entry: block.entry,
coloring: widget.coloring,
),
),
if (widget.now case final now?)
Positioned(
key: HourGrid.nowKey,
top: top(now) - 4,
left: 0,
right: 0,
height: 8,
child: IgnorePointer(
child: Row(
children: [
DecoratedBox(
decoration: BoxDecoration(
color: widget.nowColor,
shape: BoxShape.circle,
),
child: const SizedBox.square(dimension: 8),
),
Expanded(
child: Container(height: 2, color: widget.nowColor),
),
],
),
),
),
],
);
},
),
),
);
}
}
/// A day's tasks without a time, where a block dropped goes without its
/// time; tinted while one is over it.
class const _AllDayDropArea({
required final Day day,
required final Widget child,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => DragTarget<TaskDrag>(
onAcceptWithDetails: (details) =>
unawaited(moveTask(context, details.data.id, day, allDay: true)),
builder: (context, candidates, _) => DecoratedBox(
decoration: BoxDecoration(
color: candidates.isEmpty
? null
: Theme.of(context).colorScheme.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 32, maxHeight: 96),
child: child,
),
),
);
}
/// A block held and dragged (D17): it follows the pointer where it was
/// grabbed, its place meanwhile faded, the grid scrolling at its edges.
class const _DraggableBlock({
required final String taskId,
required final Widget child,
}) extends StatefulWidget {
@override
State<_DraggableBlock> createState() => _DraggableBlockState();
}
class _DraggableBlockState() extends State<_DraggableBlock> {
late final _drag = TaskDrag(widget.taskId);
var _grab = Offset.zero;
/// One for each scrollable the block is in, innermost first.
var _scrollers = <EdgeDraggingAutoScroller>[];
void _stop() {
for (final scroller in _scrollers) {
scroller.stopAutoScroll();
}
_scrollers = [];
}
@override
void dispose() {
_stop();
super.dispose();
}
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) => Listener(
onPointerDown: (event) {
_grab = event.localPosition;
_drag.grabY = event.localPosition.dy;
},
child: LongPressDraggable<TaskDrag>(
data: _drag,
dragAnchorStrategy: pointerDragAnchorStrategy,
onDragStarted: () {
_scrollers = [
for (
var scrollable = Scrollable.maybeOf(context);
scrollable != null;
scrollable = Scrollable.maybeOf(scrollable.context)
)
EdgeDraggingAutoScroller(scrollable, velocityScalar: 20),
];
},
onDragUpdate: (details) {
for (final scroller in _scrollers) {
scroller.startAutoScrollIfNecessary(
Rect.fromCenter(
center: details.globalPosition,
width: 1,
height: 64,
),
);
}
},
onDragEnd: (_) => _stop(),
onDraggableCanceled: (_, _) => _stop(),
onDragCompleted: _stop,
// Built as the drag starts, once the grab is known.
feedback: Builder(
builder: (context) => Transform.translate(
offset: -_grab,
child: Material(
elevation: 6,
type: MaterialType.transparency,
child: SizedBox.fromSize(
size: constraints.biggest,
child: widget.child,
),
),
),
),
childWhenDragging: Opacity(opacity: 0.4, child: widget.child),
child: widget.child,
),
),
);
}
/// A task's block, coloured by [coloring] (its priority, first label with a
/// colour, or project's), struck through once done, muted with the
/// recurrence's mark for a later time; a tap opens the task, a long press
/// drags it, but for a later time.
class const _TaskBlock({
required final CalendarEntry entry,
required final TaskColoring coloring,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final state = context.watch<TodoCubit>().state;
final content = entry.task.content;
final palette = switch (coloring) {
TaskColoring.priority => null,
TaskColoring.label => [
for (final id in content.labelIds)
for (final label in state.labels)
if (label.id == id) ?label.content?.color,
].firstOrNull,
TaskColoring.project =>
state.projectOf(content.projectId)?.content?.color,
};
final color =
switch (palette) {
final palette? => paletteText(context, palette),
null when coloring == TaskColoring.priority => priorityColor(
context,
content.priority,
),
null => null,
} ??
scheme.outline;
final muted = entry.occurrence || content.completed;
final textStyle = theme.textTheme.labelMedium?.copyWith(
color: muted ? scheme.onSurfaceVariant : null,
decoration: content.completed ? TextDecoration.lineThrough : null,
);
final block = Material(
color: Color.alphaBlend(
color.withValues(alpha: muted ? 0.08 : 0.18),
scheme.surface,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
side: BorderSide(color: color.withValues(alpha: 0.6)),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => openTaskSheet(context, entry.task.id),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
entry.occurrence
? LucideIcons.repeat
: content.completed
? LucideIcons.circleCheck
: LucideIcons.circle,
size: 12,
color: color,
),
const SizedBox(width: 4),
Expanded(
child: Text(
content.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: textStyle,
),
),
],
),
),
),
);
return entry.occurrence
? block
: _DraggableBlock(taskId: entry.task.id, child: block);
}
}

View file

@ -12,8 +12,8 @@ import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dar
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_refresh.dart';
import 'package:halkyon_todo_flutter/src/tabs/day_agenda.dart';
import 'package:halkyon_todo_flutter/src/tabs/hour_grid.dart';
import 'package:halkyon_todo_flutter/src/tabs/tab_screen.dart';
import 'package:halkyon_todo_flutter/src/tabs/three_day_grid.dart';
import 'package:halkyon_todo_flutter/src/tabs/week_strip.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
@ -21,7 +21,7 @@ import 'package:material_ui/material_ui.dart';
/// Upcoming (ADR 0033, D2, D8): every task of the person's from today on
/// that its Display keeps (D17, in their settings), as an agenda
/// ([DayAgenda], its project at the end of each row) or three days side by
/// side ([ThreeDayGrid]); the add button adds a task for the day picked.
/// side ([HourGrid]); the add button adds a task for the day picked.
class const UpcomingTab({super.key}) extends StatefulWidget {
@override
State<UpcomingTab> createState() => _UpcomingTabState();
@ -80,7 +80,7 @@ class _UpcomingTabState() extends State<UpcomingTab> {
calendarFor: calendarFor,
),
),
UpcomingLayout.threeDay => ThreeDayGrid(
UpcomingLayout.threeDay => HourGrid(
coloring: upcoming.coloring,
onStart: select,
calendarFor: calendarFor,

View file

@ -28,7 +28,7 @@ import 'package:halkyon_todo_flutter/src/feature/task/widget/quick_add_sheet.dar
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_sheet.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
import 'package:halkyon_todo_flutter/src/tabs/three_day_grid.dart';
import 'package:halkyon_todo_flutter/src/tabs/hour_grid.dart';
import 'package:halkyon_todo_flutter/src/tabs/today_icon.dart';
import 'package:halkyon_todo_flutter/src/tabs/week_strip.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -1071,13 +1071,13 @@ void main() {
);
await threeDay(tester);
expect(find.byType(ThreeDayGrid), findsOneWidget);
expect(find.byType(HourGrid), findsOneWidget);
for (final day in [today, today.plus(1), today.plus(2)]) {
expect(find.byKey(ThreeDayGrid.columnKey(day)), findsOneWidget);
expect(find.byKey(HourGrid.columnKey(day)), findsOneWidget);
}
final stored = await tester.runAsync(() => tasks().all());
final dentist = find.byKey(
ThreeDayGrid.blockKey(
HourGrid.blockKey(
stored!.singleWhere((t) => t.content?.title == 'Dentist').id,
today.plus(1),
),
@ -1098,7 +1098,7 @@ void main() {
await start(tester, usedBefore: true);
await threeDay(tester);
final today = Day.of(DateTime.now());
final column = find.byKey(ThreeDayGrid.columnKey(today));
final column = find.byKey(HourGrid.columnKey(today));
// 10:00 is 600 logical pixels down the day.
await tester.longPressAt(
tester.getTopLeft(column) + const Offset(20, 10 * 60 + 10),
@ -1164,14 +1164,14 @@ void main() {
'snapping to a quarter hour; which can be undone', (tester) async {
final id = await dentist(tester);
final today = Day.of(DateTime.now());
final block = find.byKey(ThreeDayGrid.blockKey(id, today.plus(1)));
final block = find.byKey(HourGrid.blockKey(id, today.plus(1)));
await tester.ensureVisible(block);
await tester.pumpAndSettle();
// Held 20 minutes below its top, dropped where 14:27 is under the
// pointer: its top at 14:07, which snaps to 14:00.
final from = tester.getTopLeft(block) + const Offset(10, 20);
final column = tester.getTopLeft(
find.byKey(ThreeDayGrid.columnKey(today)),
find.byKey(HourGrid.columnKey(today)),
);
await drag(tester, from, column + const Offset(20, 14 * 60 + 27));
@ -1185,7 +1185,7 @@ void main() {
expect(moved.time, 14 * 60);
expect(moved.duration, const Duration(hours: 1));
expect(moved.repeat, const Repeat(RepeatUnit.week));
expect(find.byKey(ThreeDayGrid.blockKey(id, today)), findsOneWidget);
expect(find.byKey(HourGrid.blockKey(id, today)), findsOneWidget);
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
@ -1197,12 +1197,12 @@ void main() {
testWidgets('onto a day’s All day takes its time away', (tester) async {
final id = await dentist(tester);
final today = Day.of(DateTime.now());
final block = find.byKey(ThreeDayGrid.blockKey(id, today.plus(1)));
final block = find.byKey(HourGrid.blockKey(id, today.plus(1)));
await tester.ensureVisible(block);
await tester.pumpAndSettle();
final from = tester.getTopLeft(block) + const Offset(10, 20);
final allDay = tester.getCenter(
find.byKey(ThreeDayGrid.allDayKey(today.plus(2))),
find.byKey(HourGrid.allDayKey(today.plus(2))),
);
await drag(tester, from, allDay);
@ -1232,11 +1232,11 @@ void main() {
await tester.tap(find.byTooltip('Later days'));
await tester.pumpAndSettle();
final today = Day.of(DateTime.now());
final later = find.byKey(ThreeDayGrid.blockKey(id, today.plus(8)));
final later = find.byKey(HourGrid.blockKey(id, today.plus(8)));
await tester.ensureVisible(later);
await tester.pumpAndSettle();
final column = tester.getTopLeft(
find.byKey(ThreeDayGrid.columnKey(today.plus(6))),
find.byKey(HourGrid.columnKey(today.plus(6))),
);
await drag(
tester,