feat(todo): comments in boxes of their own, with reactions (ADR 0033, D36)

As Todoist's, after the developer's test: a task's sheet shows how many
comments it has, the latest on one line and Show all, with Add a comment
docked at its foot; comments open on a sheet of their own, each in its
box, the latest in view and the field staying at the foot. Reactions are
kept in the comment's sealed payload, each emoji once, picked from
emoji_picker_flutter, whose recents stay on the device.

Whats-New: Comments have a sheet of their own, each in its box, and take reactions; a task shows only the latest.
Whats-New-De: Kommentare haben ein eigenes Blatt, jeder in seinem Kasten, und nehmen Reaktionen an; eine Aufgabe zeigt nur den neuesten.

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:12:36 +01:00
commit 4875b26174
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 533 additions and 111 deletions

View file

@ -1,3 +1,4 @@
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'
show PointerDeviceKind, kLongPressTimeout;
@ -10,6 +11,7 @@ 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/model/comment.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';
@ -525,9 +527,12 @@ void main() {
await tester.pumpAndSettle();
}
Future<void> write(WidgetTester tester, String text) async {
await tester.ensureVisible(find.byKey(commentFieldKey));
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);
await tester.pumpAndSettle();
await tester.tap(find.byKey(commentSendKey));
@ -536,13 +541,14 @@ void main() {
await tester.pumpAndSettle();
}
testWidgets('a comment written shows in the sheet, the field empties, '
'and nothing blank is sent', (tester) async {
testWidgets('the bar stays at the foot of the sheet and opens the '
'comments, nothing blank being sent', (tester) async {
await start(tester, usedBefore: true);
await openTask(tester);
await tester.ensureVisible(find.byKey(commentFieldKey));
await tester.pumpAndSettle();
expect(find.byKey(AddCommentBar.barKey), findsOneWidget);
expect(find.byKey(commentFieldKey), findsNothing);
await openComments(tester);
await tester.enterText(find.byKey(commentFieldKey), ' ');
await tester.pumpAndSettle();
expect(
@ -552,8 +558,7 @@ void main() {
await write(tester, 'Ask the vet');
expect(find.text('Ask the vet', findRichText: true), findsOneWidget);
expect(find.text('Comments 1'), findsOneWidget);
expect(find.text('Ask the vet', findRichText: true), findsWidgets);
expect(
tester
.widget<TextField>(find.byKey(commentFieldKey))
@ -563,10 +568,27 @@ void main() {
);
});
testWidgets("the task's sheet shows the latest comment and Show all, "
'not every comment', (tester) async {
await start(tester, usedBefore: true);
await openTask(tester);
await openComments(tester);
await write(tester, 'First note');
await write(tester, 'Second note');
await tester.tap(find.byTooltip('Close').last);
await tester.pumpAndSettle();
expect(find.text('Comments 2'), findsOneWidget);
expect(find.text('Second note'), findsOneWidget);
expect(find.text('First note'), findsNothing);
expect(find.text('Show all'), findsOneWidget);
});
testWidgets('a comment edited says so; one deleted is gone, after '
'asking', (tester) async {
await start(tester, usedBefore: true);
await openTask(tester);
await openComments(tester);
await write(tester, 'Ask the vet');
await tester.tap(find.byTooltip('Comment menu'));
@ -584,7 +606,7 @@ void main() {
await tester.pumpAndSettle();
commentRows.bringRemoteChanges();
await tester.pumpAndSettle();
expect(find.text('Ask the nurse', findRichText: true), findsOneWidget);
expect(find.text('Ask the nurse', findRichText: true), findsWidgets);
expect(find.textContaining(', edited'), findsOneWidget);
await tester.tap(find.byTooltip('Comment menu'));
@ -596,7 +618,62 @@ void main() {
commentRows.bringRemoteChanges();
await tester.pumpAndSettle();
expect(find.text('Comments'), findsOneWidget);
expect(find.text('Ask the nurse', findRichText: true), findsNothing);
});
testWidgets("a comment's menu offers every emoji, nothing picked "
'leaving it as it was', (tester) async {
await start(tester, usedBefore: true);
await openTask(tester);
await openComments(tester);
await write(tester, 'Ask the vet');
await tester.tap(find.byTooltip('Comment menu'));
await tester.pumpAndSettle();
await tester.tap(find.text('Add a reaction'));
await tester.pumpAndSettle();
expect(find.byType(EmojiPicker), findsOneWidget);
});
testWidgets('a reaction kept shows on its comment, and a tap takes it '
'off', (tester) async {
await start(tester, usedBefore: true);
await tester.runAsync(
() => CommentRepository(commentRows, keys).add(
CommentContent(
taskId: 'none',
text: 'x',
writtenAt: DateTime.utc(2026),
),
),
);
await openTask(tester);
final id = await tester.runAsync(
() => TaskRepository(taskRows, keys).all(),
);
final comments = CommentRepository(commentRows, keys);
await tester.runAsync(
() => comments.add(
CommentContent(
taskId: id!.single.id,
text: 'Liked',
writtenAt: DateTime.utc(2026),
reactions: const ['❤️'],
),
),
);
commentRows.bringRemoteChanges();
await tester.pumpAndSettle();
await openComments(tester);
expect(find.text('❤️ 1'), findsOneWidget);
await tester.tap(find.text('❤️ 1'));
await tester.pumpAndSettle();
commentRows.bringRemoteChanges();
await tester.pumpAndSettle();
expect(find.text('❤️ 1'), findsNothing);
});
});

View file

@ -24,6 +24,31 @@ void main() {
expect(decodeComment(encodeComment(edited)), edited);
});
test('keeps its reactions, and an edit leaves them', () {
final reacted = CommentContent(
taskId: 't1',
text: 'Ask the vet',
writtenAt: written,
).reactedWith('❤️').reactedWith('👍');
expect(reacted.reactions, ['❤️', '👍']);
expect(decodeComment(encodeComment(reacted)), reacted);
expect(
reacted.edited('Ask again', written).reactions,
reacted.reactions,
);
});
test('the same reaction again takes it off', () {
final reacted = CommentContent(
taskId: 't1',
text: 'Ask the vet',
writtenAt: written,
).reactedWith('❤️').reactedWith('❤️');
expect(reacted.reactions, isEmpty);
});
test('of a later schema is not read', () {
expect(
decodeComment(encodeJson(commentSchemaVersion + 1, {'taskId': 't'})),