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
15 changed files with 20953 additions and 6 deletions

View file

@ -55,6 +55,15 @@
"filterMatches": "{count, plural, =1{1 Aufgabe passt} other{{count} Aufgaben passen}}",
"filterNotRead": "Nicht verstanden bei „{near}“",
"filterNotReadEnd": "Die Abfrage endet zu früh",
"commentsTitle": "Kommentare",
"commentAdd": "Kommentar schreiben",
"commentSend": "Kommentar senden",
"commentMenu": "Kommentarmenü",
"commentEdit": "Kommentar bearbeiten",
"commentCopy": "Text kopieren",
"commentDelete": "Kommentar löschen",
"commentDeleteTitle": "Kommentar löschen?",
"commentEdited": "{when}, bearbeitet",
"labelsTitle": "Labels",
"labelsNone": "Noch keine Labels.",
"labelsNoneSet": "Keine Labels",

View file

@ -115,6 +115,23 @@
"@filterNotReadEnd": {
"description": "The filter editor when its query stops before it is complete."
},
"commentsTitle": "Comments",
"commentAdd": "Add a comment",
"commentSend": "Send comment",
"commentMenu": "Comment menu",
"commentEdit": "Edit comment",
"commentCopy": "Copy text",
"commentDelete": "Delete comment",
"commentDeleteTitle": "Delete the comment?",
"commentEdited": "{when}, edited",
"@commentEdited": {
"description": "When a comment was written, once it was changed.",
"placeholders": {
"when": {
"type": "String"
}
}
},
"labelsTitle": "Labels",
"labelsNone": "No labels yet.",
"labelsNoneSet": "No labels",

View file

