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:
parent
0f8a559d2e
commit
8b13cbcf2b
10 changed files with 283 additions and 31 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue