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:
parent
bf9818e22d
commit
0f8a559d2e
2 changed files with 76 additions and 22 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue