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:
parent
648dfe4051
commit
a25cca12b4
9 changed files with 645 additions and 282 deletions
|
|
@ -7,8 +7,11 @@ import 'package:flutter/services.dart';
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_editor/halkyon_editor.dart';
|
||||
import 'package:halkyon_todo_flutter/src/app/todo_app.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/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/comment/data/comment_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/comment/model/comment.dart';
|
||||
|
|
@ -527,13 +530,45 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// The editor's change, as typing it makes: what a test can't do is
|
||||
/// type into the editor, which reads the platform's text input.
|
||||
void typeNote(WidgetTester tester, Finder within, String text) => tester
|
||||
.widget<RichContentEditor>(
|
||||
find.descendant(
|
||||
of: within,
|
||||
matching: find.byType(RichContentEditor),
|
||||
),
|
||||
)
|
||||
.onChanged(noteOf(text));
|
||||
|
||||
testWidgets('a description is written in the whole window, kept as it '
|
||||
'is left, and a task row shows its first line', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await openTask(tester);
|
||||
|
||||
await tester.tap(find.text('Description'));
|
||||
await tester.pumpAndSettle();
|
||||
typeNote(
|
||||
tester,
|
||||
find.byType(Scaffold).last,
|
||||
'Bring the mat\nAnd water',
|
||||
);
|
||||
await tester.tap(find.byType(BackButton));
|
||||
await tester.pumpAndSettle();
|
||||
taskRows.bringRemoteChanges();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Bring the mat', findRichText: true), findsWidgets);
|
||||
expect(find.text('And water', findRichText: true), findsOneWidget);
|
||||
});
|
||||
|
||||
Future<void> openComments(WidgetTester tester) async {
|
||||
await tester.tap(find.byKey(AddCommentBar.barKey));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> write(WidgetTester tester, String text) async {
|
||||
await tester.enterText(find.byKey(commentFieldKey), text);
|
||||
typeNote(tester, find.byKey(commentFieldKey), text);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(commentSendKey));
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -549,7 +584,7 @@ void main() {
|
|||
expect(find.byKey(AddCommentBar.barKey), findsOneWidget);
|
||||
expect(find.byKey(commentFieldKey), findsNothing);
|
||||
await openComments(tester);
|
||||
await tester.enterText(find.byKey(commentFieldKey), ' ');
|
||||
typeNote(tester, find.byKey(commentFieldKey), ' ');
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.widget<IconButton>(find.byKey(commentSendKey)).onPressed,
|
||||
|
|
@ -560,11 +595,8 @@ void main() {
|
|||
|
||||
expect(find.text('Ask the vet', findRichText: true), findsWidgets);
|
||||
expect(
|
||||
tester
|
||||
.widget<TextField>(find.byKey(commentFieldKey))
|
||||
.controller!
|
||||
.text,
|
||||
isEmpty,
|
||||
tester.widget<IconButton>(find.byKey(commentSendKey)).onPressed,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -595,14 +627,8 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Edit comment'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.descendant(
|
||||
of: find.byType(AlertDialog),
|
||||
matching: find.byType(TextField),
|
||||
),
|
||||
'Ask the nurse',
|
||||
);
|
||||
await tester.tap(find.text('Save'));
|
||||
typeNote(tester, find.byType(Scaffold).last, 'Ask the nurse');
|
||||
await tester.tap(find.byType(BackButton));
|
||||
await tester.pumpAndSettle();
|
||||
commentRows.bringRemoteChanges();
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -643,7 +669,7 @@ void main() {
|
|||
() => CommentRepository(commentRows, keys).add(
|
||||
CommentContent(
|
||||
taskId: 'none',
|
||||
text: 'x',
|
||||
text: noteOf('x'),
|
||||
writtenAt: DateTime.utc(2026),
|
||||
),
|
||||
),
|
||||
|
|
@ -657,7 +683,7 @@ void main() {
|
|||
() => comments.add(
|
||||
CommentContent(
|
||||
taskId: id!.single.id,
|
||||
text: 'Liked',
|
||||
text: noteOf('Liked'),
|
||||
writtenAt: DateTime.utc(2026),
|
||||
reactions: const ['❤️'],
|
||||
),
|
||||
|
|
@ -3051,7 +3077,10 @@ void main() {
|
|||
await tester.tap(find.byKey(quickAddTitleKey));
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.widgetWithText(TextField, 'Description'), findsOneWidget);
|
||||
expect(
|
||||
find.widgetWithText(NoteComposer, 'Description'),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.byTooltip('More').last);
|
||||
await tester.pumpAndSettle();
|
||||
|
|
@ -3636,7 +3665,7 @@ void main() {
|
|||
tester,
|
||||
TaskContent(
|
||||
title: title,
|
||||
description: 'With a description',
|
||||
description: noteOf('With a description'),
|
||||
projectId: inbox(),
|
||||
date: TaskDate(Day.of(DateTime.now()).plus(1)),
|
||||
),
|
||||
|
|
@ -3823,7 +3852,9 @@ void main() {
|
|||
reason: 'no name yet',
|
||||
);
|
||||
await tester.enterText(find.byType(TextField).first, 'Roadmap');
|
||||
await tester.enterText(find.byType(TextField).at(1), 'What comes');
|
||||
tester
|
||||
.widget<RichContentEditor>(find.byType(RichContentEditor))
|
||||
.onChanged(noteOf('What comes'));
|
||||
await tester.pump();
|
||||
expect(find.text('7/120'), findsOneWidget);
|
||||
await tester.tap(find.text('Parent project'));
|
||||
|
|
@ -3841,7 +3872,7 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
|
||||
final roadmap = (await stored(tester, 'Roadmap')).content!;
|
||||
expect(roadmap.description, 'What comes');
|
||||
expect(roadmap.description.text, 'What comes');
|
||||
expect(roadmap.parentId, ids['Home']);
|
||||
expect(roadmap.favorite, isTrue);
|
||||
expect(roadmap.view, const ProjectView(layout: ProjectLayout.board));
|
||||
|
|
@ -4699,7 +4730,7 @@ void main() {
|
|||
TaskContent(
|
||||
title: 'Buy oat milk',
|
||||
projectId: inbox(),
|
||||
description: 'From the Café',
|
||||
description: noteOf('From the Café'),
|
||||
),
|
||||
);
|
||||
await seed(tester, TaskContent(title: 'Call Anna', projectId: inbox()));
|
||||
|
|
|
|||
100
apps/todo/halkyon_todo_flutter/test/core/note/note_test.dart
Normal file
100
apps/todo/halkyon_todo_flutter/test/core/note/note_test.dart
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_editor/halkyon_editor.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/note/note.dart';
|
||||
|
||||
void main() {
|
||||
group('a note read from a payload', () {
|
||||
test('of text from before D38 becomes a document, lines and links '
|
||||
'kept', () {
|
||||
final note = readNote('Ask the vet\nsee https://example.org/a.')!;
|
||||
|
||||
expect(note.plainText, 'Ask the vet\nsee https://example.org/a.');
|
||||
expect(jsonEncode(note.toJson()), contains('"href"'));
|
||||
});
|
||||
|
||||
test('of a document comes back as written', () {
|
||||
final note = noteOf('Bring the mat');
|
||||
|
||||
expect(readNote(jsonDecode(jsonEncode(writeNote(note)))), note);
|
||||
});
|
||||
|
||||
test('of nothing is none', () {
|
||||
expect(readNote(null), isNull);
|
||||
expect(readNote(''), isNull);
|
||||
});
|
||||
|
||||
test('of a newer format is refused, not read half', () {
|
||||
expect(
|
||||
() => readNote({'formatVersion': 99, 'editor': <String, Object?>{}}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('a note written to a payload', () {
|
||||
test('holding nothing is left out', () {
|
||||
expect(writeNote(null), isNull);
|
||||
expect(writeNote(RichContent.empty()), isNull);
|
||||
expect(writeNote(noteOf(' ')), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('links', () {
|
||||
String hrefs(RichContent note) =>
|
||||
RegExp('"href":"([^"]*)"')
|
||||
.allMatches(jsonEncode(note.toJson()))
|
||||
.map((match) => match[1])
|
||||
.join(' ');
|
||||
|
||||
test('only http and https become links', () {
|
||||
expect(hrefs(noteOf('javascript:alert(1) file:///etc tel:123')), isEmpty);
|
||||
expect(
|
||||
hrefs(noteOf('See http://a.org and https://b.org')),
|
||||
'http://a.org https://b.org',
|
||||
);
|
||||
});
|
||||
|
||||
test('text around a link keeps its formats; a link stays as it is', () {
|
||||
final note = withLinks(
|
||||
RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'paragraph',
|
||||
'data': {
|
||||
'delta': [
|
||||
{
|
||||
'insert': 'bold https://a.org',
|
||||
'attributes': {'bold': true},
|
||||
},
|
||||
{
|
||||
'insert': 'mine',
|
||||
'attributes': {'href': 'https://b.org'},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(hrefs(note), 'https://a.org https://b.org');
|
||||
expect(note.plainText, 'bold https://a.orgmine');
|
||||
expect(jsonEncode(note.toJson()), contains('"bold":true'));
|
||||
});
|
||||
});
|
||||
|
||||
group('what a note says', () {
|
||||
test('none says nothing', () {
|
||||
const RichContent? none = null;
|
||||
|
||||
expect(none.text, isEmpty);
|
||||
expect(none.isNone, isTrue);
|
||||
expect(noteOf('First\nSecond').firstLine, 'First');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter_test/flutter_test.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/comment/data/comment_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/comment/model/comment.dart';
|
||||
|
|
@ -10,12 +11,12 @@ void main() {
|
|||
test('comes back as it went, edited or not', () {
|
||||
final plain = CommentContent(
|
||||
taskId: 't1',
|
||||
text: 'Ask the vet',
|
||||
text: noteOf('Ask the vet'),
|
||||
writtenAt: written,
|
||||
);
|
||||
final edited = CommentContent(
|
||||
taskId: 't1',
|
||||
text: 'Ask the vet again',
|
||||
text: noteOf('Ask the vet again'),
|
||||
writtenAt: written,
|
||||
editedAt: written.add(const Duration(hours: 1)),
|
||||
);
|
||||
|
|
@ -27,14 +28,14 @@ void main() {
|
|||
test('keeps its reactions, and an edit leaves them', () {
|
||||
final reacted = CommentContent(
|
||||
taskId: 't1',
|
||||
text: 'Ask the vet',
|
||||
text: noteOf('Ask the vet'),
|
||||
writtenAt: written,
|
||||
).reactedWith('❤️').reactedWith('👍');
|
||||
|
||||
expect(reacted.reactions, ['❤️', '👍']);
|
||||
expect(decodeComment(encodeComment(reacted)), reacted);
|
||||
expect(
|
||||
reacted.edited('Ask again', written).reactions,
|
||||
reacted.edited(noteOf('Ask again'), written).reactions,
|
||||
reacted.reactions,
|
||||
);
|
||||
});
|
||||
|
|
@ -42,7 +43,7 @@ void main() {
|
|||
test('the same reaction again takes it off', () {
|
||||
final reacted = CommentContent(
|
||||
taskId: 't1',
|
||||
text: 'Ask the vet',
|
||||
text: noteOf('Ask the vet'),
|
||||
writtenAt: written,
|
||||
).reactedWith('❤️').reactedWith('❤️');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter_test/flutter_test.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/filter/data/filter_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/filter/model/saved_filter.dart';
|
||||
|
|
@ -8,10 +9,10 @@ import 'package:halkyon_ui/halkyon_ui.dart';
|
|||
|
||||
void main() {
|
||||
test('a filter comes back as it went', () {
|
||||
const filter = FilterContent(
|
||||
final filter = FilterContent(
|
||||
name: 'Urgent',
|
||||
query: 'today & p1',
|
||||
description: "What can't wait",
|
||||
description: noteOf("What can't wait"),
|
||||
color: PaletteColor.cobalt,
|
||||
favorite: true,
|
||||
position: 3,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/note/note.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/filter/model/filter_query.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/repeat.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
|
|
@ -33,7 +34,7 @@ void main() {
|
|||
id: title,
|
||||
content: TaskContent(
|
||||
title: title,
|
||||
description: description,
|
||||
description: noteOf(description),
|
||||
projectId: project,
|
||||
sectionId: section,
|
||||
parentId: parent,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:convert';
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/note/note.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/project/data/project_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';
|
||||
|
|
@ -134,13 +135,13 @@ void main() {
|
|||
|
||||
test('its description, parent, favourite and default Display read back '
|
||||
'as written', () {
|
||||
const garden = ProjectContent(
|
||||
final garden = ProjectContent(
|
||||
name: 'Garden',
|
||||
description: 'Beds and the shed',
|
||||
description: noteOf('Beds and the shed'),
|
||||
parentId: 'home',
|
||||
favorite: true,
|
||||
view: ProjectView(layout: ProjectLayout.board, completed: true),
|
||||
defaultView: ProjectView(layout: ProjectLayout.board),
|
||||
view: const ProjectView(layout: ProjectLayout.board, completed: true),
|
||||
defaultView: const ProjectView(layout: ProjectLayout.board),
|
||||
);
|
||||
expect(decodeProject(encodeProject(garden)), garden);
|
||||
expect(written(encodeProject(garden)), {
|
||||
|
|
@ -148,7 +149,7 @@ void main() {
|
|||
'name': 'Garden',
|
||||
'position': 0.0,
|
||||
'view': {'layout': 'board', 'completed': true},
|
||||
'description': 'Beds and the shed',
|
||||
'description': noteOf('Beds and the shed').toJson(),
|
||||
'parentId': 'home',
|
||||
'favorite': true,
|
||||
'defaultView': {'layout': 'board'},
|
||||
|
|
@ -165,7 +166,7 @@ void main() {
|
|||
'view': {'layout': 'calendar'},
|
||||
}),
|
||||
);
|
||||
expect(older?.description, '');
|
||||
expect(older?.description, isNull);
|
||||
expect(older?.parentId, isNull);
|
||||
expect(older?.favorite, isFalse);
|
||||
expect(older?.defaultView, const ProjectView());
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import 'dart:convert';
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.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/label/data/label_payload.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/section/data/section_payload.dart';
|
||||
|
|
@ -24,7 +26,7 @@ void main() {
|
|||
final full = TaskContent(
|
||||
title: 'Pilates',
|
||||
projectId: 'p',
|
||||
description: 'Bring the mat',
|
||||
description: noteOf('Bring the mat'),
|
||||
sectionId: 's',
|
||||
parentId: 't',
|
||||
position: 3,
|
||||
|
|
@ -65,7 +67,7 @@ void main() {
|
|||
'schemaVersion': 1,
|
||||
'title': 'Pilates',
|
||||
'projectId': 'p',
|
||||
'description': 'Bring the mat',
|
||||
'description': noteOf('Bring the mat').toJson(),
|
||||
'sectionId': 's',
|
||||
'parentId': 't',
|
||||
'position': 3.0,
|
||||
|
|
@ -259,11 +261,21 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
test('a description written as text before D38 reads as a note', () {
|
||||
final payload = encodeJson(1, {
|
||||
'title': 'Yoga',
|
||||
'projectId': 'p',
|
||||
'description': 'Bring the mat',
|
||||
});
|
||||
|
||||
expect(decodeTask(payload)?.description.text, 'Bring the mat');
|
||||
});
|
||||
|
||||
test('a section and a label read back as they were written', () {
|
||||
const section = SectionContent(
|
||||
final section = SectionContent(
|
||||
name: 'Everyday life',
|
||||
projectId: 'p',
|
||||
description: 'About everyday life',
|
||||
description: noteOf('About everyday life'),
|
||||
position: 2,
|
||||
);
|
||||
expect(decodeSection(encodeSection(section)), section);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'dart:typed_data';
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/note/note.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/comment/data/comment_repository.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/comment/model/comment.dart';
|
||||
|
|
@ -522,7 +523,7 @@ void main() {
|
|||
final comments = CommentRepository(rows, await keysOf(alice));
|
||||
final comment = CommentContent(
|
||||
taskId: 't1',
|
||||
text: 'Ask the vet',
|
||||
text: noteOf('Ask the vet'),
|
||||
writtenAt: DateTime.utc(2026, 11, 6),
|
||||
);
|
||||
await comments.add(comment);
|
||||
|
|
@ -558,7 +559,7 @@ void main() {
|
|||
final at = DateTime.utc(2026, 11, 6);
|
||||
for (final id in [parent, sub, other]) {
|
||||
await comments.add(
|
||||
CommentContent(taskId: id, text: id, writtenAt: at),
|
||||
CommentContent(taskId: id, text: noteOf(id), writtenAt: at),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
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';
|
||||
|
|
@ -19,7 +20,7 @@ void main() {
|
|||
content: TaskContent(
|
||||
title: title,
|
||||
projectId: 'p',
|
||||
description: description,
|
||||
description: noteOf(description),
|
||||
labelIds: labels,
|
||||
date: day == null ? null : TaskDate(day),
|
||||
completedAt: done ? DateTime(2026, 11, 2) : null,
|
||||
|
|
|
|||
Loading…
Reference in a new issue