feat(todo): comments and descriptions in the editor (ADR 0033, D38)

Comments and the descriptions of tasks, projects, sections and filters
become the editor's document on halkyon_editor; names and titles stay
text. Rows written as text read as notes and become documents when next
saved, the server seeing only sealed bytes, so there is no migration.

Sections' descriptions can now be written, and a project's shows above
its tasks. Android answers whether a link can open only for what is
declared, so Todo declares http and https.

Whats-New: Comments and descriptions take rich text: headings, lists, checklists, links and more.
Whats-New-De: Kommentare und Beschreibungen können jetzt formatiert werden: Überschriften, Listen, Checklisten, Links und mehr.

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-06 21:36:05 +01:00
commit a25cca12b4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
37 changed files with 645 additions and 282 deletions

View file

@ -121,6 +121,17 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<!-- Links in a note: the editor asks whether one can open before it
opens it, which Android answers only for what is declared
(ADR 0033, D38). -->
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="http"/>
</intent>
<!-- The device's speech recogniser, for dictation (ADR 0033, D29). -->
<intent>
<action android:name="android.speech.RecognitionService"/>

View file

@ -1,4 +1,5 @@
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
@ -10,6 +11,7 @@ import 'package:material_ui/material_ui.dart';
const todoLocalizationsDelegates = <LocalizationsDelegate<Object?>>[
AppLocalizations.delegate,
HalkyonAppLocalizations.delegate,
HalkyonEditorLocalizations.delegate,
HalkyonUiLocalizations.delegate,
...GlobalMaterialLocalizations.delegates,
];

View file

@ -0,0 +1,99 @@
import 'package:halkyon_editor/halkyon_editor.dart';
/// A note: what is written at length in Todo, a comment or a description,
/// as the editor's document (ADR 0033, D38); names and titles stay text.
/// A piece of text: the link it stands for, or plain text.
typedef TextPiece = ({String text, Uri? url});
final _link = RegExp(r'https?://[^\s<>"]+', caseSensitive: false);
/// [text] cut into plain pieces and links: http and https alone, so that a
/// note can never start anything else on the device, and without the
/// punctuation that ends a sentence after a link.
List<TextPiece> linkPieces(String text) {
final pieces = <TextPiece>[];
var at = 0;
for (final match in _link.allMatches(text)) {
final found = match[0]!.replaceFirst(RegExp(r'[.,;:!?)\]]+$'), '');
final url = Uri.tryParse(found);
if (url == null || url.host.isEmpty) {
continue;
}
if (match.start > at) {
pieces.add((text: text.substring(at, match.start), url: null));
}
pieces.add((text: found, url: url));
at = match.start + found.length;
}
if (at < text.length) {
pieces.add((text: text.substring(at), url: null));
}
return pieces;
}
/// [text] as a note, a paragraph per line, the addresses in it links.
RichContent noteOf(String text) => withLinks(RichContent.plain(text));
/// [note] with every address written in it a link, as a note written as
/// text had them; text already leading somewhere stays as it is.
RichContent withLinks(RichContent note) => RichContent(
_linked(note.editorJson)! as Map<String, Object?>,
formatVersion: note.formatVersion,
);
Object? _linked(Object? json) => switch (json) {
final Map<String, Object?> map => {
for (final MapEntry(:key, :value) in map.entries)
key: key == 'delta' && value is List<Object?>
? value.expand(_linkedOperation).toList()
: _linked(value),
},
final List<Object?> list => list.map(_linked).toList(),
_ => json,
};
Iterable<Object?> _linkedOperation(Object? operation) sync* {
if (operation case {'insert': final String text}
when operation is Map<String, Object?> &&
(operation['attributes'] as Map<String, Object?>?)?['href'] == null) {
for (final piece in linkPieces(text)) {
final attributes = {
...?operation['attributes'] as Map<String, Object?>?,
'href': ?piece.url?.toString(),
};
yield {
'insert': piece.text,
if (attributes.isNotEmpty) 'attributes': attributes,
};
}
} else {
yield operation;
}
}
/// The note a payload holds under a key: as the editor's document, or as
/// the text a row was written with before D38, which stays until it is
/// next saved; null for none. Throws for a document of a newer format, as
/// a row is never read half.
RichContent? readNote(Object? json) => switch (json) {
final String text when text.isNotEmpty => noteOf(text),
final Map<String, Object?> document => RichContent.fromJson(document),
_ => null,
};
/// [note] for a payload; null for none, or one holding nothing.
Map<String, Object?>? writeNote(RichContent? note) =>
note == null || note.isBlank ? null : note.toJson();
/// What a note, or none, says.
extension NoteText on RichContent? {
/// Its text without formats; empty for none.
String get text => this?.plainText ?? '';
/// Its first line, for a row showing it on one.
String get firstLine => text.split('\n').first;
/// Whether there is nothing to show.
bool get isNone => this?.isBlank ?? true;
}

