mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 19:07:02 +01:00
Some checks failed
📎 now picks any file (Android: Camera / Gallery / File; desktop: an
unfiltered dialog) and the desktop page accepts any dropped file. Photo and
screenshot formats up to 64 MB are still re-encoded and shown inline;
everything else is stored byte for byte on dufs beside the images and linked
as [name](url), shown as a row with its size that opens in the platform's
app (Android) or a new browser tab (desktop). BACKLOG.md rewrites file links
to ~/data/cloud/todo-images/ like image links.
There is no size cap, so no layer holds a file in memory: uploads and
downloads stream through DufsImageClient, writes land in a .tmp renamed when
whole (an interrupted download no longer leaves a truncated file that looks
done), the desktop page POSTs the browser's own Blob with fetch, and Android's
"File" uses file_picker, because file_selector's Android side reads the whole
file into memory on the UI thread. Verified with a 1 GB file end to end on
desktop (peak RSS +36 MB) and on an API 36 emulator.
The wrapper serves attachments from the origin that holds the GitHub token
and the notes, so only script-free types open inline (nosniff), the rest
download; writes carrying another site's Origin are refused (this also
closes cross-site overwrites of the backup files); and attachment routes run
concurrently so a long upload or a playing video cannot stall the page.
Also: cupertino_icons 2.0.0 (unused, but the freshness gate blocks the
pubspec otherwise), open_file 4.0.0 and file_picker 13.1.0 pinned.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
109 lines
3.5 KiB
Dart
109 lines
3.5 KiB
Dart
/// Turning pasted image bytes and picked or dropped files into links in the
|
|
/// open note.
|
|
///
|
|
/// Shared by the capture and detail screens: each owns one
|
|
/// [ImageAttachController], hands it to its [NoteEditor] (which binds the
|
|
/// insert-at-cursor), and routes pastes through [attachImages] and picks and
|
|
/// drops through [attachFiles].
|
|
library;
|
|
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:design_system/design_system.dart';
|
|
import 'package:flutter/widgets.dart';
|
|
|
|
import 'package:todo/attachments/image_store_api.dart';
|
|
import 'package:todo/attachments/image_store_scope.dart';
|
|
import 'package:todo/images/attachment_ref.dart';
|
|
|
|
/// Lets a screen insert lines into whichever editor is currently mounted.
|
|
class ImageAttachController {
|
|
void Function(List<String> lines)? _insert;
|
|
|
|
/// Called by the editor on mount with its insert-at-cursor function;
|
|
/// returns the call that undoes it (a no-op once another editor bound).
|
|
VoidCallback bind(void Function(List<String> lines) insert) {
|
|
_insert = insert;
|
|
return () {
|
|
if (identical(_insert, insert)) _insert = null;
|
|
};
|
|
}
|
|
|
|
/// Inserts [lines] at the editor's cursor; false when no editor is bound.
|
|
bool insert(List<String> lines) {
|
|
final insert = _insert;
|
|
if (insert == null || lines.isEmpty) return false;
|
|
insert(lines);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// Stores each of [images] for the note [noteId] names and inserts their
|
|
/// links, in order, through [target].
|
|
///
|
|
/// [noteId] is a callback because the capture screen's draft only gets an id
|
|
/// once there is something to save; asking for it is what reserves one.
|
|
/// Reserving writes nothing, so a rejected paste still never creates an
|
|
/// empty note. [rejected] counts inputs already known
|
|
/// not to be images (e.g. a pasted PDF), so one message covers both.
|
|
Future<void> attachImages(
|
|
BuildContext context, {
|
|
required String Function() noteId,
|
|
required ImageAttachController target,
|
|
required List<Uint8List> images,
|
|
int rejected = 0,
|
|
}) async {
|
|
final store = ImageStoreScope.read(context);
|
|
final refs = <AttachmentRef>[];
|
|
var notImages = rejected;
|
|
String? failure;
|
|
for (final bytes in images) {
|
|
try {
|
|
final ref = await store.attach(noteId(), bytes);
|
|
if (ref == null) {
|
|
notImages++;
|
|
} else {
|
|
refs.add(ref);
|
|
}
|
|
} on ImageStoreException catch (e) {
|
|
failure = e.message;
|
|
break;
|
|
}
|
|
}
|
|
target.insert([for (final ref in refs) ref.imageMarkdown]);
|
|
if (!context.mounted) return;
|
|
if (failure != null) {
|
|
showError(context, failure);
|
|
} else if (notImages > 0) {
|
|
showError(
|
|
context,
|
|
notImages == 1 ? 'Not an image' : '$notImages files were not images',
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Stores each of [files] (picked or dropped, any type and size) for the note
|
|
/// [noteId] names and inserts their links, in order, through [target]:
|
|
/// images inline, anything else as a file link. Stops at the first file that
|
|
/// cannot be stored, keeping the ones before it.
|
|
Future<void> attachFiles(
|
|
BuildContext context, {
|
|
required String Function() noteId,
|
|
required ImageAttachController target,
|
|
required List<XFile> files,
|
|
}) async {
|
|
final store = ImageStoreScope.read(context);
|
|
final attached = <Attachment>[];
|
|
String? failure;
|
|
for (final file in files) {
|
|
try {
|
|
attached.add(await store.attachFile(noteId(), file));
|
|
} on ImageStoreException catch (e) {
|
|
failure = e.message;
|
|
break;
|
|
}
|
|
}
|
|
target.insert([for (final attachment in attached) attachment.markdown]);
|
|
if (failure != null && context.mounted) showError(context, failure);
|
|
}
|