@ -430,6 +430,60 @@ abstract class AppLocalizations {
/// **'This query ends too soon'**
String get filterNotReadEnd;
/// No description provided for @commentsTitle.
///
/// In en, this message translates to:
/// **'Comments'**
String get commentsTitle;
/// No description provided for @commentAdd.
///
/// In en, this message translates to:
/// **'Add a comment'**
String get commentAdd;
/// No description provided for @commentSend.
///
/// In en, this message translates to:
/// **'Send comment'**
String get commentSend;
/// No description provided for @commentMenu.
///
/// In en, this message translates to:
/// **'Comment menu'**
String get commentMenu;
/// No description provided for @commentEdit.
///
/// In en, this message translates to:
/// **'Edit comment'**
String get commentEdit;
/// No description provided for @commentCopy.
///
/// In en, this message translates to:
/// **'Copy text'**
String get commentCopy;
/// No description provided for @commentDelete.
///
/// In en, this message translates to:
/// **'Delete comment'**
String get commentDelete;
/// No description provided for @commentDeleteTitle.
///
/// In en, this message translates to:
/// **'Delete the comment?'**
String get commentDeleteTitle;
/// When a comment was written, once it was changed.
///
/// In en, this message translates to:
/// **'{when}, edited'**
String commentEdited(String when);
/// No description provided for @labelsTitle.
///
/// In en, this message translates to:

View file

@ -203,6 +203,35 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get filterNotReadEnd => 'Die Abfrage endet zu früh';
@override
String get commentsTitle => 'Kommentare';
@override
String get commentAdd => 'Kommentar schreiben';
@override
String get commentSend => 'Kommentar senden';
@override
String get commentMenu => 'Kommentarmenü';
@override
String get commentEdit => 'Kommentar bearbeiten';
@override
String get commentCopy => 'Text kopieren';
@override
String get commentDelete => 'Kommentar löschen';
@override
String get commentDeleteTitle => 'Kommentar löschen?';
@override
String commentEdited(String when) {
return '$when, bearbeitet';
}
@override
String get labelsTitle => 'Labels';

View file

@ -203,6 +203,35 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get filterNotReadEnd => 'This query ends too soon';
@override
String get commentsTitle => 'Comments';
@override
String get commentAdd => 'Add a comment';
@override
String get commentSend => 'Send comment';
@override
String get commentMenu => 'Comment menu';
@override
String get commentEdit => 'Edit comment';
@override
String get commentCopy => 'Copy text';
@override
String get commentDelete => 'Delete comment';
@override
String get commentDeleteTitle => 'Delete the comment?';
@override
String commentEdited(String when) {
return '$when, edited';
}
@override
String get labelsTitle => 'Labels';

View file

@ -11,6 +11,8 @@ import 'package:halkyon_todo_client/halkyon_todo_client.dart'
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/core/privacy/sync_content_key_gateway.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/sync_comment_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/data/filter_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/data/sync_filter_gateway.dart';
import 'package:halkyon_todo_flutter/src/feature/label/data/label_repository.dart';
@ -67,6 +69,9 @@ class TodoPerson(
/// Their filters, sealed and synced (D35).
required final FilterRepository filters,
/// Their comments on tasks, sealed and synced (D36).
required final CommentRepository comments,
/// Their settings of Todo's (Upcoming's Display), sealed and synced.
required final SettingsRepository settings,
@ -405,9 +410,11 @@ Future<TodoPerson> _openPersonData(
final projects = ProjectRepository(SyncProjectGateway(db, sync), keys);
final sections = SectionRepository(SyncSectionGateway(db, sync), keys);
final settings = SettingsRepository(SyncSettingsGateway(db, sync), keys);
final comments = CommentRepository(SyncCommentGateway(db, sync), keys);
final tasks = TaskRepository(
SyncTaskGateway(db, sync),
keys,
onDeleted: comments.deleteOn,
automaticReminder: () async => (await settings.read()).reminders.automatic,
);
final labels = LabelRepository(SyncLabelGateway(db, sync), keys);
@ -429,6 +436,7 @@ Future<TodoPerson> _openPersonData(
tasks: tasks,
labels: labels,
filters: filters,
comments: comments,
settings: settings,
reminders: reminders,
notifications: notifications,
@ -442,6 +450,7 @@ Future<TodoPerson> _openPersonData(
await tasks.dispose();
await labels.dispose();
await filters.dispose();
await comments.dispose();
await settings.dispose();
await sync.dispose();
await db.close();

View file

@ -0,0 +1,32 @@
import 'dart:typed_data';
import 'package:halkyon_todo_flutter/src/core/sealed/json_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/comment/model/comment.dart';
/// The schema of a comment's payload this app writes and reads.
const commentSchemaVersion = 1;
/// [content] as its row's payload, before sealing.
ByteData encodeComment(CommentContent content) =>
encodeJson(commentSchemaVersion, {
'taskId': content.taskId,
'text': content.text,
'writtenAt': content.writtenAt.toUtc().toIso8601String(),
'editedAt': ?content.editedAt?.toUtc().toIso8601String(),
});
/// The comment [payload] holds, opened; null for one it can't read.
CommentContent? decodeComment(ByteData payload) => decodeJson(
payload,
version: commentSchemaVersion,
name: 'Comment',
read: (json) => CommentContent(
taskId: json['taskId']! as String,
text: json['text']! as String,
writtenAt: DateTime.parse(json['writtenAt']! as String),
editedAt: switch (json['editedAt']) {
final String at => DateTime.parse(at),
_ => null,
},
),
);

View file

@ -0,0 +1,48 @@
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_rows.dart';
import 'package:halkyon_todo_flutter/src/feature/comment/data/comment_payload.dart';
import 'package:halkyon_todo_flutter/src/feature/comment/model/comment.dart';
/// The person's comments on their tasks (ADR 0033, D36), each sealed with
/// their key for its row (D5, [SealedRows]); which task one belongs to is
/// sealed with it.
class CommentRepository(final SealedRowGateway _gateway, final TodoKeys _keys) {
late final _rows = SealedRows<CommentContent>(
_gateway,
_keys,
table: 'task_comment',
encode: encodeComment,
decode: decodeComment,
);
/// Fires when the comments changed, here or on another device.
Stream<void> get changes => _rows.changes;
/// Every comment, oldest first.
Future<List<StoredComment>> all() async => [
for (final (:id, :content) in await _rows.all()) StoredComment(id, content),
];
/// Keeps a new comment and returns its ID; throws [KeysMissing] where
/// this device can't seal.
Future<String> add(CommentContent content) => _rows.insert(content);
Future<void> update(String id, CommentContent content) =>
_rows.update(id, content);
/// Deletes the comments [ids].
Future<void> delete(Iterable<String> ids) => _rows.delete(ids);
/// Deletes the comments on the tasks [taskIds], which are gone.
Future<void> deleteOn(Set<String> taskIds) async {
final comments = await all();
await _rows.delete([
for (final comment in comments)
if (taskIds.contains(comment.content?.taskId)) comment.id,
]);
}
/// Closes what it holds open.
Future<void> dispose() => _rows.dispose();
}

View file

@ -0,0 +1,99 @@
import 'dart:typed_data';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_client/halkyon_todo_client.dart' as protocol;
import 'package:halkyon_todo_flutter/src/core/sealed/sealed_row_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// The person's comments on their local database ([SealedRowGateway]), which the
/// sync engine keeps in step with their other devices.
class SyncCommentGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
) implements SealedRowGateway {
@override
Future<List<SealedRow>> rows() async => [
for (final row in await protocol.TaskComment.db.find(
_db,
orderBy: (t) => t.id,
))
(
id: row.id!.uuid,
envelope: (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
),
];
@override
Future<void> insert(SealedRow row) async {
await protocol.TaskComment.db.insertRow(
_db,
protocol.TaskComment(
id: protocol.UuidValue.withValidation(row.id),
keyId: row.envelope.keyId,
nonce: row.envelope.nonce,
payload: row.envelope.payload,
),
);
_sync.requestSync();
}
@override
Future<void> update(String id, Envelope envelope) async {
if (await protocol.TaskComment.db.findById(
_db,
protocol.UuidValue.withValidation(id),
)
case final row?) {
// Only the named columns change (ADR 0001, rule 1).
await protocol.TaskComment.db.updateRow(
_db,
row.copyWith(
keyId: envelope.keyId,
nonce: envelope.nonce,
payload: envelope.payload,
),
columns: (t) => [t.keyId, t.nonce, t.payload],
);
_sync.requestSync();
}
}
@override
Future<void> delete(Iterable<String> ids) async {
final uuids = <protocol.UuidValue>{
for (final id in ids) protocol.UuidValue.withValidation(id),
};
if (uuids.isEmpty) {
return;
}
await _db.db.transaction((transaction) async {
// Blanked first (ADR 0002, rule 2): the instance keeps deleted rows as
// they last were, until the space goes.
for (final row in await protocol.TaskComment.db.find(
_db,
where: (t) => t.id.inSet(uuids),
transaction: transaction,
)) {
await protocol.TaskComment.db.updateRow(
_db,
protocol.TaskComment(
id: row.id,
spaceId: row.spaceId,
payload: ByteData(0),
),
columns: (t) => [t.keyId, t.nonce, t.payload],
transaction: transaction,
);
}
await protocol.TaskComment.db.deleteWhere(
_db,
where: (t) => t.id.inSet(uuids),
transaction: transaction,
);
});
_sync.requestSync();
}
@override
Stream<void> get remoteChanges => _sync.merges;
}

View file

@ -0,0 +1,50 @@
import 'package:equatable/equatable.dart';
/// A comment on the task [taskId] (ADR 0033, D36): plain [text], with links
/// shown as such, [writtenAt], and [editedAt] once it was changed. Markdown
/// can come later without touching what is kept.
final class const CommentContent({
required final String taskId,
required final String text,
required final DateTime writtenAt,
final DateTime? editedAt,
}) with Equatable {
@override
List<Object?> get props => [taskId, text, writtenAt, editedAt];
}
/// A comment as kept: its [id], and [content] null where it doesn't open.
final class const StoredComment(final String id, final CommentContent? content)
with Equatable {
@override
List<Object?> get props => [id, content];
}
/// A piece of a comment's text: the link it stands for, or plain text.
typedef TextPiece = ({String text, Uri? url});
final _link = RegExp(r'https?://[^\s<>"]+', caseSensitive: false);
/// [text] cut into plain pieces and links: http and https alone, so that a
/// comment can never start anything else on the device, and without the
/// punctuation that ends a sentence after a link.
List<TextPiece> linkPieces(String text) {
final pieces = <TextPiece>[];
var at = 0;
for (final match in _link.allMatches(text)) {
final found = match[0]!.replaceFirst(RegExp(r'[.,;:!?)\]]+$'), '');
final url = Uri.tryParse(found);
if (url == null || url.host.isEmpty) {
continue;
}
if (match.start > at) {
pieces.add((text: text.substring(at, match.start), url: null));
}
pieces.add((text: found, url: url));
at = match.start + found.length;
}
if (at < text.length) {
pieces.add((text: text.substring(at), url: null));
}
return pieces;
}

View file

@ -0,0 +1,290 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart';
import 'package:halkyon_todo_flutter/src/feature/comment/model/comment.dart';
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
import 'package:url_launcher/url_launcher.dart';
/// The key of the field writing a comment.
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 {
@override
State<TaskComments> createState() => _TaskCommentsState();
}
class _TaskCommentsState() extends State<TaskComments> {
final _text = TextEditingController();
@override
void dispose() {
_text.dispose();
super.dispose();
}
Future<void> _send(TodoPerson todo) async {
final text = _text.text.trim();
if (text.isEmpty) {
return;
}
_text.clear();
setState(() {});
await todo.comments.add(
CommentContent(
taskId: widget.taskId,
text: text,
writtenAt: clock.now(),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
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,
),
),
),
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(() {}),
),
),
IconButton(
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})
extends StatelessWidget {
Future<void> _edit(BuildContext context, CommentContent content) async {
final todo = PersonScope.of<TodoPerson>(context);
final edited = await showDialog<String>(
context: context,
builder: (context) => _EditComment(text: content.text),
);
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(),
),
);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final muted = theme.colorScheme.onSurfaceVariant;
final content = comment.content!;
final local = content.writtenAt.toLocal();
final when =
'${dayText(context, Day.of(local), Day.of(clock.now()))}, '
'${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),
),
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,
),
),
],
),
],
),
);
}
}
/// [text] with its links underlined, a tap opening them in the browser.
class const _LinkedText({required final String text}) extends StatefulWidget {
@override
State<_LinkedText> createState() => _LinkedTextState();
}
class _LinkedTextState() extends State<_LinkedText> {
final _taps = <TapGestureRecognizer>[];
void _release() {
for (final tap in _taps) {
tap.dispose();
}
_taps.clear();
}
@override
void dispose() {
_release();
super.dispose();
}
@override
Widget build(BuildContext context) {
_release();
final link = TextStyle(
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
);
return SelectableText.rich(
TextSpan(
children: [
for (final piece in linkPieces(widget.text))
switch (piece.url) {
final url? => TextSpan(
text: piece.text,
style: link,
recognizer: TapGestureRecognizer()
..onTap = () => unawaited(
launchUrl(url, mode: LaunchMode.externalApplication),
),
),
null => TextSpan(text: piece.text),
},
],
),
);
}
}
class const _EditComment({required final String text}) extends StatefulWidget {
@override
State<_EditComment> createState() => _EditCommentState();
}
class _EditCommentState() extends State<_EditComment> {
late final _text = TextEditingController(text: widget.text);
@override
void dispose() {
_text.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return AlertDialog(
title: Text(l10n.commentEdit),
content: TextField(
controller: _text,
autofocus: true,
minLines: 1,
maxLines: 8,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(_text.text.trim()),
child: Text(l10n.save),
),
],
);
}
}

