todo-app/lib/ui/image_attach.dart
Krzysztof kuhy Rudnicki 5ea7ba7b73 Text-free list, editor, sync and image cues
Glyph + spoken label replaces every app-written string the guard
found: empty states, tile meta, sheet dates/fields, date presets,
template/section headings, status footer, snackbars, GitHub and
cloud-image results, upload/download badges, Material tooltips.
Guard tests (text_free_*_test) walk the painted tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 01:17:03 +01:00

115 lines
3.7 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: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/cue_snack.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) {
showCue(context, Icons.cloud_off, failure, error: true);
} else if (notImages > 0) {
// A broken-image glyph plus how many were refused.
showCue(
context,
Icons.broken_image_outlined,
notImages == 1 ? 'Not an image' : '$notImages files were not images',
error: true,
count: notImages,
);
}
}
/// 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) {
showCue(context, Icons.cloud_off, failure, error: true);
}
}