fix(todo): quick add highlights a word while the keyboard still composes it

On Android, keyboards with suggestions (Gboard) and Japanese ones keep
the word being typed composing until a space. The title's controller
fell back to the plain composing span whenever a composing range was
set, so "today" lit up only after the space or once the keyboard
closed. It now always draws the tokens' highlight, cut where a token or
the composing range starts or ends, with the composing underline on top
where they overlap.

Whats-New: Add task highlights dates and other words as you type them, not only after a space.
Whats-New-De: „Aufgabe hinzufügen“ hebt Daten und andere Wörter schon beim Tippen hervor, nicht erst nach einem Leerzeichen.

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 22:16:10 +01:00
commit 0f8a559d2e
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 76 additions and 22 deletions

View file

@ -700,7 +700,10 @@ class const QuickAddButton({
}
}
/// The title's controller, its [tokens] highlighted.
/// The title's controller, its [tokens] highlighted, also while the
/// keyboard still composes them: Android's keyboards (Gboard with
/// suggestions, Japanese ones) compose the word typed until a space, and a
/// plain composing span had hidden its highlight until then.
class _TokenController() extends TextEditingController {
List<QuickAddToken> tokens = const [];
Color? highlight;
@ -712,37 +715,51 @@ class _TokenController() extends TextEditingController {
required bool withComposing,
TextStyle? style,
}) {
final shown = [
for (final token in tokens)
if (token.end <= text.length) token,
];
final composing =
withComposing &&
value.isComposingRangeValid &&
!value.composing.isCollapsed;
if (tokens.isEmpty || composing) {
return super.buildTextSpan(
context: context,
style: style,
withComposing: withComposing,
);
value.isComposingRangeValid &&
!value.composing.isCollapsed
? value.composing
: null;
if (shown.isEmpty && composing == null) {
return TextSpan(style: style, text: text);
}
final marked = TextStyle(
backgroundColor: highlight,
color: onHighlight,
);
const underlined = TextStyle(decoration: TextDecoration.underline);
// Split where a token or the composing range starts or ends.
final cuts = <int>{
0,
text.length,
for (final token in shown) ...[token.start, token.end],
if (composing != null) ...[composing.start, composing.end],
}.toList()..sort();
final children = <TextSpan>[];
var from = 0;
for (final token in tokens) {
if (token.end > text.length) {
break;
}
if (token.start > from) {
children.add(TextSpan(text: text.substring(from, token.start)));
}
children.add(
TextSpan(text: text.substring(token.start, token.end), style: marked),
for (var i = 0; i + 1 < cuts.length; i++) {
final from = cuts[i];
final to = cuts[i + 1];
final inToken = shown.any(
(token) => token.start <= from && to <= token.end,
);
final inComposing =
composing != null && composing.start <= from && to <= composing.end;
children.add(
TextSpan(
text: text.substring(from, to),
style: switch ((inToken, inComposing)) {
(true, true) => marked.merge(underlined),
(true, false) => marked,
(false, true) => underlined,
(false, false) => null,
},
),
);
from = token.end;
}
if (from < text.length) {
children.add(TextSpan(text: text.substring(from)));
}
return TextSpan(style: style, children: children);
}

View file

@ -1463,6 +1463,43 @@ void main() {
expect(stored.single.content?.date, isNull);
});
testWidgets('a token is highlighted while the keyboard still composes '
'it, the composing underline over it (Gboard, Japanese IMEs)', (
tester,
) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Inbox');
await tester.tap(find.byTooltip('Add task'));
await tester.pumpAndSettle();
await tester.showKeyboard(find.byKey(quickAddTitleKey));
const text = 'Pay rent today';
tester.testTextInput.updateEditingValue(
const TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
composing: TextRange(start: 9, end: text.length),
),
);
await tester.pumpAndSettle();
final field = find.byKey(quickAddTitleKey);
final span = tester
.widget<TextField>(field)
.controller!
.buildTextSpan(
context: tester.element(field),
withComposing: true,
);
final today = span.children!.cast<TextSpan>().singleWhere(
(child) => child.text == 'today',
);
expect(today.style?.backgroundColor, isNotNull, reason: 'highlighted');
expect(today.style?.decoration, TextDecoration.underline);
final rest = span.children!.cast<TextSpan>().first;
expect(rest.text, 'Pay rent ');
expect(rest.style?.decoration, isNull);
});
testWidgets('a chip picked takes its token out of the title; the down '
'arrow and the + open the description', (tester) async {
await start(tester, usedBefore: true);