mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 20:27:01 +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>
114 lines
3.9 KiB
Dart
114 lines
3.9 KiB
Dart
/// The 📎 button: pick photos from the camera or gallery, or any file
|
|
/// (Android), or any file from disk (desktop), and hand them on.
|
|
library;
|
|
|
|
import 'package:file_picker/file_picker.dart' show FilePicker;
|
|
import 'package:file_selector/file_selector.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
|
|
export 'package:file_selector/file_selector.dart' show XFile;
|
|
|
|
/// Where the phone takes an attachment from.
|
|
enum AttachmentPickSource {
|
|
/// A new photo.
|
|
camera,
|
|
|
|
/// Existing photos, several at once.
|
|
gallery,
|
|
|
|
/// Any files, several at once, from the system file picker.
|
|
file,
|
|
}
|
|
|
|
/// Picks files and returns them; empty when the user cancels.
|
|
///
|
|
/// Injectable so widget tests never reach a platform picker.
|
|
typedef AttachmentPicking = Future<List<XFile>> Function(BuildContext context);
|
|
|
|
/// The platform picker: a Camera/Gallery/File sheet on Android, an
|
|
/// unfiltered multi-select file dialog on the desktop (web) build.
|
|
///
|
|
/// [fromPhone] is the platform call behind the sheet, injectable for tests.
|
|
Future<List<XFile>> pickAttachments(
|
|
BuildContext context, {
|
|
Future<List<XFile>> Function(AttachmentPickSource) fromPhone = _pickFromPhone,
|
|
}) async {
|
|
// The desktop build has no sheet: straight to the file dialog.
|
|
if (kIsWeb) return await openFiles(); // coverage:ignore-line
|
|
final source = await showModalBottomSheet<AttachmentPickSource>(
|
|
context: context,
|
|
builder: (context) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ListTile(
|
|
leading: const Icon(Icons.photo_camera_outlined),
|
|
title: const Text('Camera'),
|
|
onTap: () => Navigator.pop(context, AttachmentPickSource.camera),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.photo_library_outlined),
|
|
title: const Text('Gallery'),
|
|
onTap: () => Navigator.pop(context, AttachmentPickSource.gallery),
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.insert_drive_file_outlined),
|
|
title: const Text('File'),
|
|
onTap: () => Navigator.pop(context, AttachmentPickSource.file),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
if (source == null) return const [];
|
|
return await fromPhone(source);
|
|
}
|
|
|
|
// coverage:ignore-start
|
|
// Thin calls into platform pickers with no test binding; the sheet above and
|
|
// everything downstream of the returned files are covered.
|
|
//
|
|
// "File" uses file_picker, not file_selector: file_selector's Android side
|
|
// reads the whole file into memory on the UI thread (an `int`-sized byte
|
|
// array, then across the platform channel), so a large pick crashes the app.
|
|
// file_picker streams it into the cache directory on a background thread.
|
|
Future<List<XFile>> _pickFromPhone(AttachmentPickSource source) async {
|
|
final picker = ImagePicker();
|
|
return switch (source) {
|
|
AttachmentPickSource.camera => [
|
|
?await picker.pickImage(source: ImageSource.camera),
|
|
],
|
|
AttachmentPickSource.gallery => await picker.pickMultiImage(),
|
|
AttachmentPickSource.file => [
|
|
for (final file in await FilePicker.pickFiles()) file.xFile,
|
|
],
|
|
};
|
|
}
|
|
// coverage:ignore-end
|
|
|
|
/// App-bar action that picks attachments and passes them to [onFiles].
|
|
class AttachButton extends StatelessWidget {
|
|
/// Creates the button; [picking] defaults to [pickAttachments].
|
|
const AttachButton({required this.onFiles, this.picking, super.key});
|
|
|
|
/// Receives the picked files (never called for a cancelled pick).
|
|
final ValueChanged<List<XFile>> onFiles;
|
|
|
|
/// The picker, injectable for tests.
|
|
final AttachmentPicking? picking;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return IconButton(
|
|
tooltip: 'Attach',
|
|
icon: const Icon(Icons.attach_file),
|
|
onPressed: () async {
|
|
final files = await (picking ?? pickAttachments)(context);
|
|
if (files.isNotEmpty) onFiles(files);
|
|
},
|
|
);
|
|
}
|
|
}
|