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

View file

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:clock/clock.dart';
import 'package:flutter/semantics.dart';
@ -148,63 +149,107 @@ class const TaskTile({
? TaskSelectionScope.maybeOf(context)
: null;
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(
selected: chosen,
selectedTileColor: theme.colorScheme.secondaryContainer,
selectedColor: theme.colorScheme.onSecondaryContainer,
contentPadding: const EdgeInsets.only(left: 4, right: 16),
leading: later
? Tooltip(
message: l10n.taskLater,
child: SizedBox.square(
dimension: 48,
child: Icon(LucideIcons.repeat, color: muted, size: 20),
minVerticalPadding: 4,
title: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
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,
),
)
: TaskCheck(task: task),
title: Text(
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)
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
content.description.split('\n').first,
maxLines: 1,
overflow: TextOverflow.ellipsis,
content.title,
style: content.completed
? TextStyle(
decoration: TextDecoration.lineThrough,
color: muted,
)
: later
? TextStyle(color: muted)
: null,
),
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,
],
if (subtitle != null)
DefaultTextStyle.merge(
style: theme.textTheme.bodyMedium?.copyWith(
color: chosen
? theme.colorScheme.onSecondaryContainer
: muted,
),
child: subtitle,
),
),
],
],
),
),
),
],
),
onTap: selection != null && selection.active
? () => selection.toggle(task.id)
: () => openTaskSheet(context, task.id),