View file

@ -0,0 +1,166 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_todo_flutter/src/core/note/note.dart';
import 'package:material_ui/material_ui.dart';
/// Writes a note where it sits, in a sheet or a dialog (ADR 0033, D38): an
/// editor as high as its lines, up to [maxLines] and then scrolling, with
/// the format bar while it has the focus, [hint] while it is empty.
class const NoteComposer({
/// What it opens with; later changes to it are ignored.
required final RichContent? initial,
/// Called with the whole note after every change.
required final ValueChanged<RichContent> onChanged,
/// What it says while empty.
required final String hint,
final bool autofocus = false,
final FocusNode? focusNode,
final int maxLines = 6,
super.key,
}) extends StatefulWidget {
@override
State<NoteComposer> createState() => _NoteComposerState();
}
class _NoteComposerState() extends State<NoteComposer> {
static const _lineHeight = 26.0;
static const _barHeight = 52.0;
late var _note = widget.initial ?? RichContent.empty();
FocusNode? _ownFocus;
var _focused = false;
FocusNode get _focus => widget.focusNode ?? (_ownFocus ??= FocusNode());
@override
void initState() {
super.initState();
_focus.addListener(_onFocus);
}
@override
void dispose() {
_focus.removeListener(_onFocus);
_ownFocus?.dispose();
super.dispose();
}
void _onFocus() {
if (_focus.hasFocus != _focused) {
setState(() => _focused = _focus.hasFocus);
}
}
@override
Widget build(BuildContext context) {
final lines = math.max(1, _note.plainText.split('\n').length);
// Desktops have the editor's own placeholder at the caret.
final mobile =
defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;
return AnimatedSize(
duration: Durations.short2,
child: SizedBox(
height:
math.min(lines, widget.maxLines) * _lineHeight +
8 +
(_focused ? _barHeight : 0),
child: Stack(
children: [
RichContentEditor(
initialContent: _note,
autofocus: widget.autofocus,
focusNode: _focus,
formatBar: true,
onChanged: (note) {
setState(() => _note = note);
widget.onChanged(note);
},
),
if (_note.isBlank && (mobile || !_focused))
IgnorePointer(
child: Text(
widget.hint,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
],
),
),
);
}
}
/// A note shown, or [hint] where there is none, which a tap edits in the
/// whole window ([editNote]); its links open with a tap of their own.
class const NoteField({
required final RichContent? note,
required final String hint,
/// What the window says is being written.
required final String title,
required final ValueChanged<RichContent> onChanged,
super.key,
}) extends StatelessWidget {
Future<void> _edit(BuildContext context) async {
final edited = await editNote(context, note, title: title);
if (edited.isBlank != note.isNone || (!edited.isBlank && edited != note)) {
onChanged(edited);
}
}
@override
Widget build(BuildContext context) => InkWell(
onTap: () => unawaited(_edit(context)),
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 12, 12, 12),
child: switch (note) {
final note? when !note.isBlank => RichContentView(content: note),
_ => Text(
hint,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
},
),
);
}
/// [initial] edited in the whole window, over what opened it, with the
/// format bar: as written when left, however it is left, as a description
/// was saved as the field lost the focus.
Future<RichContent> editNote(
BuildContext context,
RichContent? initial, {
required String title,
}) async {
var note = initial ?? RichContent.empty();
await Navigator.of(context, rootNavigator: true).push<void>(
MaterialPageRoute(
fullscreenDialog: true,
builder: (context) => Scaffold(
appBar: AppBar(leading: const BackButton(), title: Text(title)),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: RichContentEditor(
initialContent: note,
autofocus: true,
formatBar: true,
onChanged: (changed) => note = changed,
),
),
),
),
),
);
return withLinks(note);
}

