feat(todo): comments on a task (ADR 0033, D36)

Plain text with links, written, edited and deleted in the task's sheet,
counted on its row. They are sealed in `task_comment`, the task they belong
to with them, and go with their task.

Whats-New: Comments: write on a task, edit or delete what you wrote, and open its links.
Whats-New-De: Kommentare: Schreibe zu einer Aufgabe, bearbeite oder lösche es und öffne Links darin.

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 19:53:28 +01:00
commit 016b74ff74
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 20953 additions and 6 deletions

View file

@ -9,6 +9,8 @@ import 'package:halkyon_app/testing.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/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/feature/comment/data/comment_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/comment/widget/task_comments.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/data/filter_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/saved_filter.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/widget/filter_editor.dart';
@ -70,6 +72,7 @@ void main() {
late MemorySealedRowGateway taskRows;
late MemorySealedRowGateway labelRows;
late MemorySealedRowGateway filterRows;
late MemorySealedRowGateway commentRows;
late MemorySealedRowGateway settingsRows;
late MemoryReminderGateway reminders;
late MemoryLocationGateway locationGateway;
@ -101,6 +104,7 @@ void main() {
taskRows = MemorySealedRowGateway();
labelRows = MemorySealedRowGateway();
filterRows = MemorySealedRowGateway();
commentRows = MemorySealedRowGateway();
settingsRows = MemorySealedRowGateway();
reminders = MemoryReminderGateway();
locationGateway = MemoryLocationGateway();
@ -204,6 +208,7 @@ void main() {
),
labels: LabelRepository(labelRows, keys),
filters: FilterRepository(filterRows, keys),
comments: CommentRepository(commentRows, keys),
settings: settings,
reminders: reminders,
locationReminders: locationGateway,
@ -406,6 +411,101 @@ void main() {
expect(find.text('No labels yet.'), findsOneWidget);
});
group('comments (D36)', () {
Future<void> openTask(WidgetTester tester) async {
await tester.runAsync(() async {
final inbox = await ProjectRepository(
projectRows,
keys,
).ensureInbox();
await TaskRepository(
taskRows,
keys,
).add(TaskContent(title: 'Vaccination', projectId: inbox));
});
projectRows.bringRemoteChanges();
taskRows.bringRemoteChanges();
await tester.pumpAndSettle();
await goTab(tester, 'Inbox');
await tester.tap(find.text('Vaccination'));
await tester.pumpAndSettle();
}
Future<void> write(WidgetTester tester, String text) async {
await tester.ensureVisible(find.byKey(commentFieldKey));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(commentFieldKey), text);
await tester.pumpAndSettle();
await tester.tap(find.byKey(commentSendKey));
await tester.pumpAndSettle();
commentRows.bringRemoteChanges();
await tester.pumpAndSettle();
}
testWidgets('a comment written shows in the sheet, the field empties, '
'and nothing blank is sent', (tester) async {
await start(tester, usedBefore: true);
await openTask(tester);
await tester.ensureVisible(find.byKey(commentFieldKey));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(commentFieldKey), ' ');
await tester.pumpAndSettle();
expect(
tester.widget<IconButton>(find.byKey(commentSendKey)).onPressed,
isNull,
);
await write(tester, 'Ask the vet');
expect(find.text('Ask the vet', findRichText: true), findsOneWidget);
expect(find.text('Comments 1'), findsOneWidget);
expect(
tester
.widget<TextField>(find.byKey(commentFieldKey))
.controller!
.text,
isEmpty,
);
});
testWidgets('a comment edited says so; one deleted is gone, after '
'asking', (tester) async {
await start(tester, usedBefore: true);
await openTask(tester);
await write(tester, 'Ask the vet');
await tester.tap(find.byTooltip('Comment menu'));
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'));
await tester.pumpAndSettle();
commentRows.bringRemoteChanges();
await tester.pumpAndSettle();
expect(find.text('Ask the nurse', findRichText: true), findsOneWidget);
expect(find.textContaining(', edited'), findsOneWidget);
await tester.tap(find.byTooltip('Comment menu'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete comment'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete comment').last);
await tester.pumpAndSettle();
commentRows.bringRemoteChanges();
await tester.pumpAndSettle();
expect(find.text('Comments'), findsOneWidget);
});
});
group('filters and labels (D35)', () {
Future<void> openFilters(WidgetTester tester) async {
await goTab(tester, 'Browse');

View file

@ -0,0 +1,80 @@
import 'package:flutter_test/flutter_test.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';
void main() {
final written = DateTime.utc(2026, 11, 6, 19, 16);
group("a comment's payload", () {
test('comes back as it went, edited or not', () {
final plain = CommentContent(
taskId: 't1',
text: 'Ask the vet',
writtenAt: written,
);
final edited = CommentContent(
taskId: 't1',
text: 'Ask the vet again',
writtenAt: written,
editedAt: written.add(const Duration(hours: 1)),
);
expect(decodeComment(encodeComment(plain)), plain);
expect(decodeComment(encodeComment(edited)), edited);
});
test('of a later schema is not read', () {
expect(
decodeComment(encodeJson(commentSchemaVersion + 1, {'taskId': 't'})),
isNull,
);
});
});
group('links in a comment', () {
List<String?> urls(String text) => [
for (final piece in linkPieces(text)) piece.url?.toString(),
];
test('plain text stays one piece', () {
expect(linkPieces('Nothing here'), [
(text: 'Nothing here', url: null),
]);
});
test('a link is found, without the sentence ending after it', () {
expect(linkPieces('See https://example.org/a. Then go'), [
(text: 'See ', url: null),
(
text: 'https://example.org/a',
url: Uri.parse('https://example.org/a'),
),
(text: '. Then go', url: null),
]);
});
test('nothing is lost between the pieces', () {
const text = 'a http://one.example b https://two.example/x?y=1 c';
expect(linkPieces(text).map((piece) => piece.text).join(), text);
expect(urls(text).nonNulls, [
'http://one.example',
'https://two.example/x?y=1',
]);
});
test('nothing but http and https ever links', () {
for (final text in [
'javascript:alert(1)',
'file:///etc/passwd',
'intent://scan#Intent;end',
'tel:+491234',
'mailto:someone@example.org',
'https://',
]) {
expect(urls(text).nonNulls, isEmpty, reason: text);
}
});
});
}

View file

@ -5,6 +5,8 @@ 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/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';
import 'package:halkyon_todo_flutter/src/feature/filter/data/filter_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/saved_filter.dart';
import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dart';
@ -516,5 +518,55 @@ void main() {
FilterRepository(rows, await keysOf(null)).add(filter),
throwsA(isA<KeysMissing>()),
);
final comments = CommentRepository(rows, await keysOf(alice));
final comment = CommentContent(
taskId: 't1',
text: 'Ask the vet',
writtenAt: DateTime.utc(2026, 11, 6),
);
await comments.add(comment);
expect((await comments.all()).last.content, comment);
expect(
(await CommentRepository(rows, await keysOf(bob)).all()).last.content,
isNull,
);
await expectLater(
CommentRepository(rows, await keysOf(null)).add(comment),
throwsA(isA<KeysMissing>()),
);
});
test('a task deleted takes the comments on it and its sub-tasks along, '
'and no others', () async {
final keys = await keysOf(alice);
final comments = CommentRepository(MemorySealedRowGateway(), keys);
final tasks = TaskRepository(
MemorySealedRowGateway(),
keys,
onDeleted: comments.deleteOn,
);
final parent = await tasks.add(
const TaskContent(title: 'Parent', projectId: 'p'),
);
final sub = await tasks.add(
TaskContent(title: 'Sub', projectId: 'p', parentId: parent),
);
final other = await tasks.add(
const TaskContent(title: 'Other', projectId: 'p'),
);
final at = DateTime.utc(2026, 11, 6);
for (final id in [parent, sub, other]) {
await comments.add(
CommentContent(taskId: id, text: id, writtenAt: at),
);
}
await tasks.delete(parent);
expect(
[for (final comment in await comments.all()) comment.content?.taskId],
[other],
);
});
}