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
11 changed files with 533 additions and 111 deletions

View file

@ -55,6 +55,10 @@
"filterMatches": "{count, plural, =1{1 Aufgabe passt} other{{count} Aufgaben passen}}", "filterMatches": "{count, plural, =1{1 Aufgabe passt} other{{count} Aufgaben passen}}",
"filterNotRead": "Nicht verstanden bei „{near}“", "filterNotRead": "Nicht verstanden bei „{near}“",
"filterNotReadEnd": "Die Abfrage endet zu früh", "filterNotReadEnd": "Die Abfrage endet zu früh",
"commentShowAll": "Alle anzeigen",
"commentReact": "Reaktion hinzufügen",
"commentReactionRemove": "{emoji} entfernen",
"commentNone": "Halte hier Notizen, Links oder anderes fest.",
"commentsTitle": "Kommentare", "commentsTitle": "Kommentare",
"commentAdd": "Kommentar schreiben", "commentAdd": "Kommentar schreiben",
"commentSend": "Kommentar senden", "commentSend": "Kommentar senden",

View file

@ -115,6 +115,17 @@
"@filterNotReadEnd": { "@filterNotReadEnd": {
"description": "The filter editor when its query stops before it is complete." "description": "The filter editor when its query stops before it is complete."
}, },
"commentShowAll": "Show all",
"commentReact": "Add a reaction",
"commentReactionRemove": "Remove {emoji}",
"commentNone": "Add relevant notes, links or anything else here.",
"@commentReactionRemove": {
"placeholders": {
"emoji": {
"type": "String"
}
}
},
"commentsTitle": "Comments", "commentsTitle": "Comments",
"commentAdd": "Add a comment", "commentAdd": "Add a comment",
"commentSend": "Send comment", "commentSend": "Send comment",

View file

@ -430,6 +430,30 @@ abstract class AppLocalizations {
/// **'This query ends too soon'** /// **'This query ends too soon'**
String get filterNotReadEnd; String get filterNotReadEnd;
/// No description provided for @commentShowAll.
///
/// In en, this message translates to:
/// **'Show all'**
String get commentShowAll;
/// No description provided for @commentReact.
///
/// In en, this message translates to:
/// **'Add a reaction'**
String get commentReact;
/// No description provided for @commentReactionRemove.
///
/// In en, this message translates to:
/// **'Remove {emoji}'**
String commentReactionRemove(String emoji);
/// No description provided for @commentNone.
///
/// In en, this message translates to:
/// **'Add relevant notes, links or anything else here.'**
String get commentNone;
/// No description provided for @commentsTitle. /// No description provided for @commentsTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -203,6 +203,20 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get filterNotReadEnd => 'Die Abfrage endet zu früh'; String get filterNotReadEnd => 'Die Abfrage endet zu früh';
@override
String get commentShowAll => 'Alle anzeigen';
@override
String get commentReact => 'Reaktion hinzufügen';
@override
String commentReactionRemove(String emoji) {
return '$emoji entfernen';
}
@override
String get commentNone => 'Halte hier Notizen, Links oder anderes fest.';
@override @override
String get commentsTitle => 'Kommentare'; String get commentsTitle => 'Kommentare';

View file

@ -203,6 +203,20 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get filterNotReadEnd => 'This query ends too soon'; String get filterNotReadEnd => 'This query ends too soon';
@override
String get commentShowAll => 'Show all';
@override
String get commentReact => 'Add a reaction';
@override
String commentReactionRemove(String emoji) {
return 'Remove $emoji';
}
@override
String get commentNone => 'Add relevant notes, links or anything else here.';
@override @override
String get commentsTitle => 'Comments'; String get commentsTitle => 'Comments';

View file

@ -13,6 +13,7 @@ ByteData encodeComment(CommentContent content) =>
'text': content.text, 'text': content.text,
'writtenAt': content.writtenAt.toUtc().toIso8601String(), 'writtenAt': content.writtenAt.toUtc().toIso8601String(),
'editedAt': ?content.editedAt?.toUtc().toIso8601String(), 'editedAt': ?content.editedAt?.toUtc().toIso8601String(),
if (content.reactions.isNotEmpty) 'reactions': content.reactions,
}); });
/// The comment [payload] holds, opened; null for one it can't read. /// The comment [payload] holds, opened; null for one it can't read.
@ -28,5 +29,9 @@ CommentContent? decodeComment(ByteData payload) => decodeJson(
final String at => DateTime.parse(at), final String at => DateTime.parse(at),
_ => null, _ => null,
}, },
reactions: [
for (final emoji in json['reactions'] as List<Object?>? ?? const [])
if (emoji is String) emoji,
],
), ),
); );

