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:
parent
6385d84cff
commit
016b74ff74
15 changed files with 20953 additions and 6 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue