feat(todo,app): files attached to tasks and comments

Halkyon's ADR 0043, D1, D3, D5 and D8: any file up to 100 MB, attached to
a task's description or to a comment, sealed as everything of Todo's.

Server: the attachment table, synced and sealed as the others, with the
sealed file's hash readable for the store; todoFiles hands halkyon_base
its rows, readable by their space's members, and takes sealed files
alone; the files no attachment names go daily. Its tests hold downloads
to the row's space, refuse a readable file and prune what no live row
names. Migration attachments: CREATE TABLE "attachment" with its indexes
and the foreign key to its space, cascading as every synced table's.

App: AttachmentRepository seals each file for its row, keeps it sealed
and opened in the person's folder, uploads it after each sync and fetches
it as it shows, both held back by Without files on mobile data but for a
file tapped. Under the description and a comment's text, a row of them:
pictures as thumbnails opening full-size, other files as chips opening in
the system's app, else saved; the paperclip beside the description and in the comment's
composer adds them, the composer holding them until it is sent. A task deleted takes its files
and its comments' along, a comment its own.

halkyon_app opens a file in the system's app (FileOpener): on Android
through its plugin and a FileProvider of its own class, sharing the
cache's halkyon_opened/ folder alone; on the desktop as a file: URI.

Whats-New: Attach files to a task's description or a comment with the paperclip: pictures show as thumbnails, other files open in their app.
Whats-New-De: Hänge mit der Büroklammer Dateien an die Beschreibung einer Aufgabe oder an einen Kommentar: Bilder erscheinen als Vorschau, andere Dateien öffnen sich in ihrer App.
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-11 01:17:22 +01:00
commit 3ca4b4308d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -14,6 +14,8 @@ import 'package:halkyon_todo_flutter/src/app/todo_dependencies.dart';
import 'package:halkyon_todo_flutter/src/core/note/note.dart';
import 'package:halkyon_todo_flutter/src/core/note/note_fields.dart';
import 'package:halkyon_todo_flutter/src/core/privacy/todo_keys.dart';
import 'package:halkyon_todo_flutter/src/feature/attachment/data/attachment_repository.dart';
import 'package:halkyon_todo_flutter/src/feature/attachment/widget/attachments.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';
@ -68,6 +70,7 @@ import 'package:material_ui/material_ui.dart';
import '../support/fake_calendar_feeds.dart';
import '../support/fake_weather.dart';
import '../support/memory_attachment_gateway.dart';
import '../support/memory_content_key_gateway.dart';
import '../support/memory_location_gateway.dart';
import '../support/memory_reminder_gateway.dart';
@ -90,6 +93,10 @@ void main() {
late MemorySealedRowGateway labelRows;
late MemorySealedRowGateway filterRows;
late MemorySealedRowGateway commentRows;
late MemoryAttachmentGateway attachmentRows;
late FakeFileServerGateway fileServer;
late FakeFilePicker picker;
late FakeFileOpener opener;
late MemorySealedRowGateway settingsRows;
late MemoryReminderGateway reminders;
late FakeCalendarFeeds calendarFeeds;
@ -133,6 +140,10 @@ void main() {
labelRows = MemorySealedRowGateway();
filterRows = MemorySealedRowGateway();
commentRows = MemorySealedRowGateway();
attachmentRows = MemoryAttachmentGateway();
fileServer = FakeFileServerGateway();
picker = FakeFilePicker();
opener = FakeFileOpener();
settingsRows = MemorySealedRowGateway();
reminders = MemoryReminderGateway();
calendarFeeds = FakeCalendarFeeds();
@ -216,7 +227,8 @@ void main() {
FakeInstanceOperatorGateway(),
),
fileSaver: MemoryFileSaver(),
files: FakeFilePicker(),
files: picker,
opener: opener,
profiles: PublicProfileRepository(
FakePublicProfileGateway(),
drawAvatar: (picture) async => picture,
@ -232,6 +244,19 @@ void main() {
final settings = SettingsRepository(settingsRows, keys);
// As the app does when it opens the person's data.
await projects.ensureInbox();
// As the app ties them: a task's comments and files go with it,
// a comment's files with it.
final attachments = AttachmentRepository(
attachmentRows,
keys,
fileServer,
MemoryFileCache(),
);
final comments = CommentRepository(
commentRows,
keys,
onDeleted: attachments.deleteOnComments,
);
return TodoPerson(
person,
sync: sync,
@ -240,12 +265,17 @@ void main() {
tasks: TaskRepository(
taskRows,
keys,
onDeleted: (ids) async {
await comments.deleteOn(ids);
await attachments.deleteOnTasks(ids);
},
automaticReminder: () async =>
(await settings.read()).reminders.automatic,
),
labels: LabelRepository(labelRows, keys),
filters: FilterRepository(filterRows, keys),
comments: CommentRepository(commentRows, keys),
comments: comments,
attachments: attachments,
settings: settings,
reminders: reminders,
locationReminders: locationGateway,
@ -793,6 +823,98 @@ void main() {
expect(find.text('❤️ 1'), findsNothing);
});
group('attachments (Halkyon ADR 0043, D8)', () {
final receipt = (
name: 'receipt.pdf',
bytes: Uint8List.fromList(List.filled(3000, 7)),
);
testWidgets('the paperclip under the description attaches a file, '
'shown as its chip, which opens in the system app; removed after '
'asking', (tester) async {
await start(tester, usedBefore: true);
await openTask(tester);
picker.nextMany = [receipt];
await tester.tap(find.byKey(attachButtonKey));
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pumpAndSettle();
expect(find.text('receipt.pdf'), findsOneWidget);
expect(find.text('3 KB'), findsOneWidget);
expect(attachmentRows.byId, hasLength(1));
await tester.tap(find.text('receipt.pdf'));
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pumpAndSettle();
expect(opener.opened.single.name, 'receipt.pdf');
expect(opener.opened.single.mimeType, 'application/pdf');
expect(opener.opened.single.bytes, receipt.bytes);
final id = attachmentRows.byId.keys.single;
await tester.tap(find.byKey(attachmentMenuKey(id)));
await tester.pumpAndSettle();
await tester.tap(find.text('Remove'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remove').last);
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pumpAndSettle();
expect(attachmentRows.byId, isEmpty);
expect(find.text('receipt.pdf'), findsNothing);
});
testWidgets('a file no app opens is saved where the person '
'chooses', (tester) async {
await start(tester, usedBefore: true);
await openTask(tester);
picker.nextMany = [receipt];
opener.takes = false;
await tester.tap(find.byKey(attachButtonKey));
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pumpAndSettle();
await tester.tap(find.text('receipt.pdf'));
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pumpAndSettle();
final saver =
TodoDependencies.of(
tester.element(find.text('receipt.pdf')),
).fileSaver
as MemoryFileSaver;
expect(saver.saved.single.name, 'receipt.pdf');
});
testWidgets('a comment goes with files alone, which show on it, and '
'its files go with it', (tester) async {
await start(tester, usedBefore: true);
await openTask(tester);
await openComments(tester);
picker.nextMany = [receipt];
await tester.tap(find.byKey(commentAttachKey));
await tester.pumpAndSettle();
expect(find.widgetWithText(InputChip, 'receipt.pdf'), findsOneWidget);
await tester.tap(find.byKey(commentSendKey));
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pumpAndSettle();
expect(find.widgetWithText(InputChip, 'receipt.pdf'), findsNothing);
expect(find.text('receipt.pdf'), findsOneWidget);
expect(attachmentRows.byId, hasLength(1));
await tester.tap(find.byTooltip('Comment menu'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete comment'));
await tester.pumpAndSettle();
await tester.tap(find.text('Delete comment').last);
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pumpAndSettle();
expect(attachmentRows.byId, isEmpty);
});
});
});
group('filters and labels (D35)', () {