fix(todo,ui): Today's day centred in its calendar, and the way back to today shows it

Today's tab drew its day centred at 16 of the calendar's 24 units, where
Lucide's SVG has its body's middle; its font draws the calendar a unit
higher (the top bar's stroke from 8 to 10, the foot's from 20 to 22,
measured with the real fonts), so the digits sat a unit and a half low.
halkyon_ui's new DayCalendarIcon centres them at 15, in Noto Sans set
explicitly, and its tests draw it with the real fonts at 16 to 32 pixels:
the digits centred in the body within half a unit, and 10, 20, 28 and 31
clear of the sides. Todo's TodayIcon keeps the midnight redraw around it.

The agenda's, the 3-day grid's and the month's way back to today showed
Lucide's calendar-1; it shows today's day in the same calendar now, as
Todoist's.

Whats-New: Today's date sits centred in its calendar icon, and the button back to today shows it too.
Whats-New-De: Das heutige Datum steht mittig in seinem Kalendersymbol, und der Knopf zurück zu heute zeigt es auch.
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 22:57:44 +01:00
commit 28e07dbf9d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 249 additions and 74 deletions

View file

@ -21,6 +21,7 @@ export 'src/widget/collapsing_row.dart';
export 'src/widget/confirm_dialog.dart';
export 'src/widget/count_badge.dart';
export 'src/widget/count_stepper.dart';
export 'src/widget/day_calendar_icon.dart';
export 'src/widget/empty_list_message.dart';
export 'src/widget/explained_title.dart';
export 'src/widget/field_label.dart';

View file

@ -0,0 +1,68 @@
import 'package:halkyon_ui/src/theme/app_fonts.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Lucide's blank calendar with a day of the month in it, as its
/// `calendar-1` holds a 1 (Todoist's Today, and its way back to today): the
/// day's digits centred in the calendar's body, below its top bar, two of
/// them fitting side by side at any size. Sized and coloured by the
/// [IconTheme], as an [Icon].
class const DayCalendarIcon({
/// The day of the month, 1 to 31.
required final int day,
super.key,
}) extends StatelessWidget {
/// The digits' size, in the calendar's 24 units.
static const fontSize = 9.6;
/// Noto Sans' digits' height, of the font's size.
static const _digitHeight = 0.714;
/// Where the body's inside is centred, in 24 units: Lucide's font draws
/// the calendar a unit above its SVG, its top bar's stroke from 8 to 10 and
/// its foot's from 20 to 22 (measured, 7 Nov 2026), so the inside runs
/// from 10 to 20. Centred at 16, as the SVG's inside is, the day sat a
/// unit and a half low (the developer's review, 7 Nov 2026).
static const bodyCentre = 15.0;
@override
Widget build(BuildContext context) {
final icon = IconTheme.of(context);
final size = icon.size ?? 24;
return SizedBox.square(
dimension: size,
child: Stack(
alignment: Alignment.center,
children: [
const Icon(LucideIcons.calendar),
// Placed by its baseline, a line box's place in it being the font's
// to choose.
Positioned.fill(
child: Align(
alignment: Alignment.topCenter,
child: Baseline(
baseline:
size * (bodyCentre + _digitHeight * fontSize / 2) / 24,
baselineType: TextBaseline.alphabetic,
child: Text(
'$day',
textScaler: TextScaler.noScaling,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.visible,
style: TextStyle(
color: icon.color,
fontFamily: appFontFamily,
fontSize: size * fontSize / 24,
height: 1,
fontWeight: FontWeight.w700,
),
),
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,130 @@
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Where ink is drawn, in the calendar's 24 units.
typedef DigitInk = ({double left, double top, double right, double bottom});
void main() {
setUpAll(() async {
// The real fonts, as a phone draws them: the test font's square glyphs
// say nothing about where the digits' ink stands.
final noto = FontLoader(appFontFamily)
..addFont(rootBundle.load('assets/fonts/NotoSans-Variable.ttf'));
final lucide = FontLoader('packages/lucide_icons_flutter/Lucide')
..addFont(
rootBundle.load('packages/lucide_icons_flutter/assets/lucide.ttf'),
);
await Future.wait([noto.load(), lucide.load()]);
});
/// The alpha channel of [child] drawn at [size], ten pixels a unit.
Future<(int, List<bool>)> draw(
WidgetTester tester,
Widget child,
double size,
) async {
final key = GlobalKey();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: IconTheme(
data: IconThemeData(size: size, color: const Color(0xFF000000)),
child: Center(
child: RepaintBoundary(key: key, child: child),
),
),
),
);
final boundary =
key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final drawn = await tester.runAsync(() async {
final image = await boundary.toImage(pixelRatio: 240 / size);
final bytes = await image.toByteData();
final width = image.width;
image.dispose();
final rgba = bytes!.buffer.asUint8List();
return (
width,
[for (var i = 3; i < rgba.length; i += 4) rgba[i] > 128],
);
});
return drawn!;
}
/// The ink of the day [day] at [size], apart from the calendar's own.
Future<DigitInk> digits(WidgetTester tester, int day, double size) async {
final (width, calendar) = await draw(
tester,
SizedBox.square(
dimension: size,
child: const Icon(LucideIcons.calendar),
),
size,
);
final (_, icon) = await draw(tester, DayCalendarIcon(day: day), size);
var left = width;
var top = width;
var right = -1;
var bottom = -1;
for (var i = 0; i < icon.length; i++) {
if (icon[i] && !calendar[i]) {
final x = i % width;
final y = i ~/ width;
if (x < left) {
left = x;
}
if (x > right) {
right = x;
}
if (y < top) {
top = y;
}
if (y > bottom) {
bottom = y;
}
}
}
return (
left: left / 10,
top: top / 10,
right: (right + 1) / 10,
bottom: (bottom + 1) / 10,
);
}
for (final size in [16.0, 18.0, 20.0, 24.0, 32.0]) {
testWidgets('at $size pixels, the day stands centred in the body, below '
"its top bar (the developer's review, 7 Nov 2026)", (tester) async {
final ink = await digits(tester, 7, size);
// Lucide's font draws the body's inside from 10 to 20 units.
expect(
(ink.top + ink.bottom) / 2,
moreOrLessEquals(15, epsilon: 0.5),
);
expect(ink.top, greaterThan(10));
expect(ink.bottom, lessThan(20));
});
testWidgets('at $size pixels, two digits fit between the sides', (
tester,
) async {
for (final day in [10, 20, 28, 31]) {
final ink = await digits(tester, day, size);
// The sides' strokes end at 4 and begin at 20 units; a unit clear.
expect(ink.left, greaterThan(5), reason: '$day');
expect(ink.right, lessThan(19), reason: '$day');
expect(
(ink.left + ink.right) / 2,
moreOrLessEquals(12, epsilon: 0.75),
reason: '$day',
);
}
});
}
}