View file

@ -8,9 +8,35 @@ final class const CommentContent({
required final String text, required final String text,
required final DateTime writtenAt, required final DateTime writtenAt,
final DateTime? editedAt, final DateTime? editedAt,
/// The emoji it was reacted with, each once, oldest first.
final List<String> reactions = const [],
}) with Equatable { }) with Equatable {
/// [emoji] added to its reactions, or taken off where it is among them.
CommentContent reactedWith(String emoji) => CommentContent(
taskId: taskId,
text: text,
writtenAt: writtenAt,
editedAt: editedAt,
reactions: reactions.contains(emoji)
? [
for (final kept in reactions)
if (kept != emoji) kept,
]
: [...reactions, emoji],
);
/// Its text changed at [at], its reactions staying.
CommentContent edited(String changed, DateTime at) => CommentContent(
taskId: taskId,
text: changed,
writtenAt: writtenAt,
editedAt: at,
reactions: reactions,
);
@override @override
List<Object?> get props => [taskId, text, writtenAt, editedAt]; List<Object?> get props => [taskId, text, writtenAt, editedAt, reactions];
} }
/// A comment as kept: its [id], and [content] null where it doesn't open. /// A comment as kept: its [id], and [content] null where it doesn't open.

View file

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@ -23,17 +24,112 @@ const commentFieldKey = ValueKey('comment-field');
/// The key of the button sending a comment. /// The key of the button sending a comment.
const commentSendKey = ValueKey('comment-send'); const commentSendKey = ValueKey('comment-send');
/// The comments on the task [taskId] in its sheet (ADR 0033, D36): each with /// The bar at the foot of a task's sheet (ADR 0033, D36, as Todoist's),
/// when it was written and a menu to edit, copy or delete it, its links /// which stays while the task scrolls and opens its comments to write one.
/// opening in the browser, and a field to write one more. class const AddCommentBar({required final String taskId, super.key})
class const TaskComments({required final String taskId, super.key}) extends StatelessWidget {
extends StatefulWidget { static const barKey = ValueKey('add-comment-bar');
@override @override
State<TaskComments> createState() => _TaskCommentsState(); Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
child: Material(
color: theme.colorScheme.surfaceContainerHighest,
clipBehavior: Clip.antiAlias,
shape: const StadiumBorder(),
child: InkWell(
key: barKey,
onTap: () => unawaited(showComments(context, taskId)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Text(
l10n.commentAdd,
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
),
),
),
),
),
);
}
} }
class _TaskCommentsState() extends State<TaskComments> { /// What a task's sheet shows of its comments (as Todoist's): how many, the
/// latest on one line, and Show all opening them.
class const CommentsPreview({required final String taskId, super.key})
extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final comments = context.watch<TodoCubit>().state.commentsOn(taskId);
if (comments.isEmpty) {
return const SizedBox.shrink();
}
return InkWell(
onTap: () => unawaited(showComments(context, taskId)),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'${l10n.commentsTitle} ${comments.length}',
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
comments.last.content!.text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (comments.length > 1)
Align(
child: TextButton(
onPressed: () => unawaited(showComments(context, taskId)),
child: Text(l10n.commentShowAll),
),
)
else
const SizedBox(height: 8),
],
),
),
);
}
}
/// The comments on the task [taskId] on a sheet of their own (D36, as
/// Todoist's): each in its box, the latest in view, and the field to write
/// one more at the foot, ready, whatever has scrolled.
Future<void> showComments(BuildContext context, String taskId) =>
showAdaptiveSheet<void>(
context,
builder: (context) => _CommentsSheet(taskId: taskId),
);
class const _CommentsSheet({required final String taskId})
extends StatefulWidget {
@override
State<_CommentsSheet> createState() => _CommentsSheetState();
}
class _CommentsSheetState() extends State<_CommentsSheet> {
final _text = TextEditingController(); final _text = TextEditingController();
final _last = GlobalKey();
@override
void initState() {
super.initState();
_showLatest();
}
@override @override
void dispose() { void dispose() {
@ -41,6 +137,12 @@ class _TaskCommentsState() extends State<TaskComments> {
super.dispose(); super.dispose();
} }
void _showLatest() => WidgetsBinding.instance.addPostFrameCallback((_) {
if (_last.currentContext case final context? when context.mounted) {
Scrollable.ensureVisible(context);
}
});
Future<void> _send(TodoPerson todo) async { Future<void> _send(TodoPerson todo) async {
final text = _text.text.trim(); final text = _text.text.trim();
if (text.isEmpty) { if (text.isEmpty) {
@ -55,62 +157,97 @@ class _TaskCommentsState() extends State<TaskComments> {
writtenAt: clock.now(), writtenAt: clock.now(),
), ),
); );
_showLatest();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final theme = Theme.of(context); final theme = Theme.of(context);
final muted = theme.colorScheme.onSurfaceVariant;
final todo = PersonScope.of<TodoPerson>(context); final todo = PersonScope.of<TodoPerson>(context);
final comments = context.watch<TodoCubit>().state.commentsOn(widget.taskId); final state = context.watch<TodoCubit>().state;
return Column( final comments = state.commentsOn(widget.taskId);
crossAxisAlignment: CrossAxisAlignment.stretch, final title = state.tasks
children: [ .where((task) => task.id == widget.taskId)
Padding( .firstOrNull
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), ?.content
child: Text( .title;
comments.isEmpty return SheetScaffold(
? l10n.commentsTitle title: l10n.commentsTitle,
: '${l10n.commentsTitle} ${comments.length}', heading: Column(
style: theme.textTheme.titleSmall?.copyWith( crossAxisAlignment: CrossAxisAlignment.start,
color: theme.colorScheme.onSurfaceVariant, children: [
Text(l10n.commentsTitle, style: theme.textTheme.titleLarge),
if (title != null)
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(color: muted),
), ),
), ],
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (comments.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 16),
child: Text(
l10n.commentNone,
textAlign: TextAlign.center,
style: TextStyle(color: muted),
),
),
for (final (index, comment) in comments.indexed)
_Comment(
key: index == comments.length - 1 ? _last : null,
comment: comment,
),
],
),
footer: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(24),
), ),
for (final comment in comments) _Comment(comment: comment), padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
Padding( child: Row(
padding: const EdgeInsets.fromLTRB(12, 4, 4, 8), crossAxisAlignment: CrossAxisAlignment.end,
child: Row( children: [
crossAxisAlignment: CrossAxisAlignment.end, Expanded(
children: [ child: TextField(
Expanded( key: commentFieldKey,
child: TextField( controller: _text,
key: commentFieldKey, autofocus: true,
controller: _text, minLines: 1,
minLines: 1, maxLines: 6,
maxLines: 6, textCapitalization: TextCapitalization.sentences,
textCapitalization: TextCapitalization.sentences, decoration: InputDecoration.collapsed(
decoration: InputDecoration(hintText: l10n.commentAdd), hintText: l10n.commentAdd,
onChanged: (_) => setState(() {}),
), ),
onChanged: (_) => setState(() {}),
), ),
IconButton( ),
key: commentSendKey, IconButton.filled(
tooltip: l10n.commentSend, key: commentSendKey,
icon: const Icon(LucideIcons.arrowUp), tooltip: l10n.commentSend,
onPressed: _text.text.trim().isEmpty icon: const Icon(LucideIcons.arrowUp),
? null onPressed: _text.text.trim().isEmpty
: () => unawaited(_send(todo)), ? null
), : () => unawaited(_send(todo)),
], ),
), ],
), ),
], ),
); );
} }
} }
class const _Comment({required final StoredComment comment}) /// A comment in a box of its own (D36, as Todoist's), set off from the next:
/// when it was written, its menu, its text and its reactions.
class const _Comment({required final StoredComment comment, super.key})
extends StatelessWidget { extends StatelessWidget {
Future<void> _edit(BuildContext context, CommentContent content) async { Future<void> _edit(BuildContext context, CommentContent content) async {
final todo = PersonScope.of<TodoPerson>(context); final todo = PersonScope.of<TodoPerson>(context);
@ -121,16 +258,18 @@ class const _Comment({required final StoredComment comment})
if (edited != null && edited.isNotEmpty && edited != content.text) { if (edited != null && edited.isNotEmpty && edited != content.text) {
await todo.comments.update( await todo.comments.update(
comment.id, comment.id,
CommentContent( content.edited(edited, clock.now()),
taskId: content.taskId,
text: edited,
writtenAt: content.writtenAt,
editedAt: clock.now(),
),
); );
} }
} }
Future<void> _react(BuildContext context, CommentContent content) async {
final todo = PersonScope.of<TodoPerson>(context);
if (await pickEmoji(context) case final emoji?) {
await todo.comments.update(comment.id, content.reactedWith(emoji));
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
@ -143,63 +282,140 @@ class const _Comment({required final StoredComment comment})
'${timeText(context, local.hour * 60 + local.minute)}'; '${timeText(context, local.hour * 60 + local.minute)}';
final todo = PersonScope.of<TodoPerson>(context); final todo = PersonScope.of<TodoPerson>(context);
return Padding( return Padding(
padding: const EdgeInsetsDirectional.only(start: 16, bottom: 4), padding: const EdgeInsets.symmetric(vertical: 4),
child: Row( child: Material(
crossAxisAlignment: CrossAxisAlignment.start, color: theme.colorScheme.surfaceContainerHigh,
children: [ clipBehavior: Clip.antiAlias,
Expanded( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Column( child: Padding(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.fromLTRB(16, 4, 4, 12),
children: [ child: Column(
Text( crossAxisAlignment: CrossAxisAlignment.start,
content.editedAt == null ? when : l10n.commentEdited(when), children: [
style: theme.textTheme.bodySmall?.copyWith(color: muted), Row(
children: [
Expanded(
child: Text(
content.editedAt == null
? when
: l10n.commentEdited(when),
style: theme.textTheme.bodySmall?.copyWith(color: muted),
),
),
PopupMenuButton<VoidCallback>(
tooltip: l10n.commentMenu,
icon: const Icon(LucideIcons.ellipsis),
onSelected: (action) => action(),
itemBuilder: (context) => [
PopupMenuItem(
value: () => unawaited(_react(context, content)),
child: MenuEntry(
LucideIcons.smilePlus,
l10n.commentReact,
),
),
PopupMenuItem(
value: () => unawaited(
Clipboard.setData(ClipboardData(text: content.text)),
),
child: MenuEntry(LucideIcons.copy, l10n.commentCopy),
),
PopupMenuItem(
value: () => unawaited(_edit(context, content)),
child: MenuEntry(LucideIcons.pencil, l10n.commentEdit),
),
PopupMenuItem(
value: () async {
if (await confirmDestructiveAction(
context,
title: l10n.commentDeleteTitle,
message: content.text,
confirmLabel: l10n.commentDelete,
)) {
await todo.comments.delete([comment.id]);
}
},
child: MenuEntry(
LucideIcons.trash2,
l10n.commentDelete,
destructive: true,
),
),
],
),
],
),
Padding(
padding: const EdgeInsetsDirectional.only(end: 12),
child: _LinkedText(text: content.text),
),
if (content.reactions.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final emoji in content.reactions)
ActionChip(
visualDensity: VisualDensity.compact,
tooltip: l10n.commentReactionRemove(emoji),
label: Text('$emoji 1'),
onPressed: () => todo.comments.update(
comment.id,
content.reactedWith(emoji),
),
),
IconButton(
visualDensity: VisualDensity.compact,
tooltip: l10n.commentReact,
icon: const Icon(LucideIcons.smilePlus, size: 18),
onPressed: () => unawaited(_react(context, content)),
),
],
), ),
const SizedBox(height: 2),
_LinkedText(text: content.text),
], ],
),
),
PopupMenuButton<VoidCallback>(
tooltip: l10n.commentMenu,
icon: const Icon(LucideIcons.ellipsis),
onSelected: (action) => action(),
itemBuilder: (context) => [
PopupMenuItem(
value: () => unawaited(_edit(context, content)),
child: MenuEntry(LucideIcons.pencil, l10n.commentEdit),
),
PopupMenuItem(
value: () => unawaited(
Clipboard.setData(ClipboardData(text: content.text)),
),
child: MenuEntry(LucideIcons.copy, l10n.commentCopy),
),
PopupMenuItem(
value: () async {
if (await confirmDestructiveAction(
context,
title: l10n.commentDeleteTitle,
message: content.text,
confirmLabel: l10n.commentDelete,
)) {
await todo.comments.delete([comment.id]);
}
},
child: MenuEntry(
LucideIcons.trash2,
l10n.commentDelete,
destructive: true,
),
),
], ],
), ),
], ),
), ),
); );
} }
} }
/// Every emoji to pick from, recents first and searchable, as Todoist's
/// (D36); null where none was picked. The recents stay on this device.
Future<String?> pickEmoji(BuildContext context) => showAdaptiveSheet<String>(
context,
builder: (context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return SheetScaffold(
title: l10n.commentReact,
body: SizedBox(
height: 360,
child: EmojiPicker(
onEmojiSelected: (_, emoji) => Navigator.of(context).pop(emoji.emoji),
config: Config(
locale: Localizations.localeOf(context),
bottomActionBarConfig: const BottomActionBarConfig(
showBackspaceButton: false,
),
emojiViewConfig: EmojiViewConfig(
backgroundColor: theme.colorScheme.surface,
),
categoryViewConfig: CategoryViewConfig(
backgroundColor: theme.colorScheme.surface,
indicatorColor: theme.colorScheme.primary,
iconColorSelected: theme.colorScheme.primary,
),
),
),
),
);
},
);
/// [text] with its links underlined, a tap opening them in the browser. /// [text] with its links underlined, a tap opening them in the browser.
class const _LinkedText({required final String text}) extends StatefulWidget { class const _LinkedText({required final String text}) extends StatefulWidget {
@override @override

View file

@ -465,12 +465,14 @@ class _TaskSheetState() extends State<_TaskSheet> {
required Widget body, required Widget body,
Widget? heading, Widget? heading,
Widget? action, Widget? action,
Widget? footer,
}) { }) {
if (_SheetScroll.maybeOf(context) case final scroll? when !widget.inPane) { if (_SheetScroll.maybeOf(context) case final scroll? when !widget.inPane) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
return ScaffoldMessenger( return ScaffoldMessenger(
child: Scaffold( child: Scaffold(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
bottomNavigationBar: footer,
body: ListView( body: ListView(
controller: scroll, controller: scroll,
padding: const EdgeInsets.fromLTRB(12, 0, 12, 24), padding: const EdgeInsets.fromLTRB(12, 0, 12, 24),
@ -515,6 +517,7 @@ class _TaskSheetState() extends State<_TaskSheet> {
hasSnackBars: true, hasSnackBars: true,
action: action, action: action,
body: body, body: body,
footer: footer,
); );
} }
return Column( return Column(
@ -549,6 +552,7 @@ class _TaskSheetState() extends State<_TaskSheet> {
child: body, child: body,
), ),
), ),
?footer,
], ],
); );
} }
@ -689,6 +693,7 @@ class _TaskSheetState() extends State<_TaskSheet> {
}, },
), ),
action: _menu(context, task, subTasks), action: _menu(context, task, subTasks),
footer: AddCommentBar(taskId: task.id),
// Its parts in rounded boxes, as Todoist's (the developer's review, // Its parts in rounded boxes, as Todoist's (the developer's review,
// 3 Nov 2026): the title, the description, the date, the reminders, // 3 Nov 2026): the title, the description, the date, the reminders,
// then the priority, labels, deadline and project as chips, and the // then the priority, labels, deadline and project as chips, and the
@ -907,7 +912,8 @@ class _TaskSheetState() extends State<_TaskSheet> {
], ],
), ),
), ),
_Box(child: TaskComments(taskId: task.id)), if (state.commentsOn(task.id).isNotEmpty)
_Box(child: CommentsPreview(taskId: task.id)),
], ],
), ),
); );

