fix(todo): a task row's check stands by its title's first line

The round check sat in the middle of the row, so on a row with a
description, a date or a long title it stood below the title. As
Todoist's it now stands by the title's first line. ListTile centres its
leading or tops it at its padding, never at the title's line, so the
check and the texts share the tile's title, the texts lowered by half
of what the check's 48 dp tap target is taller than a line (computed
from the text scale, the tap target kept).

Whats-New: A task's circle stands beside the first line of its title.
Whats-New-De: Der Kreis einer Aufgabe steht neben der ersten Zeile ihres Titels.

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 20:41:24 +01:00
commit a71d80fc2d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 141 additions and 47 deletions

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:flutter/semantics.dart'; import 'package:flutter/semantics.dart';
@ -148,63 +149,107 @@ class const TaskTile({
? TaskSelectionScope.maybeOf(context) ? TaskSelectionScope.maybeOf(context)
: null; : null;
final chosen = selection?.ids.contains(task.id) ?? false; final chosen = selection?.ids.contains(task.id) ?? false;
final subtitle =
details.isEmpty && content.description.isEmpty && where == null
? null
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (content.description.isNotEmpty)
Text(
content.description.split('\n').first,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (details.isNotEmpty || where != null)
Padding(
padding: const EdgeInsets.only(top: 2),
// Where the task is ends the line of its date, as its
// place, not a detail of it (the developer's, 1 Nov).
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Wrap(
spacing: 12,
runSpacing: 2,
children: details,
),
),
?where,
],
),
),
],
);
// The check stands by the title's first line, not in the middle of the
// row, as Todoist's (the developer's review, 4 Nov 2026): a ListTile
// centres its leading or tops it at a padding, never at the title's
// line, so the check and the texts share its title, the texts lowered
// by half of what the check's 48 dp tap target is taller than a line.
final titleStyle = theme.textTheme.bodyLarge;
final line = MediaQuery.textScalerOf(
context,
).scale((titleStyle?.fontSize ?? 16) * (titleStyle?.height ?? 1.5));
const target = 48.0;
final check = later
? Tooltip(
message: l10n.taskLater,
child: SizedBox.square(
dimension: target,
child: Icon(LucideIcons.repeat, color: muted, size: 20),
),
)
: TaskCheck(task: task);
final tile = ListTile( final tile = ListTile(
selected: chosen, selected: chosen,
selectedTileColor: theme.colorScheme.secondaryContainer, selectedTileColor: theme.colorScheme.secondaryContainer,
selectedColor: theme.colorScheme.onSecondaryContainer, selectedColor: theme.colorScheme.onSecondaryContainer,
contentPadding: const EdgeInsets.only(left: 4, right: 16), contentPadding: const EdgeInsets.only(left: 4, right: 16),
leading: later minVerticalPadding: 4,
? Tooltip( title: Row(
message: l10n.taskLater, crossAxisAlignment: CrossAxisAlignment.start,
child: SizedBox.square( children: [
dimension: 48, Padding(
child: Icon(LucideIcons.repeat, color: muted, size: 20), padding: EdgeInsets.only(top: math.max(0, (line - target) / 2)),
child: check,
),
const SizedBox(width: 16),
Expanded(
child: Padding(
padding: EdgeInsets.only(
top: math.max(0, (target - line) / 2),
bottom: 4,
), ),
) child: Column(
: TaskCheck(task: task), crossAxisAlignment: CrossAxisAlignment.start,
title: Text( children: [
content.title,
style: content.completed
? TextStyle(
decoration: TextDecoration.lineThrough,
color: muted,
)
: later
? TextStyle(color: muted)
: null,
),
subtitle: details.isEmpty && content.description.isEmpty && where == null
? null
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (content.description.isNotEmpty)
Text( Text(
content.description.split('\n').first, content.title,
maxLines: 1, style: content.completed
overflow: TextOverflow.ellipsis, ? TextStyle(
decoration: TextDecoration.lineThrough,
color: muted,
)
: later
? TextStyle(color: muted)
: null,
), ),
if (details.isNotEmpty || where != null) if (subtitle != null)
Padding( DefaultTextStyle.merge(
padding: const EdgeInsets.only(top: 2), style: theme.textTheme.bodyMedium?.copyWith(
// Where the task is ends the line of its date, as its color: chosen
// place, not a detail of it (the developer's, 1 Nov). ? theme.colorScheme.onSecondaryContainer
child: Row( : muted,
crossAxisAlignment: CrossAxisAlignment.start, ),
children: [ child: subtitle,
Expanded(
child: Wrap(
spacing: 12,
runSpacing: 2,
children: details,
),
),
?where,
],
), ),
), ],
], ),
), ),
),
],
),
onTap: selection != null && selection.active onTap: selection != null && selection.active
? () => selection.toggle(task.id) ? () => selection.toggle(task.id)
: () => openTaskSheet(context, task.id), : () => openTaskSheet(context, task.id),

View file

@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' show kLongPressTimeout; import 'package:flutter/gestures.dart' show kLongPressTimeout;
import 'package:flutter/rendering.dart' show RenderParagraph;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart'; import 'package:halkyon_app/halkyon_app.dart';
@ -1878,6 +1879,54 @@ void main() {
expect(count, findsNothing); expect(count, findsNothing);
}); });
testWidgets("a row's check stands by its title's first line, not in the "
"middle of the row, as Todoist's (the developer's review, 4 Nov "
'2026)', (tester) async {
await start(tester, usedBefore: true);
for (final title in [
'Short',
'A title long enough to run onto a second line and even onto a third',
]) {
await seed(
tester,
TaskContent(
title: title,
description: 'With a description',
projectId: inbox(),
date: TaskDate(Day.of(DateTime.now()).plus(1)),
),
);
}
await goTab(tester, 'Inbox');
for (final title in ['Short', 'A title long']) {
final tile = find.ancestor(
of: find.textContaining(title),
matching: find.byType(ListTile),
);
final check = tester.getRect(
find.descendant(of: tile, matching: find.byIcon(LucideIcons.circle)),
);
final text = find.textContaining(title);
final paragraph = tester.renderObject<RenderParagraph>(text);
final firstLine = paragraph
.getBoxesForSelection(
const TextSelection(baseOffset: 0, extentOffset: 1),
)
.first;
final lineCentre =
tester.getTopLeft(text).dy + (firstLine.top + firstLine.bottom) / 2;
expect(check.center.dy, moreOrLessEquals(lineCentre, epsilon: 1));
expect(
tester.getSize(
find.descendant(of: tile, matching: find.byType(IconButton)),
),
const Size.square(48),
reason: 'its tap target kept',
);
}
});
testWidgets('Today puts what is overdue first; Reschedule moves it to ' testWidgets('Today puts what is overdue first; Reschedule moves it to '
'the day picked', (tester) async { 'the day picked', (tester) async {
final today = Day.of(DateTime.now()); final today = Day.of(DateTime.now());