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:
parent
8a677e0845
commit
a71d80fc2d
2 changed files with 141 additions and 47 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart' show kLongPressTimeout;
|
||||
import 'package:flutter/rendering.dart' show RenderParagraph;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
|
@ -1878,6 +1879,54 @@ void main() {
|
|||
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 '
|
||||
'the day picked', (tester) async {
|
||||
final today = Day.of(DateTime.now());
|
||||
|
|
|
|||
Loading…
Reference in a new issue