View file

@ -1,5 +1,6 @@
import 'dart:typed_data';
import 'package:halkyon_todo_flutter/src/core/note/note.dart';
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/comment/model/comment.dart';
@ -10,7 +11,7 @@ const commentSchemaVersion = 1;
ByteData encodeComment(CommentContent content) =>
encodeJson(commentSchemaVersion, {
'taskId': content.taskId,
'text': content.text,
'text': content.text.toJson(),
'writtenAt': content.writtenAt.toUtc().toIso8601String(),
'editedAt': ?content.editedAt?.toUtc().toIso8601String(),
if (content.reactions.isNotEmpty) 'reactions': content.reactions,
@ -23,7 +24,7 @@ CommentContent? decodeComment(ByteData payload) => decodeJson(
name: 'Comment',
read: (json) => CommentContent(
taskId: json['taskId']! as String,
text: json['text']! as String,
text: readNote(json['text'])!,
writtenAt: DateTime.parse(json['writtenAt']! as String),
editedAt: switch (json['editedAt']) {
final String at => DateTime.parse(at),

View file

@ -1,11 +1,12 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
/// A comment on the task [taskId] (ADR 0033, D36): plain [text], with links
/// shown as such, [writtenAt], and [editedAt] once it was changed. Markdown
/// can come later without touching what is kept.
final class const CommentContent({
required final String taskId,
required final String text,
required final RichContent text,
required final DateTime writtenAt,
final DateTime? editedAt,
@ -27,7 +28,7 @@ final class const CommentContent({
);
/// Its text changed at [at], its reactions staying.
CommentContent edited(String changed, DateTime at) => CommentContent(
CommentContent edited(RichContent changed, DateTime at) => CommentContent(
taskId: taskId,
text: changed,
writtenAt: writtenAt,
@ -45,32 +46,3 @@ final class const StoredComment(final String id, final CommentContent? content)
@override
List<Object?> get props => [id, content];
}
/// A piece of a comment's text: the link it stands for, or plain text.
typedef TextPiece = ({String text, Uri? url});
final _link = RegExp(r'https?://[^\s<>"]+', caseSensitive: false);
/// [text] cut into plain pieces and links: http and https alone, so that a
/// comment can never start anything else on the device, and without the
/// punctuation that ends a sentence after a link.
List<TextPiece> linkPieces(String text) {
final pieces = <TextPiece>[];
var at = 0;
for (final match in _link.allMatches(text)) {
final found = match[0]!.replaceFirst(RegExp(r'[.,;:!?)\]]+$'), '');
final url = Uri.tryParse(found);
if (url == null || url.host.isEmpty) {
continue;
}
if (match.start > at) {
pieces.add((text: text.substring(at, match.start), url: null));
}
pieces.add((text: found, url: url));
at = match.start + found.length;
}
if (at < text.length) {
pieces.add((text: text.substring(at), url: null));
}
return pieces;
}

View file

@ -2,12 +2,14 @@ import 'dart:async';
import 'package:clock/clock.dart';
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/core/note/note.dart';
import 'package:halkyon_todo_flutter/src/core/note/note_fields.dart';
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart';
import 'package:halkyon_todo_flutter/src/feature/comment/model/comment.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
@ -16,7 +18,6 @@ import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import 'package:url_launcher/url_launcher.dart';
/// The key of the field writing a comment.
const commentFieldKey = ValueKey('comment-field');
@ -86,7 +87,7 @@ class const CommentsPreview({required final String taskId, super.key})
),
const SizedBox(height: 8),
Text(
comments.last.content!.text,
comments.last.content!.text.plainText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
@ -122,7 +123,8 @@ class const _CommentsSheet({required final String taskId})
}
class _CommentsSheetState() extends State<_CommentsSheet> {
final _text = TextEditingController();
var _draft = RichContent.empty();
var _sent = 0;
final _last = GlobalKey();
@override
@ -131,12 +133,6 @@ class _CommentsSheetState() extends State<_CommentsSheet> {
_showLatest();
}
@override
void dispose() {
_text.dispose();
super.dispose();
}
void _showLatest() => WidgetsBinding.instance.addPostFrameCallback((_) {
if (_last.currentContext case final context? when context.mounted) {
Scrollable.ensureVisible(context);
@ -144,16 +140,18 @@ class _CommentsSheetState() extends State<_CommentsSheet> {
});
Future<void> _send(TodoPerson todo) async {
final text = _text.text.trim();
if (text.isEmpty) {
final text = _draft;
if (text.isBlank) {
return;
}
_text.clear();
setState(() {});
setState(() {
_draft = RichContent.empty();
_sent++;
});
await todo.comments.add(
CommentContent(
taskId: widget.taskId,
text: text,
text: withLinks(text),
writtenAt: clock.now(),
),
);
@ -217,26 +215,22 @@ class _CommentsSheetState() extends State<_CommentsSheet> {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: TextField(
key: commentFieldKey,
controller: _text,
autofocus: true,
minLines: 1,
maxLines: 6,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration.collapsed(
hintText: l10n.commentAdd,
child: KeyedSubtree(
key: ValueKey(_sent),
child: NoteComposer(
key: commentFieldKey,
initial: null,
hint: l10n.commentAdd,
autofocus: true,
onChanged: (note) => setState(() => _draft = note),
),
onChanged: (_) => setState(() {}),
),
),
IconButton.filled(
key: commentSendKey,
tooltip: l10n.commentSend,
icon: const Icon(LucideIcons.arrowUp),
onPressed: _text.text.trim().isEmpty
? null
: () => unawaited(_send(todo)),
onPressed: _draft.isBlank ? null : () => unawaited(_send(todo)),
),
],
),
@ -250,12 +244,14 @@ class _CommentsSheetState() extends State<_CommentsSheet> {
class const _Comment({required final StoredComment comment, super.key})
extends StatelessWidget {
Future<void> _edit(BuildContext context, CommentContent content) async {
final l10n = AppLocalizations.of(context);
final todo = PersonScope.of<TodoPerson>(context);
final edited = await showDialog<String>(
context: context,
builder: (context) => _EditComment(text: content.text),
final edited = await editNote(
context,
content.text,
title: l10n.commentEdit,
);
if (edited != null && edited.isNotEmpty && edited != content.text) {
if (!edited.isBlank && edited != content.text) {
await todo.comments.update(
comment.id,
content.edited(edited, clock.now()),
@ -316,7 +312,9 @@ class const _Comment({required final StoredComment comment, super.key})
),
PopupMenuItem(
value: () => unawaited(
Clipboard.setData(ClipboardData(text: content.text)),
Clipboard.setData(
ClipboardData(text: content.text.plainText),
),
),
child: MenuEntry(LucideIcons.copy, l10n.commentCopy),
),
@ -329,7 +327,7 @@ class const _Comment({required final StoredComment comment, super.key})
if (await confirmDestructiveAction(
context,
title: l10n.commentDeleteTitle,
message: content.text,
message: content.text.plainText,
confirmLabel: l10n.commentDelete,
)) {
await todo.comments.delete([comment.id]);
@ -347,7 +345,7 @@ class const _Comment({required final StoredComment comment, super.key})
),
Padding(
padding: const EdgeInsetsDirectional.only(end: 12),
child: _LinkedText(text: content.text),
child: RichContentView(content: content.text),
),
if (content.reactions.isNotEmpty) ...[
const SizedBox(height: 8),
@ -415,92 +413,3 @@ Future<String?> pickEmoji(BuildContext context) => showAdaptiveSheet<String>(
);
},
);
/// [text] with its links underlined, a tap opening them in the browser.
class const _LinkedText({required final String text}) extends StatefulWidget {
@override
State<_LinkedText> createState() => _LinkedTextState();
}
class _LinkedTextState() extends State<_LinkedText> {
final _taps = <TapGestureRecognizer>[];
void _release() {
for (final tap in _taps) {
tap.dispose();
}
_taps.clear();
}
@override
void dispose() {
_release();
super.dispose();
}
@override
Widget build(BuildContext context) {
_release();
final link = TextStyle(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
);
return SelectableText.rich(
TextSpan(
children: [
for (final piece in linkPieces(widget.text))
switch (piece.url) {
final url? => TextSpan(
text: piece.text,
style: link,
recognizer: TapGestureRecognizer()
..onTap = () => unawaited(
launchUrl(url, mode: LaunchMode.externalApplication),
),
),
null => TextSpan(text: piece.text),
},
],
),
);
}
}
class const _EditComment({required final String text}) extends StatefulWidget {
@override
State<_EditComment> createState() => _EditCommentState();
}
class _EditCommentState() extends State<_EditComment> {
late final _text = TextEditingController(text: widget.text);
@override
void dispose() {
_text.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return AlertDialog(
title: Text(l10n.commentEdit),
content: TextField(
controller: _text,
autofocus: true,
minLines: 1,
maxLines: 8,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(_text.text.trim()),
child: Text(l10n.save),
),
],
);
}
}

View file

@ -1,5 +1,6 @@
import 'dart:typed_data';
import 'package:halkyon_todo_flutter/src/core/note/note.dart';
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/saved_filter.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -12,7 +13,7 @@ ByteData encodeFilter(FilterContent content) =>
encodeJson(filterSchemaVersion, {
'name': content.name,
'query': content.query,
if (content.description.isNotEmpty) 'description': content.description,
'description': ?writeNote(content.description),
'color': ?content.color?.name,
if (content.favorite) 'favorite': true,
'position': content.position,
@ -26,7 +27,7 @@ FilterContent? decodeFilter(ByteData payload) => decodeJson(
read: (json) => FilterContent(
name: json['name']! as String,
query: json['query']! as String,
description: json['description'] as String? ?? '',
description: readNote(json['description']),
color: PaletteColor.byName(json['color'] as String?),
favorite: json['favorite'] as bool? ?? false,
position: (json['position'] as num?)?.toDouble() ?? 0,

View file

@ -1,3 +1,4 @@
import 'package:halkyon_todo_flutter/src/core/note/note.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_search.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
@ -285,7 +286,7 @@ class _Reader(final String _text) {
if (_search.firstMatch(word) case final match?) {
final wanted = match[1]!;
return (task, _) => folded(
'${task.content.title}\n${task.content.description}',
'${task.content.title}\n${task.content.description.text}',
).contains(wanted);
}
return null;

View file

@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
/// A filter the person keeps (ADR 0033, D35): its [name], its [query] in
@ -7,7 +8,7 @@ import 'package:halkyon_ui/halkyon_ui.dart';
final class const FilterContent({
required final String name,
required final String query,
final String description = '',
final RichContent? description,
final PaletteColor? color,
final bool favorite = false,
final double position = 0,

View file

@ -1,4 +1,6 @@
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/core/note/note.dart';
import 'package:halkyon_todo_flutter/src/core/note/note_fields.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/filter_query.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/saved_filter.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task_views.dart';
@ -60,9 +62,7 @@ class const _FilterEditor({
class _FilterEditorState() extends State<_FilterEditor> {
late final _name = TextEditingController(text: widget.current?.name);
late final _description = TextEditingController(
text: widget.current?.description,
);
late var _description = widget.current?.description;
late final _query = TextEditingController(text: widget.current?.query);
late var _color = widget.current?.color ?? PaletteColor.cobalt;
late var _favorite = widget.current?.favorite ?? false;
@ -70,7 +70,6 @@ class _FilterEditorState() extends State<_FilterEditor> {
@override
void dispose() {
_name.dispose();
_description.dispose();
_query.dispose();
super.dispose();
}
@ -83,7 +82,10 @@ class _FilterEditorState() extends State<_FilterEditor> {
FilterContent(
name: _name.text.trim(),
query: _query.text.trim(),
description: _description.text.trim(),
description: switch (_description) {
final note? => withLinks(note),
null => null,
},
color: _color,
favorite: _favorite,
position: widget.current?.position ?? 0,
@ -142,12 +144,10 @@ class _FilterEditorState() extends State<_FilterEditor> {
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 12),
TextField(
controller: _description,
minLines: 1,
maxLines: 3,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(labelText: l10n.filterDescription),
NoteComposer(
initial: _description,
hint: l10n.filterDescription,
onChanged: (note) => _description = note,
),
const SizedBox(height: 12),
TextField(

View file

@ -1,6 +1,7 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/app/todo_paths.dart';
@ -44,7 +45,7 @@ FilterWorld filterWorldOf(TodoState state) => FilterWorld(
class const _TaskList({
required final List<OpenTask> tasks,
required final String nothing,
final String description = '',
final RichContent? description,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
@ -63,10 +64,10 @@ class const _TaskList({
96 + FloatingNavigationBar.coverOf(context),
),
children: [
if (description.isNotEmpty)
if (description case final description? when !description.isBlank)
Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 12),
child: Text(description, style: TextStyle(color: muted)),
child: RichContentView(content: description),
),
if (tasks.isEmpty)
Padding(

View file

@ -1,5 +1,5 @@
import 'package:flutter/foundation.dart';
import 'package:halkyon_todo_flutter/src/core/note/note.dart';
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
@ -37,7 +37,7 @@ ByteData encodeProject(ProjectContent content) =>
'position': content.position,
// As at first, nothing: a project's Display is written once set (D17).
if (!content.view.isDefault) 'view': encodeView(content.view),
if (content.description.isNotEmpty) 'description': content.description,
'description': ?writeNote(content.description),
'parentId': ?content.parentId,
if (content.favorite) 'favorite': true,
if (!content.defaultView.isDefault)
@ -118,7 +118,7 @@ ProjectContent? decodeProject(ByteData payload) => decodeJson(
color: PaletteColor.byName(json['color'] as String?),
position: (json['position'] as num?)?.toDouble() ?? 0,
view: decodeView(json['view']),
description: json['description'] as String? ?? '',
description: readNote(json['description']),
parentId: json['parentId'] as String?,
favorite: json['favorite'] == true,
defaultView: decodeView(json['defaultView']),

View file

@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -19,7 +20,7 @@ final class const ProjectContent({
final ProjectView view = const ProjectView(),
/// What the project is for, below its name in its editor (D28).
final String description = '',
final RichContent? description,
/// The project it is a sub-project of (D28); none: a project of its own.
final String? parentId,
@ -50,7 +51,7 @@ final class const ProjectContent({
PaletteColor? Function()? color,
double? position,
ProjectView? view,
String? description,
RichContent? description,
String? Function()? parentId,
bool? favorite,
ProjectView? defaultView,

View file

@ -1,4 +1,6 @@
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/core/note/note.dart';
import 'package:halkyon_todo_flutter/src/core/note/note_fields.dart';
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
@ -35,14 +37,26 @@ Future<SectionContent?> editSection(
SectionContent current,
) async {
final l10n = AppLocalizations.of(context);
var description = current.description;
final named = await _nameAndColor(
context,
title: current.name.isEmpty ? l10n.sectionAdd : l10n.sectionEdit,
name: current.name,
color: null,
below: NoteComposer(
initial: description,
hint: l10n.taskDescription,
onChanged: (note) => description = note,
),
);
return switch (named) {
(:final name, color: _) => current.copyWith(name: name),
(:final name, color: _) => current.copyWith(
name: name,
description: switch (description) {
final note? => withLinks(note),
null => null,
},
),
null => null,
};
}
@ -52,6 +66,9 @@ Future<({String name, PaletteColor? color})?> _nameAndColor(
required String title,
required String name,
required PaletteColor? color,
/// What the dialog asks below its name.
Widget? below,
}) {
final l10n = AppLocalizations.of(context);
return showDialog<({String name, PaletteColor? color})>(
@ -84,6 +101,7 @@ Future<({String name, PaletteColor? color})?> _nameAndColor(
decoration: InputDecoration(labelText: l10n.nameLabel),
onSubmitted: (_) => done(),
),
if (below != null) ...[const SizedBox(height: 12), below],
if (chosen case final current?) ...[
const SizedBox(height: 16),
Text(

View file

@ -1,4 +1,6 @@
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/core/note/note.dart';
import 'package:halkyon_todo_flutter/src/core/note/note_fields.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_tree.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
@ -55,9 +57,7 @@ class const _ProjectEditor({
class _ProjectEditorState() extends State<_ProjectEditor> {
late final _name = TextEditingController(text: widget.current?.name);
late final _description = TextEditingController(
text: widget.current?.description,
);
late var _description = widget.current?.description;
late var _color = widget.current?.color ?? PaletteColor.cobalt;
late var _parentId = widget.current?.parentId;
late var _favorite = widget.current?.favorite ?? false;
@ -75,7 +75,6 @@ class _ProjectEditorState() extends State<_ProjectEditor> {
@override
void dispose() {
_name.dispose();
_description.dispose();
super.dispose();
}
@ -84,7 +83,10 @@ class _ProjectEditorState() extends State<_ProjectEditor> {
if (name.isEmpty) {
return;
}
final description = _description.text.trim();
final description = switch (_description) {
final note? => withLinks(note),
null => null,
};
final current = widget.current;
Navigator.of(context).pop(switch (current) {
// A new project starts with its layout (D28).
@ -192,12 +194,10 @@ class _ProjectEditorState() extends State<_ProjectEditor> {
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: TextField(
controller: _description,
minLines: 1,
maxLines: 5,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(labelText: l10n.taskDescription),
child: NoteComposer(
initial: _description,
hint: l10n.taskDescription,
onChanged: (note) => _description = note,
),
),
RoundedGroup(

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/core/note/note.dart';
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/display_sheet.dart';
@ -438,7 +439,7 @@ class const _TableRow({
],
),
TaskColumn.description => _text(
content.description,
content.description.firstLine,
small?.copyWith(color: muted),
),
TaskColumn.date => switch (content.date) {

View file

@ -3,8 +3,10 @@ import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/core/note/note.dart';
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/project_view.dart';
import 'package:halkyon_todo_flutter/src/feature/project/widget/display_sheet.dart';
@ -216,6 +218,12 @@ class const ProjectTasks({required final String projectId, super.key})
slivers: [
SliverList.list(
children: [
if (state.projectOf(projectId)?.content?.description
case final description? when !description.isBlank)
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: RichContentView(content: description),
),
for (final (index, task) in loose.indexed)
_row(
state,
@ -410,10 +418,10 @@ class const ProjectTasks({required final String projectId, super.key})
'${content.name} ${tasks.where((t) => !t.content.completed).length}',
style: theme.textTheme.titleMedium,
),
subtitle: content.description.isEmpty
subtitle: content.description.isNone
? null
: Text(
content.description,
content.description.firstLine,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),

View file

@ -1,5 +1,6 @@
import 'dart:typed_data';
import 'package:halkyon_todo_flutter/src/core/note/note.dart';
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/section/model/section.dart';
@ -11,7 +12,7 @@ ByteData encodeSection(SectionContent content) =>
encodeJson(sectionSchemaVersion, {
'name': content.name,
'projectId': content.projectId,
if (content.description.isNotEmpty) 'description': content.description,
'description': ?writeNote(content.description),
'position': content.position,
});
@ -23,7 +24,7 @@ SectionContent? decodeSection(ByteData payload) => decodeJson(
read: (json) => SectionContent(
name: json['name']! as String,
projectId: json['projectId']! as String,
description: json['description'] as String? ?? '',
description: readNote(json['description']),
position: (json['position'] as num?)?.toDouble() ?? 0,
),
);

View file

@ -1,17 +1,18 @@
import 'package:equatable/equatable.dart';
import 'package:halkyon_editor/halkyon_editor.dart';
/// What a section of a project is (ADR 0033, D6): its [name], a
/// [description], the project it is in and its [position] there.
final class const SectionContent({
required final String name,
required final String projectId,
final String description = '',
final RichContent? description,
final double position = 0,
}) with Equatable {
SectionContent copyWith({
String? name,
String? projectId,
String? description,
RichContent? description,
double? position,
}) => SectionContent(
name: name ?? this.name,

Some files were not shown because too many files have changed in this diff Show more