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
7 changed files with 283 additions and 31 deletions

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,