View file

@ -5,6 +5,7 @@ import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_core/halkyon_core.dart';
import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/feature/comment/model/comment.dart';
import 'package:halkyon_todo_flutter/src/feature/filter/model/saved_filter.dart';
import 'package:halkyon_todo_flutter/src/feature/label/model/label.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
@ -26,6 +27,9 @@ final class const TodoState({
/// Their filters that open, in their order (D35).
final List<StoredFilter> filters = const [],
/// Their comments that open, oldest first (D36).
final List<StoredComment> comments = const [],
/// Their settings of Todo's, as at first until read.
final TodoSettings settings = const TodoSettings(),
@ -40,6 +44,12 @@ final class const TodoState({
.map((project) => project.id)
.firstOrNull;
/// The comments on the task [taskId], oldest first.
List<StoredComment> commentsOn(String taskId) => [
for (final comment in comments)
if (comment.content!.taskId == taskId) comment,
];
/// The project [id], opened; null for one gone or locked.
StoredProject? projectOf(String id) => projects
.where((project) => project.id == id && project.content != null)
@ -63,6 +73,7 @@ final class const TodoState({
tasks,
labels,
filters,
comments,
settings,
locked,
];
@ -88,6 +99,7 @@ class TodoCubit(final TodoPerson _todo) extends Cubit<TodoState> {
_todo.tasks.changes,
_todo.labels.changes,
_todo.filters.changes,
_todo.comments.changes,
_todo.settings.changes,
]) {
_subscriptions.add(changes.listen((_) => unawaited(_reads.run())));
@ -101,6 +113,7 @@ class TodoCubit(final TodoPerson _todo) extends Cubit<TodoState> {
final tasks = await _todo.tasks.all();
final labels = await _todo.labels.all();
final filters = await _todo.filters.all();
final comments = await _todo.comments.all();
final settings = await _todo.settings.read();
if (isClosed) {
return;
@ -122,6 +135,13 @@ class TodoCubit(final TodoPerson _todo) extends Cubit<TodoState> {
for (final filter in filters)
if (filter.content != null) filter,
]..sort((a, b) => a.content!.position.compareTo(b.content!.position)),
comments:
[
for (final comment in comments)
if (comment.content != null) comment,
]..sort(
(a, b) => a.content!.writtenAt.compareTo(b.content!.writtenAt),
),
settings: settings,
locked: [
...projects.map((row) => row.content),
@ -129,6 +149,7 @@ class TodoCubit(final TodoPerson _todo) extends Cubit<TodoState> {
...tasks.map((row) => row.content),
...labels.map((row) => row.content),
...filters.map((row) => row.content),
...comments.map((row) => row.content),
].where((content) => content == null).length,
),
);

View file

@ -20,6 +20,9 @@ class TaskRepository(
/// The person's automatic reminder, read as it is needed; none: never.
final Future<int?> Function()? automaticReminder,
/// Told the tasks deleted, whose comments go with them (D36).
final Future<void> Function(Set<String> taskIds)? onDeleted,
}) {
late final _rows = SealedRows<TaskContent>(
_gateway,
@ -239,14 +242,14 @@ class TaskRepository(
/// Deletes the task [id] with its sub-tasks, theirs too, together.
Future<void> delete(String id) async {
final tasks = await all();
await _rows.delete(_withDescendants({id}, tasks));
await _gone(_withDescendants({id}, tasks));
}
/// Deletes every task in the project [projectId] (or, given, only those
/// in its section [sectionId]), with their sub-tasks, together.
Future<void> deleteIn(String projectId, {String? sectionId}) async {
final tasks = await all();
await _rows.delete(
await _gone(
_withDescendants({
for (final task in tasks)
if (task.content case final content?
@ -257,6 +260,11 @@ class TaskRepository(
);
}
Future<void> _gone(Set<String> ids) async {
await _rows.delete(ids);
await onDeleted?.call(ids);
}
/// Closes what it holds open.
Future<void> dispose() => _rows.dispose();

View file

@ -9,6 +9,7 @@ import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/app/todo_paths.dart';
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart';
import 'package:halkyon_todo_flutter/src/feature/comment/widget/task_comments.dart';
import 'package:halkyon_todo_flutter/src/feature/location/model/task_location.dart';
import 'package:halkyon_todo_flutter/src/feature/location/widget/location_picker.dart';
import 'package:halkyon_todo_flutter/src/feature/project/model/stored_project.dart';
@ -906,6 +907,7 @@ class _TaskSheetState() extends State<_TaskSheet> {
],
),
),
_Box(child: TaskComments(taskId: task.id)),
],
),
);

View file

@ -108,6 +108,8 @@ class const TaskTile({
'${subTasks.length}',
muted,
),
if (state.commentsOn(task.id).length case final count when count > 0)
meta(LucideIcons.messageSquare, '$count', muted),
for (final id in content.labelIds)
if (labels[id] case final label?)
meta(