feat(todo): a task's title wraps in its sheet and pane, its words read as quick add's
The title runs over as many lines as it needs instead of trailing off, and what it says is highlighted as in quick add and set as it is saved (the developer's review, 10 Nov 2026): a day and its recurrence, the priority, the deadline, labels, reminders and places added, the project the task moves to; a word tapped, or a Backspace right after it, keeps it as text. Enter still saves. Quick add's controller and reading of its title are shared for it (title_tokens.dart). Whats-New: In a task's details, a long title now wraps, and words like "every Monday" in it are recognised as in quick add. Whats-New-De: In den Details einer Aufgabe bricht ein langer Titel jetzt um, und Wörter wie „jeden Montag“ darin werden wie beim schnellen Hinzufügen erkannt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
f9cd30dd5e
commit
ca9ad6c69c
5 changed files with 422 additions and 185 deletions
File diff suppressed because one or more lines are too long
|
|
@ -16,12 +16,12 @@ import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.da
|
|||
import 'package:halkyon_todo_flutter/src/feature/location/widget/location_picker.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/quick_add_parser.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/quick_add_words.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/date_picker.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_pickers.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/title_tokens.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
|
@ -127,7 +127,7 @@ class const _QuickAdd({
|
|||
}
|
||||
|
||||
class _QuickAddState() extends State<_QuickAdd> {
|
||||
final _title = _TokenController();
|
||||
final _title = TitleController();
|
||||
RichContent? _description;
|
||||
var _sent = 0;
|
||||
final _focus = FocusNode();
|
||||
|
|
@ -175,42 +175,12 @@ class _QuickAddState() extends State<_QuickAdd> {
|
|||
void _changed() => setState(() {});
|
||||
|
||||
/// What the title says now.
|
||||
ParsedTask _parse() {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final state = context.read<TodoCubit>().state;
|
||||
return parseQuickAdd(
|
||||
_title.text,
|
||||
now: clock.now(),
|
||||
words: QuickAddWords.forLanguage(
|
||||
Localizations.localeOf(context).languageCode,
|
||||
),
|
||||
projects: _projects(l10n, state),
|
||||
labels: _labels(state),
|
||||
locations: _places(state),
|
||||
ignored: _title.kept,
|
||||
);
|
||||
}
|
||||
|
||||
/// The projects by the names `#` takes, the Inbox by its name in the
|
||||
/// app's language and in English.
|
||||
static List<NamedId> _projects(AppLocalizations l10n, TodoState state) => [
|
||||
for (final project in state.projects)
|
||||
if (project.inbox) ...[
|
||||
(id: project.id, name: l10n.shellTabInbox),
|
||||
if (l10n.shellTabInbox != 'Inbox') (id: project.id, name: 'Inbox'),
|
||||
] else if (project.content case final content?)
|
||||
(id: project.id, name: content.name),
|
||||
];
|
||||
|
||||
static List<NamedId> _labels(TodoState state) => [
|
||||
for (final label in state.labels)
|
||||
if (label.content case final content?) (id: label.id, name: content.name),
|
||||
];
|
||||
|
||||
/// The recent places by the names `+` takes (D32), each by its place.
|
||||
static List<NamedId> _places(TodoState state) => [
|
||||
for (final place in recentsOf(state)) (id: place.key, name: place.name),
|
||||
];
|
||||
ParsedTask _parse() => parseTitle(
|
||||
context,
|
||||
context.read<TodoCubit>().state,
|
||||
_title.text,
|
||||
kept: _title.kept,
|
||||
);
|
||||
|
||||
/// The places of [parsed]'s `+` tokens beside those picked.
|
||||
List<TaskLocation> _placesOf(ParsedTask parsed, TodoState state) {
|
||||
|
|
@ -266,9 +236,9 @@ class _QuickAddState() extends State<_QuickAdd> {
|
|||
}
|
||||
final typed = typing.typed.toLowerCase();
|
||||
final named = switch (typing.symbol) {
|
||||
'#' => _projects(l10n, state),
|
||||
'@' => _labels(state),
|
||||
_ => _places(state),
|
||||
'#' => titleProjects(l10n, state),
|
||||
'@' => titleLabels(state),
|
||||
_ => titlePlaces(state),
|
||||
};
|
||||
if (named.any((item) => item.name.toLowerCase() == typed)) {
|
||||
return const [];
|
||||
|
|
@ -362,32 +332,6 @@ class _QuickAddState() extends State<_QuickAdd> {
|
|||
}
|
||||
}
|
||||
|
||||
/// A tap in the title on a token keeps it as text, there alone (D29).
|
||||
void _tapped() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final offset = _title.selection.baseOffset;
|
||||
for (final token in _title.tokens) {
|
||||
if (offset > token.start && offset < token.end) {
|
||||
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(
|
||||
|
|
@ -583,8 +527,8 @@ class _QuickAddState() extends State<_QuickAdd> {
|
|||
? null
|
||||
: l10n.quickAddTokenHint,
|
||||
),
|
||||
inputFormatters: [_backspaceKeeps],
|
||||
onTap: _tapped,
|
||||
inputFormatters: [_title.backspaceKeeps],
|
||||
onTap: _title.keepTapped,
|
||||
onSubmitted: (_) => _add(),
|
||||
),
|
||||
),
|
||||
|
|
@ -911,111 +855,3 @@ class const QuickAddButton({
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 [];
|
||||
|
||||
/// 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,
|
||||
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
|
||||
? 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>[];
|
||||
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,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return TextSpan(style: style, children: children);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
|
@ -12,12 +13,15 @@ import 'package:halkyon_todo_flutter/src/core/note/note_fields.dart';
|
|||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/comment/widget/task_comments.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/location/widget/location_picker.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/reminder/widget/reminder_picker.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/data/task_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/quick_add_parser.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/date_picker.dart';
|
||||
|
|
@ -26,6 +30,7 @@ import 'package:halkyon_todo_flutter/src/feature/task/widget/repeat_picker.dart'
|
|||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_pickers.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/title_tokens.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/pane_widths.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/side_pane.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
|
|
@ -76,6 +81,9 @@ Future<void> openTaskSheet(BuildContext context, String id) async {
|
|||
/// The key of the handle atop a task's sheet on a phone.
|
||||
const taskSheetHandleKey = ValueKey('task-sheet-handle');
|
||||
|
||||
/// The key of the title's field in a task's sheet and pane.
|
||||
const taskTitleFieldKey = ValueKey('task-title-field');
|
||||
|
||||
/// A task's sheet on a phone: partway up as high as its content, at most
|
||||
/// [_partway] of the window, and up to the whole window dragged.
|
||||
class const _DraggableSheet({
|
||||
|
|
@ -274,8 +282,15 @@ class const _TaskSheet({
|
|||
}
|
||||
|
||||
class _TaskSheetState() extends State<_TaskSheet> {
|
||||
late final TextEditingController _title;
|
||||
/// The title, what it says highlighted as quick add's (the developer's
|
||||
/// review, 10 Nov 2026).
|
||||
final _title = TitleController();
|
||||
|
||||
/// What the title says, read as it changes: [dispose] can't read it any
|
||||
/// more.
|
||||
ParsedTask? _parsed;
|
||||
late final TaskRepository _tasks;
|
||||
late final LabelRepository _labels;
|
||||
|
||||
/// Both read in [initState], as [dispose] can't look them up any more.
|
||||
late final TodoCubit _todo;
|
||||
|
|
@ -284,16 +299,43 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
_todo = context.read<TodoCubit>();
|
||||
_tasks = PersonScope.of<TodoPerson>(context).tasks;
|
||||
final person = PersonScope.of<TodoPerson>(context);
|
||||
_tasks = person.tasks;
|
||||
_labels = person.labels;
|
||||
final content = _todo.state.taskOf(widget.id)?.content;
|
||||
_title = TextEditingController(text: content?.title);
|
||||
_title
|
||||
..text = content?.title ?? ''
|
||||
..addListener(_retitled);
|
||||
}
|
||||
|
||||
void _retitled() => setState(_parse);
|
||||
|
||||
/// Reads the title, its tokens highlighted; none as long as it is the
|
||||
/// task's title as stored, which may hold words once kept as text.
|
||||
void _parse() {
|
||||
if (_title.text.trim() == _content?.title) {
|
||||
_parsed = null;
|
||||
_title.tokens = const [];
|
||||
} else {
|
||||
final parsed = parseTitle(
|
||||
context,
|
||||
_todo.state,
|
||||
_title.text,
|
||||
kept: _title.kept,
|
||||
);
|
||||
_parsed = parsed;
|
||||
_title.tokens = parsed.tokens;
|
||||
}
|
||||
_title.tokensText = _title.text;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// What was typed is kept as the sheet closes.
|
||||
unawaited(_saveText());
|
||||
_title.dispose();
|
||||
_title
|
||||
..removeListener(_retitled)
|
||||
..dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -404,14 +446,82 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Saves the title, setting what its words say as quick add does (the
|
||||
/// developer's review, 10 Nov 2026): the date with its recurrence, the
|
||||
/// priority, the deadline, labels, reminders and places added, and the
|
||||
/// project the task moves to.
|
||||
Future<void> _saveText() async {
|
||||
final content = _content;
|
||||
final title = _title.text.trim();
|
||||
if (content == null || title.isEmpty || title == content.title) {
|
||||
final parsed = _parsed;
|
||||
final title = (parsed?.title ?? _title.text).trim();
|
||||
if (content == null || title.isEmpty) {
|
||||
return;
|
||||
}
|
||||
if (parsed == null || parsed.tokens.isEmpty) {
|
||||
if (title != content.title) {
|
||||
await _write(content.copyWith(title: title));
|
||||
}
|
||||
return;
|
||||
}
|
||||
final state = _todo.state;
|
||||
final made = <String>[
|
||||
for (final (index, name) in parsed.newLabels.indexed)
|
||||
await _labels.add(
|
||||
LabelContent(name: name, position: _lastLabel(state) + 1 + index),
|
||||
),
|
||||
];
|
||||
final recent = {for (final place in recentsOf(state)) place.key: place};
|
||||
await _write(
|
||||
content.copyWith(
|
||||
title: title,
|
||||
date: () => parsed.date ?? content.date,
|
||||
priority: parsed.priority ?? content.priority,
|
||||
deadline: () => parsed.deadline ?? content.deadline,
|
||||
labelIds: {
|
||||
...content.labelIds,
|
||||
...parsed.labelIds,
|
||||
...made,
|
||||
}.toList(),
|
||||
reminders: {...content.reminders, ...parsed.reminders}.toList(),
|
||||
reminderTimes: [...content.reminderTimes, ...parsed.reminderTimes],
|
||||
locations: [
|
||||
...content.locations,
|
||||
for (final id in parsed.locationIds)
|
||||
if (recent[id] case final place?
|
||||
when !content.locations.any((other) => other.key == id))
|
||||
place,
|
||||
],
|
||||
),
|
||||
);
|
||||
if (parsed.projectId case final projectId?
|
||||
when projectId != content.projectId) {
|
||||
await _move(projectId);
|
||||
}
|
||||
if (mounted) {
|
||||
_title
|
||||
..kept = const []
|
||||
..text = title;
|
||||
}
|
||||
}
|
||||
|
||||
/// Line breaks, pasted, as spaces: the title is one line, wrapped.
|
||||
static final _oneLine = TextInputFormatter.withFunction(
|
||||
(old, next) => next.text.contains('\n')
|
||||
? next.copyWith(text: next.text.replaceAll('\n', ' '))
|
||||
: next,
|
||||
);
|
||||
|
||||
static double _lastLabel(TodoState state) => state.labels.fold<double>(
|
||||
0,
|
||||
(position, label) => switch (label.content?.position) {
|
||||
final at? when at > position => at,
|
||||
_ => position,
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> _write(TaskContent content) async {
|
||||
try {
|
||||
await _tasks.update(widget.id, content.copyWith(title: title));
|
||||
await _tasks.update(widget.id, content);
|
||||
} on KeysMissing {
|
||||
// Nothing is written on a device without the keys.
|
||||
}
|
||||
|
|
@ -666,6 +776,9 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
return _frame(context, title: l10n.taskTitle, body: Text(l10n.taskGone));
|
||||
}
|
||||
final content = task.content;
|
||||
_title
|
||||
..highlight = theme.colorScheme.primaryContainer
|
||||
..onHighlight = theme.colorScheme.onPrimaryContainer;
|
||||
final today = Day.of(clock.now());
|
||||
final project = state.projectOf(content.projectId);
|
||||
final subTasks = subTasksOf(state.tasks, task.id);
|
||||
|
|
@ -713,8 +826,16 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
TaskCheck(task: task),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: taskTitleFieldKey,
|
||||
controller: _title,
|
||||
style: theme.textTheme.titleLarge,
|
||||
// Wrapping, as Todoist's (the developer's review, 10 Nov
|
||||
// 2026); Enter still saves, a line break pasted a space.
|
||||
maxLines: null,
|
||||
keyboardType: TextInputType.text,
|
||||
textInputAction: TextInputAction.done,
|
||||
inputFormatters: [_title.backspaceKeeps, _oneLine],
|
||||
onTap: _title.keepTapped,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,177 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/location/widget/location_picker.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/quick_add_parser.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/quick_add_words.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// What a task's title says (ADR 0033, D15), read as quick add reads it:
|
||||
/// the [text] typed, its tokens [kept] as text left as they are.
|
||||
ParsedTask parseTitle(
|
||||
BuildContext context,
|
||||
TodoState state,
|
||||
String text, {
|
||||
List<QuickAddRange> kept = const [],
|
||||
}) => parseQuickAdd(
|
||||
text,
|
||||
now: clock.now(),
|
||||
words: QuickAddWords.forLanguage(
|
||||
Localizations.localeOf(context).languageCode,
|
||||
),
|
||||
projects: titleProjects(AppLocalizations.of(context), state),
|
||||
labels: titleLabels(state),
|
||||
locations: titlePlaces(state),
|
||||
ignored: kept,
|
||||
);
|
||||
|
||||
/// The projects by the names `#` takes, the Inbox by its name in the
|
||||
/// app's language and in English.
|
||||
List<NamedId> titleProjects(AppLocalizations l10n, TodoState state) => [
|
||||
for (final project in state.projects)
|
||||
if (project.inbox) ...[
|
||||
(id: project.id, name: l10n.shellTabInbox),
|
||||
if (l10n.shellTabInbox != 'Inbox') (id: project.id, name: 'Inbox'),
|
||||
] else if (project.content case final content?)
|
||||
(id: project.id, name: content.name),
|
||||
];
|
||||
|
||||
List<NamedId> titleLabels(TodoState state) => [
|
||||
for (final label in state.labels)
|
||||
if (label.content case final content?) (id: label.id, name: content.name),
|
||||
];
|
||||
|
||||
/// The recent places by the names `+` takes (D32), each by its place.
|
||||
List<NamedId> titlePlaces(TodoState state) => [
|
||||
for (final place in recentsOf(state)) (id: place.key, name: place.name),
|
||||
];
|
||||
|
||||
/// 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 TitleController() 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)];
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// A tap in the title on a token keeps it as text, there alone (D29):
|
||||
/// for a field's `onTap`, once the tap has placed the cursor.
|
||||
void keepTapped() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final offset = selection.baseOffset;
|
||||
for (final token in tokens) {
|
||||
if (offset > token.start && offset < token.end) {
|
||||
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 TextInputFormatter backspaceKeeps =
|
||||
TextInputFormatter.withFunction(backspaced);
|
||||
|
||||
/// [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,
|
||||
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
|
||||
? 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>[];
|
||||
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,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return TextSpan(style: style, children: children);
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@ import 'package:halkyon_todo_flutter/src/feature/task/widget/task_drag.dart';
|
|||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_sheet.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_tile.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/title_tokens.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/browse_tab.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/hour_grid.dart';
|
||||
import 'package:halkyon_todo_flutter/src/tabs/plan_panel.dart';
|
||||
|
|
@ -4872,6 +4873,108 @@ void main() {
|
|||
expect(await tester.runAsync(() => tasks().all()), isEmpty);
|
||||
});
|
||||
|
||||
group("a task's title in its sheet and pane (the developer's review, 10 "
|
||||
'Nov 2026)', () {
|
||||
testWidgets('a long one wraps over several lines, whole', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
const long =
|
||||
'Ask the landlord about the heating in the back room before the '
|
||||
'cold weather comes';
|
||||
await seed(tester, TaskContent(title: long, projectId: inbox()));
|
||||
await goTab(tester, 'Inbox');
|
||||
await tester.tap(find.text(long));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final field = find.byKey(taskTitleFieldKey);
|
||||
expect(tester.widget<TextField>(field).maxLines, isNull);
|
||||
final line = tester.getSize(find.byKey(taskTitleFieldKey)).height;
|
||||
await tester.enterText(field, 'Short');
|
||||
await tester.pump();
|
||||
expect(
|
||||
tester.getSize(find.byKey(taskTitleFieldKey)).height,
|
||||
lessThan(line - 20),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('a day and its recurrence typed are highlighted as quick '
|
||||
"add's, and set as it is saved, the title left without them", (
|
||||
tester,
|
||||
) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await seed(tester, TaskContent(title: 'Water', projectId: inbox()));
|
||||
await goTab(tester, 'Inbox');
|
||||
await tester.tap(find.text('Water'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final field = find.byKey(taskTitleFieldKey);
|
||||
await tester.enterText(field, 'Water plants every monday');
|
||||
await tester.pump();
|
||||
final controller =
|
||||
tester.widget<TextField>(field).controller! as TitleController;
|
||||
expect(
|
||||
controller.tokens.map(
|
||||
(token) => controller.text.substring(token.start, token.end),
|
||||
),
|
||||
['every monday'],
|
||||
);
|
||||
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
final content = (await tester.runAsync(
|
||||
() => tasks().all(),
|
||||
))!.single.content!;
|
||||
expect(content.title, 'Water plants');
|
||||
expect(content.date?.repeat, isNotNull);
|
||||
expect(content.date!.day.weekday, DateTime.monday);
|
||||
expect(controller.text, 'Water plants');
|
||||
});
|
||||
|
||||
testWidgets('a word tapped is kept as text, as in quick add', (
|
||||
tester,
|
||||
) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await seed(tester, TaskContent(title: 'Read', projectId: inbox()));
|
||||
await goTab(tester, 'Inbox');
|
||||
await tester.tap(find.text('Read'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final field = find.byKey(taskTitleFieldKey);
|
||||
await tester.enterText(field, 'Read Tomorrow');
|
||||
await tester.pump();
|
||||
final controller =
|
||||
tester.widget<TextField>(field).controller! as TitleController;
|
||||
controller.keep(controller.tokens.single);
|
||||
await tester.pump();
|
||||
expect(controller.tokens, isEmpty);
|
||||
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
final content = (await tester.runAsync(
|
||||
() => tasks().all(),
|
||||
))!.single.content!;
|
||||
expect(content.title, 'Read Tomorrow');
|
||||
expect(content.date, isNull);
|
||||
});
|
||||
|
||||
testWidgets('a line break pasted becomes a space', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await seed(tester, TaskContent(title: 'Call', projectId: inbox()));
|
||||
await goTab(tester, 'Inbox');
|
||||
await tester.tap(find.text('Call'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byKey(taskTitleFieldKey), 'Call Anna\nnow');
|
||||
await tester.pump();
|
||||
expect(
|
||||
tester
|
||||
.widget<TextField>(find.byKey(taskTitleFieldKey))
|
||||
.controller!
|
||||
.text,
|
||||
'Call Anna now',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets("a task's sheet shows where it is, the project as an icon "
|
||||
'once tasks are above it, each step opening what it names', (
|
||||
tester,
|
||||
|
|
|
|||
Loading…
Reference in a new issue