View file

@ -1,3 +1,4 @@
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart' import 'package:flutter/gestures.dart'
show PointerDeviceKind, kLongPressTimeout; 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/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.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/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/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/data/filter_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/saved_filter.dart'; import 'package:halkyon_todo_flutter/src/feature/filter/model/saved_filter.dart';
@ -525,9 +527,12 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }
Future<void> write(WidgetTester tester, String text) async { Future<void> openComments(WidgetTester tester) async {
await tester.ensureVisible(find.byKey(commentFieldKey)); await tester.tap(find.byKey(AddCommentBar.barKey));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
}
Future<void> write(WidgetTester tester, String text) async {
await tester.enterText(find.byKey(commentFieldKey), text); await tester.enterText(find.byKey(commentFieldKey), text);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byKey(commentSendKey)); await tester.tap(find.byKey(commentSendKey));
@ -536,13 +541,14 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }
testWidgets('a comment written shows in the sheet, the field empties, ' testWidgets('the bar stays at the foot of the sheet and opens the '
'and nothing blank is sent', (tester) async { 'comments, nothing blank being sent', (tester) async {
await start(tester, usedBefore: true); await start(tester, usedBefore: true);
await openTask(tester); await openTask(tester);
await tester.ensureVisible(find.byKey(commentFieldKey)); expect(find.byKey(AddCommentBar.barKey), findsOneWidget);
await tester.pumpAndSettle(); expect(find.byKey(commentFieldKey), findsNothing);
await openComments(tester);
await tester.enterText(find.byKey(commentFieldKey), ' '); await tester.enterText(find.byKey(commentFieldKey), ' ');
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect( expect(
@ -552,8 +558,7 @@ void main() {
await write(tester, 'Ask the vet'); await write(tester, 'Ask the vet');
expect(find.text('Ask the vet', findRichText: true), findsOneWidget); expect(find.text('Ask the vet', findRichText: true), findsWidgets);
expect(find.text('Comments 1'), findsOneWidget);
expect( expect(
tester tester
.widget<TextField>(find.byKey(commentFieldKey)) .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 ' testWidgets('a comment edited says so; one deleted is gone, after '
'asking', (tester) async { 'asking', (tester) async {
await start(tester, usedBefore: true); await start(tester, usedBefore: true);
await openTask(tester); await openTask(tester);
await openComments(tester);
await write(tester, 'Ask the vet'); await write(tester, 'Ask the vet');
await tester.tap(find.byTooltip('Comment menu')); await tester.tap(find.byTooltip('Comment menu'));
@ -584,7 +606,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
commentRows.bringRemoteChanges(); commentRows.bringRemoteChanges();
await tester.pumpAndSettle(); 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); expect(find.textContaining(', edited'), findsOneWidget);
await tester.tap(find.byTooltip('Comment menu')); await tester.tap(find.byTooltip('Comment menu'));
@ -596,7 +618,62 @@ void main() {
commentRows.bringRemoteChanges(); commentRows.bringRemoteChanges();
await tester.pumpAndSettle(); 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); 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', () { test('of a later schema is not read', () {
expect( expect(
decodeComment(encodeJson(commentSchemaVersion + 1, {'taskId': 't'})), decodeComment(encodeJson(commentSchemaVersion + 1, {'taskId': 't'})),