mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-11 13:47:45 +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>
180 lines
5.6 KiB
Dart
180 lines
5.6 KiB
Dart
/// One attached non-image file as it appears in a note: an icon for its
|
|
/// type, its name and size. Tapping opens it (see `file_opening.dart`).
|
|
library;
|
|
|
|
import 'package:design_system/design_system.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:todo/attachments/image_store_api.dart';
|
|
import 'package:todo/attachments/image_store_scope.dart';
|
|
import 'package:todo/images/attachment_ref.dart';
|
|
import 'package:todo/ui/images/file_opening.dart';
|
|
import 'package:todo/ui/images/image_embed.dart';
|
|
|
|
/// Opens an attached file; returns why it could not, or null.
|
|
typedef FileOpening = Future<String?> Function(ImageSource source, String name);
|
|
|
|
/// Renders the file [attachment] from the nearest [ImageStoreScope].
|
|
class FileEmbed extends StatefulWidget {
|
|
/// Creates an embed: a full-width row inline, or a square tile when
|
|
/// [compact] (the strip under the editor). [opening] is injectable.
|
|
const FileEmbed({
|
|
required this.attachment,
|
|
this.compact = false,
|
|
this.opening = openAttachedFile,
|
|
super.key,
|
|
});
|
|
|
|
/// The file to show.
|
|
final Attachment attachment;
|
|
|
|
/// Square tile instead of a row.
|
|
final bool compact;
|
|
|
|
/// What a tap does.
|
|
final FileOpening opening;
|
|
|
|
@override
|
|
State<FileEmbed> createState() => _FileEmbedState();
|
|
}
|
|
|
|
class _FileEmbedState extends State<FileEmbed> {
|
|
late Future<int?> _size;
|
|
|
|
// Re-asked whenever the store notifies (a download may have landed), not
|
|
// on every rebuild: on desktop each ask is a request to the wrapper.
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
_size = ImageStoreScope.of(context).sizeOf(widget.attachment.ref);
|
|
}
|
|
|
|
Future<void> _open(ImageSource source) async {
|
|
final failure = await widget.opening(source, widget.attachment.name);
|
|
if (failure != null && mounted) showError(context, failure);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final store = ImageStoreScope.of(context);
|
|
final attachment = widget.attachment;
|
|
final theme = Theme.of(context);
|
|
return FutureBuilder<int?>(
|
|
future: _size,
|
|
builder: (context, snapshot) {
|
|
final size = snapshot.data;
|
|
final detail = size == null ? kImageNotDownloaded : formatBytes(size);
|
|
final icon = Icon(fileIconFor(attachment.ref.extension));
|
|
final name = Text(
|
|
attachment.name,
|
|
maxLines: widget.compact ? 2 : 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
textAlign: widget.compact ? TextAlign.center : TextAlign.start,
|
|
style: widget.compact ? theme.textTheme.bodySmall : null,
|
|
);
|
|
final pending = store.isPending(attachment.ref)
|
|
? const Tooltip(
|
|
message: kImageWaitingToUpload,
|
|
child: Icon(Icons.cloud_upload_outlined, size: 18),
|
|
)
|
|
: null;
|
|
final content = widget.compact
|
|
? Stack(
|
|
children: [
|
|
Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
icon,
|
|
const SizedBox(height: AppSpacing.xs),
|
|
name,
|
|
],
|
|
),
|
|
),
|
|
if (pending != null)
|
|
Positioned(right: 0, top: 0, child: pending),
|
|
],
|
|
)
|
|
: Row(
|
|
children: [
|
|
icon,
|
|
const SizedBox(width: AppSpacing.sm),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
name,
|
|
Text(detail, style: theme.textTheme.bodySmall),
|
|
],
|
|
),
|
|
),
|
|
?pending,
|
|
],
|
|
);
|
|
return Semantics(
|
|
label: 'Attached file ${attachment.name}, $detail',
|
|
button: size != null,
|
|
child: Material(
|
|
color: theme.colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
|
onTap: size == null
|
|
? null
|
|
: () => _open(store.sourceFor(attachment.ref)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(AppSpacing.sm),
|
|
child: content,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// [bytes] as a short human size: `512 B`, `1.2 KB`, `34 MB`, `2.1 GB`.
|
|
String formatBytes(int bytes) {
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
var value = bytes.toDouble();
|
|
var unit = 0;
|
|
while (value >= 1024 && unit < units.length - 1) {
|
|
value /= 1024;
|
|
unit++;
|
|
}
|
|
final digits = unit == 0 || value >= 10 ? 0 : 1;
|
|
return '${value.toStringAsFixed(digits)} ${units[unit]}';
|
|
}
|
|
|
|
/// The icon for a file with [extension].
|
|
IconData fileIconFor(String extension) => switch (extension) {
|
|
'pdf' => Icons.picture_as_pdf_outlined,
|
|
'mp4' ||
|
|
'm4v' ||
|
|
'webm' ||
|
|
'mov' ||
|
|
'mkv' ||
|
|
'avi' ||
|
|
'ogv' => Icons.movie_outlined,
|
|
'mp3' ||
|
|
'm4a' ||
|
|
'ogg' ||
|
|
'oga' ||
|
|
'opus' ||
|
|
'wav' ||
|
|
'flac' ||
|
|
'aac' => Icons.audiotrack_outlined,
|
|
'txt' || 'md' || 'log' || 'csv' || 'json' => Icons.description_outlined,
|
|
'zip' || 'tar' || 'gz' || '7z' || 'rar' || 'xz' => Icons.folder_zip_outlined,
|
|
'png' ||
|
|
'gif' ||
|
|
'webp' ||
|
|
'bmp' ||
|
|
'svg' ||
|
|
'psd' ||
|
|
'heic' ||
|
|
'tif' => Icons.image_outlined,
|
|
_ => Icons.insert_drive_file_outlined,
|
|
};
|