fix(todo): the hours' time now no longer overlaps an hour, and run down to the bar

The hours' column labelled 22:00 and, in red, the time now 22:12 over
each other. As Todoist's (its screenshots at 21:50, 21:51, 21:55, 22:05,
22:09 and 22:10): an hour's label gives way while the time now is within
nine minutes of it, either side, wherever the line at the time now is
drawn (Today's Calendar, Upcoming's 3-day, the Inbox's 3-day), so at
21:51 to 22:09 the time now stands alone and at 21:50 and 22:10 both do
(`hourLabelShown`).

The hours left a strip as high as the add button empty below midnight,
above the floating navigation bar. They run on down to the bar now, the
day's end just above it and the hours scrolling on under it before, as
Todoist's; the add button slides away as they scroll down. Where a
phone's Plan covers their foot, they still end above it.

Whats-New: The calendar's hours no longer show an hour over the time now, and run on down to the navigation bar.
Whats-New-De: Die Stunden des Kalenders zeigen keine Stunde mehr über der aktuellen Uhrzeit und reichen bis zur Navigationsleiste.
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-07 23:03:32 +01:00
commit 6c095fab47
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 140 additions and 16 deletions

View file

@ -84,7 +84,6 @@ class const ProjectCalendarView({
if (view.calendarLayout == CalendarLayout.threeDay &&
(state.projectOf(projectId)?.inbox ?? false)) {
return HourGrid(
foot: 0,
coloring: TaskColoring.priority,
calendarFor: calendarFor,
);

View file

@ -25,6 +25,13 @@ const _hourHeight = 60.0;
/// How wide the hours' column is.
const _gutter = 56.0;
/// Whether the label of [hour] shows in the hours' column, the time [now]
/// (minutes after midnight) labelled there too where given: within nine
/// minutes of it, either side, the hour's label gives way to it, as
/// Todoist's (the developer's review, 7 Nov 2026: the two overlapped).
bool hourLabelShown(int hour, {int? now}) =>
now == null || (now - hour * 60).abs() > 9;
/// 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
@ -48,9 +55,12 @@ class const HourGrid({
/// 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,
/// The room below the last hour above the navigation bar's cover: none,
/// the hours running on down to the bar as Todoist's (the developer's
/// review, 7 Nov 2026: a strip as high as the add button was left empty
/// at the day's end, the button sliding away as the hours scroll on);
/// the Plan's height where it covers a phone's foot.
final double foot = 0,
final ValueChanged<Day>? onStart,
super.key,
}) extends StatefulWidget {
@ -147,6 +157,7 @@ class _HourGridState() extends State<HourGrid> {
if (!(day < start)) (day, layOutDay(entries)),
];
final nowColor = paletteText(context, PaletteColor.cerise);
final nowShown = shown.any((shown) => shown.$1 == today);
return Column(
children: [
if (several)
@ -280,17 +291,21 @@ class _HourGridState() extends State<HourGrid> {
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 (hourLabelShown(
hour,
now: nowShown ? _now : null,
))
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))
if (nowShown)
Positioned(
top: _now / 60 * _hourHeight - 9,
left: 2,

View file

@ -100,13 +100,13 @@ mixin PlanHost<T extends StatefulWidget> on State<T> {
kind: SidePaneKind.plan,
widths: PersonScope.of<TodoPerson>(context).paneWidths,
fallback: 340,
main: grid(_grid, 88),
main: grid(_grid, 0),
pane: PlanPanel(
projectIds: _projects,
onProjects: projects,
),
)
: grid(_grid, 88);
: grid(_grid, 0);
}
// A phone's Plan over the hours' foot, above the floating bar; held
// rows lower it to its heading, the hours free behind it.
@ -115,7 +115,7 @@ mixin PlanHost<T extends StatefulWidget> on State<T> {
const heading = 56.0;
return Stack(
children: [
Positioned.fill(child: grid(_grid, _sheet ? height : 88)),
Positioned.fill(child: grid(_grid, _sheet ? height : 0)),
if (_sheet)
Positioned(
left: 0,

View file

@ -1834,6 +1834,34 @@ void main() {
await done(tester);
}
testWidgets("the hours run on down to the navigation bar, the day's "
"end just above it, as Todoist's (the developer's review, 7 Nov "
'2026)', (tester) async {
await start(tester, usedBefore: true);
await threeDay(tester);
final hours = find.descendant(
of: find.byKey(HourGrid.hoursKey),
matching: find.byType(Scrollable),
);
final position = tester.state<ScrollableState>(hours).position;
position.jumpTo(position.maxScrollExtent);
await tester.pumpAndSettle();
final today = Day.of(DateTime.now());
final bar = tester.getTopLeft(find.byType(FloatingNavigationBar)).dy;
expect(
tester.getBottomLeft(find.byKey(HourGrid.columnKey(today))).dy,
moreOrLessEquals(bar, epsilon: 1),
);
// Further up, the hours go on under the bar.
position.jumpTo(position.maxScrollExtent - 200);
await tester.pumpAndSettle();
expect(
tester.getBottomLeft(find.byKey(HourGrid.columnKey(today))).dy,
greaterThan(900 - 1),
);
});
testWidgets('the arrows stay at the left, whatever the way back to '
'today or what is overdue beside them', (tester) async {
await start(tester, usedBefore: true);

View file

@ -0,0 +1,82 @@
import 'package:clock/clock.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/feature/settings/model/todo_settings.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/tabs/hour_grid.dart';
import 'package:material_ui/material_ui.dart';
void main() {
int at(int hour, int minute) => hour * 60 + minute;
group("an hour's label beside the time now (the developer's review, 7 Nov "
"2026, as Todoist's at 21:50, 21:51, 21:55, 22:05, 22:09, 22:10)", () {
test('gives way within nine minutes of it, either side', () {
for (final now in [at(21, 51), at(21, 55), at(22, 0), at(22, 5)]) {
expect(hourLabelShown(22, now: now), isFalse, reason: '$now');
}
expect(hourLabelShown(22, now: at(22, 9)), isFalse);
});
test('shows from ten minutes away on', () {
expect(hourLabelShown(22, now: at(21, 50)), isTrue);
expect(hourLabelShown(22, now: at(22, 10)), isTrue);
expect(hourLabelShown(21, now: at(22, 0)), isTrue);
expect(hourLabelShown(23, now: at(22, 0)), isTrue);
});
test('always shows where no time now is drawn', () {
expect(hourLabelShown(22), isTrue);
});
});
/// [days] of hours, empty, at [now] today.
Future<void> grid(WidgetTester tester, DateTime now, {int days = 1}) async {
tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await withClock(Clock.fixed(now), () async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('en', 'GB'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: HourGrid(
days: days,
firstHour: now.hour - 1,
coloring: TaskColoring.priority,
calendarFor: (count) => (
overdue: const [],
days: [
for (var day = 0; day < count; day++)
(Day.of(now).plus(day), const []),
],
busy: const {},
),
),
),
),
);
});
}
for (final days in [1, 3]) {
testWidgets('$days day(s): at 22:05 only the time now stands at 22, the '
'hours around it labelled', (tester) async {
await grid(tester, DateTime(2026, 11, 7, 22, 5), days: days);
expect(find.text('22:05'), findsOneWidget);
expect(find.text('22:00'), findsNothing);
expect(find.text('21:00'), findsOneWidget);
expect(find.text('23:00'), findsOneWidget);
});
testWidgets('$days day(s): at 21:50 both stand', (tester) async {
await grid(tester, DateTime(2026, 11, 7, 21, 50), days: days);
expect(find.text('21:50'), findsOneWidget);
expect(find.text('22:00'), findsOneWidget);
});
}
}