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:
parent
6e93ef82b5
commit
4875b26174
11 changed files with 533 additions and 111 deletions
|
|
@ -55,6 +55,10 @@
|
|||
"filterMatches": "{count, plural, =1{1 Aufgabe passt} other{{count} Aufgaben passen}}",
|
||||
"filterNotRead": "Nicht verstanden bei „{near}“",
|
||||
"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",
|
||||
"commentAdd": "Kommentar schreiben",
|
||||
"commentSend": "Kommentar senden",
|
||||
|
|
|
|||
|
|
@ -115,6 +115,17 @@
|
|||
"@filterNotReadEnd": {
|
||||
"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",
|
||||
"commentAdd": "Add a comment",
|
||||
"commentSend": "Send comment",
|
||||
|
|
|
|||
|
|
@ -430,6 +430,30 @@ abstract class AppLocalizations {
|
|||
/// **'This query ends too soon'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -203,6 +203,20 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
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
|
||||
String get commentsTitle => 'Kommentare';
|
||||
|
||||
|
|
|
|||
|
|
@ -203,6 +203,20 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
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
|
||||
String get commentsTitle => 'Comments';
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ ByteData encodeComment(CommentContent content) =>
|
|||
'text': content.text,
|
||||
'writtenAt': content.writtenAt.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.
|
||||
|
|
@ -28,5 +29,9 @@ CommentContent? decodeComment(ByteData payload) => decodeJson(
|
|||
final String at => DateTime.parse(at),
|
||||
_ => null,
|
||||
},
|
||||
reactions: [
|
||||
for (final emoji in json['reactions'] as List<Object?>? ?? const [])
|
||||
if (emoji is String) emoji,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,9 +8,35 @@ final class const CommentContent({
|
|||
required final String text,
|
||||
required final DateTime writtenAt,
|
||||
final DateTime? editedAt,
|
||||
|
||||
/// The emoji it was reacted with, each once, oldest first.
|
||||
final List<String> reactions = const [],
|
||||
}) 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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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';
|
||||
|
|
@ -23,17 +24,112 @@ const commentFieldKey = ValueKey('comment-field');
|
|||
/// The key of the button sending a comment.
|
||||
const commentSendKey = ValueKey('comment-send');
|
||||
|
||||
/// The comments on the task [taskId] in its sheet (ADR 0033, D36): each with
|
||||
/// when it was written and a menu to edit, copy or delete it, its links
|
||||
/// opening in the browser, and a field to write one more.
|
||||
class const TaskComments({required final String taskId, super.key})
|
||||
extends StatefulWidget {
|
||||
/// The bar at the foot of a task's sheet (ADR 0033, D36, as Todoist's),
|
||||
/// which stays while the task scrolls and opens its comments to write one.
|
||||
class const AddCommentBar({required final String taskId, super.key})
|
||||
extends StatelessWidget {
|
||||
static const barKey = ValueKey('add-comment-bar');
|
||||
|
||||
@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 _last = GlobalKey();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_showLatest();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
|
|
@ -41,6 +137,12 @@ class _TaskCommentsState() extends State<TaskComments> {
|
|||
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 {
|
||||
final text = _text.text.trim();
|
||||
if (text.isEmpty) {
|
||||
|
|
@ -55,62 +157,97 @@ class _TaskCommentsState() extends State<TaskComments> {
|
|||
writtenAt: clock.now(),
|
||||
),
|
||||
);
|
||||
_showLatest();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final muted = theme.colorScheme.onSurfaceVariant;
|
||||
final todo = PersonScope.of<TodoPerson>(context);
|
||||
final comments = context.watch<TodoCubit>().state.commentsOn(widget.taskId);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Text(
|
||||
comments.isEmpty
|
||||
? l10n.commentsTitle
|
||||
: '${l10n.commentsTitle} ${comments.length}',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
final state = context.watch<TodoCubit>().state;
|
||||
final comments = state.commentsOn(widget.taskId);
|
||||
final title = state.tasks
|
||||
.where((task) => task.id == widget.taskId)
|
||||
.firstOrNull
|
||||
?.content
|
||||
.title;
|
||||
return SheetScaffold(
|
||||
title: l10n.commentsTitle,
|
||||
heading: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 4, 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
key: commentFieldKey,
|
||||
controller: _text,
|
||||
minLines: 1,
|
||||
maxLines: 6,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(hintText: l10n.commentAdd),
|
||||
onChanged: (_) => setState(() {}),
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
|
||||
child: Row(
|
||||
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,
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
IconButton(
|
||||
key: commentSendKey,
|
||||
tooltip: l10n.commentSend,
|
||||
icon: const Icon(LucideIcons.arrowUp),
|
||||
onPressed: _text.text.trim().isEmpty
|
||||
? null
|
||||
: () => unawaited(_send(todo)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton.filled(
|
||||
key: commentSendKey,
|
||||
tooltip: l10n.commentSend,
|
||||
icon: const Icon(LucideIcons.arrowUp),
|
||||
onPressed: _text.text.trim().isEmpty
|
||||
? 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 {
|
||||
Future<void> _edit(BuildContext context, CommentContent content) async {
|
||||
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) {
|
||||
await todo.comments.update(
|
||||
comment.id,
|
||||
CommentContent(
|
||||
taskId: content.taskId,
|
||||
text: edited,
|
||||
writtenAt: content.writtenAt,
|
||||
editedAt: clock.now(),
|
||||
),
|
||||
content.edited(edited, 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
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
|
@ -143,63 +282,140 @@ class const _Comment({required final StoredComment comment})
|
|||
'${timeText(context, local.hour * 60 + local.minute)}';
|
||||
final todo = PersonScope.of<TodoPerson>(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: 16, bottom: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
content.editedAt == null ? when : l10n.commentEdited(when),
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: muted),
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Material(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 4, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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.
|
||||
class const _LinkedText({required final String text}) extends StatefulWidget {
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -465,12 +465,14 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
required Widget body,
|
||||
Widget? heading,
|
||||
Widget? action,
|
||||
Widget? footer,
|
||||
}) {
|
||||
if (_SheetScroll.maybeOf(context) case final scroll? when !widget.inPane) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return ScaffoldMessenger(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
bottomNavigationBar: footer,
|
||||
body: ListView(
|
||||
controller: scroll,
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 24),
|
||||
|
|
@ -515,6 +517,7 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
hasSnackBars: true,
|
||||
action: action,
|
||||
body: body,
|
||||
footer: footer,
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
|
|
@ -549,6 +552,7 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
child: body,
|
||||
),
|
||||
),
|
||||
?footer,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -689,6 +693,7 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
},
|
||||
),
|
||||
action: _menu(context, task, subTasks),
|
||||
footer: AddCommentBar(taskId: task.id),
|
||||
// Its parts in rounded boxes, as Todoist's (the developer's review,
|
||||
// 3 Nov 2026): the title, the description, the date, the reminders,
|
||||
// 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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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'})),
|
||||
|
|
|
|||
Loading…
Reference in a new issue