feat(todo): quick add keeps a word as text where it stands, and from Backspace (ADR 0033, D29)

A word tapped was kept as text by its text, so in "Watch The Daily Show
Daily" tapping the first "Daily" also stopped the second one making the
task repeat (the developer's example). Kept words are now ranges of the
title that follow its edits (`shiftKept`: ranges after the edit move by
its length, one it touches is dropped), and the parser leaves a token
overlapping one of them as text, wherever else the same word stands.

Backspace right after a highlighted word keeps it as text instead of
deleting its last character, as Todoist's; the next Backspace deletes.
It is an input formatter, so the keyboard is given the text back. The
hint under the title says so.

Whats-New: In Add task, only the word you tap stays text; the same word elsewhere still counts.
Whats-New: In Add task, Backspace right after a highlighted word keeps it as text.
Whats-New-De: In „Aufgabe hinzufügen“ bleibt nur das angetippte Wort Text; dasselbe Wort an anderer Stelle zählt weiter.
Whats-New-De: In „Aufgabe hinzufügen“ behält die Rücktaste direkt nach einem hervorgehobenen Wort es als Text.

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:23:03 +01:00
commit 8b13cbcf2b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
10 changed files with 283 additions and 31 deletions

File diff suppressed because one or more lines are too long

View file

@ -181,7 +181,7 @@
"swipeDate": "Datum",
"tasksMenu": "Mehr",
"quickAddMore": "Mehr",
"quickAddTokenHint": "Tippe ein hervorgehobenes Wort an, um es als Text zu behalten.",
"quickAddTokenHint": "Tippe ein hervorgehobenes Wort an oder drücke direkt danach die Rücktaste, um es als Text zu behalten.",
"deadlineTitle": "Frist",
"orderChanged": "Reihenfolge geändert",
"addTaskCancel": "Hier ablegen zum Abbrechen",

View file

@ -505,7 +505,7 @@
"@quickAddMore": {
"description": "Quick add's + menu, holding Description and Deadline (ADR 0033, D15)."
},
"quickAddTokenHint": "Tap a highlighted word to keep it as text.",
"quickAddTokenHint": "Tap a highlighted word, or press Backspace right after it, to keep it as text.",
"@quickAddTokenHint": {
"description": "Below quick add's title while it has tokens recognised (D15)."
},

View file

@ -1189,7 +1189,7 @@ abstract class AppLocalizations {
/// Below quick add's title while it has tokens recognised (D15).
///
/// In en, this message translates to:
/// **'Tap a highlighted word to keep it as text.'**
/// **'Tap a highlighted word, or press Backspace right after it, to keep it as text.'**
String get quickAddTokenHint;
/// Quick add's deadline chip and menu entry (D15).

View file

@ -771,7 +771,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get quickAddTokenHint =>
'Tippe ein hervorgehobenes Wort an, um es als Text zu behalten.';
'Tippe ein hervorgehobenes Wort an oder drücke direkt danach die Rücktaste, um es als Text zu behalten.';
@override
String get deadlineTitle => 'Frist';

View file

@ -770,7 +770,8 @@ class AppLocalizationsEn extends AppLocalizations {
String get quickAddMore => 'More';
@override
String get quickAddTokenHint => 'Tap a highlighted word to keep it as text.';
String get quickAddTokenHint =>
'Tap a highlighted word, or press Backspace right after it, to keep it as text.';
@override
String get deadlineTitle => 'Deadline';

View file

@ -65,22 +65,59 @@ typedef NamedId = ({String id, String name});
/// and dates (`on` before one taken with it), times, durations (`for 2h`,
/// with a time alone) and recurrences in [words]. Only the first of each
/// kind counts, but for reminders and labels, each of which counts once. A
/// token whose text, in lower case, is among [ignored] (tapped off by the
/// person) stays text.
/// token overlapping one of the [ignored] ranges (kept as text by the
/// person, D29) stays text; the same words elsewhere still count.
ParsedTask parseQuickAdd(
String text, {
required DateTime now,
required List<QuickAddWords> words,
List<NamedId> projects = const [],
List<NamedId> labels = const [],
Set<String> ignored = const {},
List<QuickAddRange> ignored = const [],
}) => _Parser(text, now, words, ignored).parse(projects, labels);
/// A stretch of quick add's text, from `start` to `end`.
typedef QuickAddRange = ({int start, int end});
/// The [kept] ranges of [before] where they are in [after], [before]
/// edited (ADR 0033, D29): the edit found as what lies between the two
/// texts' common start and end, the ranges after it moved by how much
/// longer or shorter it made the text, a range it touches dropped (its
/// word changed). Typing right before or after a range leaves it whole.
List<QuickAddRange> shiftKept(
List<QuickAddRange> kept,
String before,
String after,
) {
if (kept.isEmpty || before == after) {
return kept;
}
final shorter = before.length < after.length ? before.length : after.length;
var prefix = 0;
while (prefix < shorter && before[prefix] == after[prefix]) {
prefix++;
}
var suffix = 0;
while (suffix < shorter - prefix &&
before[before.length - 1 - suffix] == after[after.length - 1 - suffix]) {
suffix++;
}
final editEnd = before.length - suffix;
final delta = after.length - before.length;
return [
for (final range in kept)
if (range.end <= prefix)
range
else if (editEnd <= range.start)
(start: range.start + delta, end: range.end + delta),
];
}
class _Parser(
final String _text,
final DateTime _now,
final List<QuickAddWords> _words,
final Set<String> _ignored,
final List<QuickAddRange> _ignored,
) {
late final _today = Day.of(_now);
final _tokens = <QuickAddToken>[];
@ -108,7 +145,7 @@ class _Parser(
bool _has(QuickAddKind kind) => _tokens.any((token) => token.kind == kind);
bool _isIgnored(int start, int end) =>
_ignored.contains(_text.substring(start, end).toLowerCase());
_ignored.any((range) => range.start < end && start < range.end);
QuickAddToken _take(QuickAddKind kind, int start, int end) {
final token = QuickAddToken(kind, start, end);

View file

@ -36,8 +36,9 @@ import 'package:material_ui/material_ui.dart';
/// (`parseQuickAdd`): `#project`, `@label`, `{deadline}`, `!!1`, `p1`,
/// `!30m` (as many as typed), `!1pm`, dates, times, durations and
/// recurrences in the app's language and English, each highlighted and
/// shown on its button; a tap on one keeps it as text, and a button's pick
/// takes its token out of the title. As `#` or `@` is typed, the projects
/// shown on its button; a tap on one, or Backspace right after it, keeps
/// that one as text (D29), and a button's pick takes its token out of the
/// title. As `#` or `@` is typed, the projects
/// or labels it may mean are offered below the title, one tapped
/// completing it.
Future<void> showQuickAdd(
@ -134,9 +135,6 @@ class _QuickAddState() extends State<_QuickAdd> {
/// Where the add button was dropped, moving on below each task added.
late DropSpot? _spot = widget.spot;
/// The tokens tapped off, by their text in lower case.
final _ignored = <String>{};
@override
void initState() {
super.initState();
@ -168,7 +166,7 @@ class _QuickAddState() extends State<_QuickAdd> {
),
projects: _projects(l10n, state),
labels: _labels(state),
ignored: _ignored,
ignored: _title.kept,
);
}
@ -272,7 +270,7 @@ class _QuickAddState() extends State<_QuickAdd> {
}
}
/// A tap in the title on a token keeps it as text.
/// A tap in the title on a token keeps it as text, there alone (D29).
void _tapped() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
@ -281,17 +279,23 @@ class _QuickAddState() extends State<_QuickAdd> {
final offset = _title.selection.baseOffset;
for (final token in _title.tokens) {
if (offset > token.start && offset < token.end) {
setState(
() => _ignored.add(
_title.text.substring(token.start, token.end).toLowerCase(),
),
);
setState(() => _title.keep(token));
return;
}
}
});
}
/// Backspace right after a token keeps it as text instead of deleting
/// its last character, as Todoist's (D29); the next one deletes.
late final _backspaceKeeps = TextInputFormatter.withFunction((old, next) {
final kept = _title.backspaced(old, next);
if (!identical(kept, next)) {
setState(() {});
}
return kept;
});
void _describe() {
setState(() => _describing = true);
WidgetsBinding.instance.addPostFrameCallback(
@ -381,7 +385,7 @@ class _QuickAddState() extends State<_QuickAdd> {
_priority = Priority.none;
_labelIds = [];
_deadline = null;
_ignored.clear();
_title.kept = const [];
if (spot != null) {
_spot = DropSpot(
sectionId: spot.sectionId,
@ -401,7 +405,9 @@ class _QuickAddState() extends State<_QuickAdd> {
final state = context.watch<TodoCubit>().state;
final today = Day.of(clock.now());
final parsed = _parse();
_title.tokens = parsed.tokens;
_title
..tokens = parsed.tokens
..tokensText = _title.text;
_title.highlight = theme.colorScheme.primaryContainer;
_title.onHighlight = theme.colorScheme.onPrimaryContainer;
final date = parsed.date ?? _date;
@ -463,6 +469,7 @@ class _QuickAddState() extends State<_QuickAdd> {
? null
: l10n.quickAddTokenHint,
),
inputFormatters: [_backspaceKeeps],
onTap: _tapped,
onSubmitted: (_) => _add(),
),
@ -706,9 +713,52 @@ class const QuickAddButton({
/// plain composing span had hidden its highlight until then.
class _TokenController() extends TextEditingController {
List<QuickAddToken> tokens = const [];
/// The text [tokens] were read from.
String tokensText = '';
Color? highlight;
Color? onHighlight;
/// The tokens kept as text, each by where it is (D29), following the
/// text's edits.
List<QuickAddRange> kept = const [];
@override
set value(TextEditingValue newValue) {
if (newValue.text != text) {
kept = shiftKept(kept, text, newValue.text);
}
super.value = newValue;
}
/// Keeps [token] as text.
void keep(QuickAddToken token) =>
kept = [...kept, (start: token.start, end: token.end)];
/// [old] where [next] is a Backspace right after a token (one character
/// deleted just before a collapsed caret at a token's end), the token
/// then kept as text; else [next].
TextEditingValue backspaced(TextEditingValue old, TextEditingValue next) {
final caret = old.selection;
if (old.text != tokensText || !caret.isValid || !caret.isCollapsed) {
return next;
}
final at = caret.baseOffset;
if (at < 1 ||
!next.selection.isCollapsed ||
next.selection.baseOffset != at - 1 ||
next.text != old.text.replaceRange(at - 1, at, '')) {
return next;
}
for (final token in tokens) {
if (token.end == at) {
keep(token);
return old;
}
}
return next;
}
@override
TextSpan buildTextSpan({
required BuildContext context,

View file

@ -1348,7 +1348,10 @@ void main() {
);
expect(find.widgetWithText(QuickAddButton, '1 label'), findsOneWidget);
expect(
find.text('Tap a highlighted word to keep it as text.'),
find.text(
'Tap a highlighted word, or press Backspace right after it, to '
'keep it as text.',
),
findsOneWidget,
);
await add(tester);
@ -1453,7 +1456,10 @@ void main() {
await tester.tapAt(render.localToGlobal(caret.center));
await tester.pumpAndSettle();
expect(
find.text('Tap a highlighted word to keep it as text.'),
find.text(
'Tap a highlighted word, or press Backspace right after it, to '
'keep it as text.',
),
findsNothing,
);
await add(tester);
@ -1463,6 +1469,85 @@ void main() {
expect(stored.single.content?.date, isNull);
});
testWidgets('a word tapped stays text there alone: the same word '
"elsewhere still counts (the developer's, 4 Nov 2026)", (
tester,
) async {
await start(tester, usedBefore: true);
await goTab(tester, 'Inbox');
const text = 'Watch The Daily Show Daily';
await open(tester, text);
final editable = tester.state<EditableTextState>(
find.descendant(
of: find.byKey(quickAddTitleKey),
matching: find.byType(EditableText),
),
);
final render = editable.renderEditable;
final caret = render.getLocalRectForCaret(
TextPosition(offset: text.indexOf('Daily') + 2),
);
await tester.tapAt(render.localToGlobal(caret.center));
await tester.pumpAndSettle();
await add(tester);
final stored = await tester.runAsync(() => tasks().all());
final content = stored!.single.content!;
expect(content.title, 'Watch The Daily Show');
expect(content.date?.repeat, const Repeat(RepeatUnit.day));
});
testWidgets('Backspace right after a highlighted word keeps it as text, '
"as Todoist's; the next Backspace deletes", (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));
TextEditingValue at(String text) => TextEditingValue(
text: text,
selection: TextSelection.collapsed(offset: text.length),
);
tester.testTextInput.updateEditingValue(at('Call today'));
await tester.pumpAndSettle();
final title = tester
.widget<TextField>(find.byKey(quickAddTitleKey))
.controller!;
// As a keyboard sends it: the text without the character before
// the caret.
tester.testTextInput.updateEditingValue(at('Call toda'));
await tester.pumpAndSettle();
expect(title.value.text, 'Call today', reason: 'nothing deleted');
expect(
title.value.selection,
const TextSelection.collapsed(offset: 10),
);
expect(
find.widgetWithText(QuickAddButton, 'Today'),
findsNothing,
reason: 'kept as text',
);
tester.testTextInput.updateEditingValue(at('Call toda'));
await tester.pumpAndSettle();
expect(title.value.text, 'Call toda');
tester.testTextInput.updateEditingValue(at('Call today'));
await tester.pumpAndSettle();
expect(
find.widgetWithText(QuickAddButton, 'Today'),
findsOneWidget,
reason: 'the word typed anew is a new word, read again',
);
await tester.sendKeyEvent(LogicalKeyboardKey.backspace);
await tester.pumpAndSettle();
expect(title.value.text, 'Call today', reason: "a desktop's key too");
expect(find.widgetWithText(QuickAddButton, 'Today'), findsNothing);
});
testWidgets('a token is highlighted while the keyboard still composes '
'it, the composing underline over it (Gboard, Japanese IMEs)', (
tester,

View file

@ -19,7 +19,7 @@ void main() {
ParsedTask parse(
String text, {
List<QuickAddWords> words = const [QuickAddWords.en],
Set<String> ignored = const {},
List<QuickAddRange> ignored = const [],
}) => parseQuickAdd(
text,
now: now,
@ -463,7 +463,10 @@ void main() {
expect(parse('Pay !tomorrow').date, isNull);
expect(parse('Wow !9').title, 'Wow !9');
expect(parse('Wow !soon').reminderTimes, isEmpty);
final kept = parse('Call !1pm !2pm', ignored: {'!1pm'});
final kept = parse(
'Call !1pm !2pm',
ignored: const [(start: 5, end: 9)],
);
expect(kept.title, 'Call !1pm');
expect(kept.reminderTimes, [ReminderTime(today, 14 * 60)]);
});
@ -538,12 +541,36 @@ void main() {
expect(added.title, 'Call');
});
test('a token tapped off stays text, compared by its text', () {
final added = parse('Watch Friday the 13th', ignored: {'friday'});
test('a token tapped off stays text, by where it is', () {
final added = parse(
'Watch Friday the 13th',
ignored: const [(start: 6, end: 12)],
);
expect(added.date, isNull);
expect(added.title, 'Watch Friday the 13th');
});
test('a word kept as text keeps only that occurrence: the same word '
"elsewhere still counts (the developer's, 4 Nov 2026)", () {
const text = 'Watch The Daily Show Daily';
final first = text.indexOf('Daily');
final added = parse(
text,
ignored: [(start: first, end: first + 5)],
);
expect(added.date?.repeat, const Repeat(RepeatUnit.day));
expect(added.title, 'Watch The Daily Show');
});
test('a range kept keeps every token overlapping it as text', () {
final added = parse(
'Meet next monday',
ignored: const [(start: 5, end: 16)],
);
expect(added.date, isNull, reason: 'monday alone overlaps it too');
expect(added.title, 'Meet next monday');
});
test('only the first of a kind counts', () {
final added = parse('Move tomorrow to friday');
expect(added.date?.day, today.plus(1));
@ -555,4 +582,39 @@ void main() {
expect(parse('Monday.com login').date, isNull);
});
});
group('kept ranges follow edits', () {
const kept = [(start: 6, end: 11)];
const text = 'Watch today ok';
test('text inserted before one shifts it', () {
expect(shiftKept(kept, text, 'Do: Watch today ok'), const [
(start: 10, end: 15),
]);
expect(shiftKept(kept, text, 'Watch today ok'), const [
(start: 7, end: 12),
], reason: 'inserted right at its start');
});
test('text deleted before one shifts it back', () {
expect(shiftKept(kept, text, 'Wat today ok'), const [
(start: 4, end: 9),
]);
});
test('an edit after one leaves it, typing right after it too', () {
expect(shiftKept(kept, text, 'Watch today okay'), kept);
expect(shiftKept(kept, text, 'Watch today, ok'), kept);
});
test('an edit inside one, or over it, drops it', () {
expect(shiftKept(kept, text, 'Watch toay ok'), isEmpty);
expect(shiftKept(kept, text, 'Watch todday ok'), isEmpty);
expect(shiftKept(kept, text, 'Watch ok'), isEmpty);
});
test('the text unchanged keeps them', () {
expect(shiftKept(kept, text, text), kept);
});
});
}