Attach images to notes, stored on dufs and kept offline

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-09-25 14:11:15 +02:00
parent 9edcc76569
commit 0798f7b1d0
72 changed files with 4410 additions and 134 deletions

View File

@ -68,6 +68,7 @@ Three layers under `lib/`, each single-purpose:
token. `sync_settings.dart`: persisted owner/repo/token/clientId.
- `notes_markdown.dart`: round-trippable single-file export/import format
(HTML-comment `<!-- @note id=… priority=… status=… … -->` markers).
- `images/` + `attachments/` — attached images (see "Images" below).
- `ui/` — screens, all take an injected `NoteRepository`.
- `capture_screen.dart`: landing screen; always-focused text box pre-filled
with the structured template; lazy note creation on first keystroke.
@ -98,11 +99,29 @@ that, the stepper would fold a retired section into its neighbour on the next
save. `tool/migrate_backlog.dart` converts an old export (Settings → Export →
migrate → Settings → Import).
### Images
A note never holds image bytes, only a line
`![](https://kuhy-cloud.duckdns.org/todo-images/<noteId>/<uuid>.jpg)`
(`lib/images/image_ref.dart` owns that shape), so sync and older builds are
untouched. Every image is re-encoded before it leaves the device: ≤2560 px,
JPEG q85, EXIF stripped after applying its rotation (`image_processing.dart`).
`ImageCacheStore` keeps every linked image on-device (offline) plus a
`.pending` marker per queued upload, and `reconcile` uploads, prefetches and
evicts (never an empty ref set, never within 10 min). Android talks to dufs
directly with the `todo` login's password (Settings → Cloud images); the
desktop page never does — the wrapper's `/images/*` routes do, with
`~/.config/dufs/logins/todo.env`, created by
`~/src/dufs-cloud/scripts/add_dufs_login.sh todo /todo-images rw`. BACKLOG.md
rewrites links to `~/data/cloud/todo-images/…` so an agent can open them.
## Testing
- **The suite must stay quick and fully green.** It is currently **313 tests at
100% line coverage** (2005 lines), running well under a minute on this
machine. Don't regress either.
- **The suite must stay quick and fully green.** It is currently **418 tests at
100% line coverage** (2494 lines), ~30s on this machine with
`--concurrency=3` (unbounded concurrency under `capped.sh`'s 4 GiB gets its
test processes SIGTERMed while the wrapper still exits 0 — check the last
line says "All tests passed"). Don't regress either.
(The "~5s" figure this file used to quote had drifted well before the port
guard was added; measure before quoting a new one.)
- Widget tests use `test/fake_note_repository.dart` — a `FakeNoteRepository`

View File

@ -0,0 +1,56 @@
/// The phone's copy of the `todo` dufs login password, in the OS keystore.
///
/// Created on the PC by `add_dufs_login.sh todo /todo-images rw` and pasted
/// into Settings → Cloud images. The desktop never uses this: its wrapper
/// reads `~/.config/dufs/logins/todo.env` instead.
library;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:todo/images/dufs_image_client.dart';
/// The login name `add_dufs_login.sh` was run with.
const kDufsImagesUser = 'todo';
/// Reads and writes the password; failures read as "no password".
class ImagePasswordStore {
/// Creates a store over [storage] (injectable for tests).
const ImagePasswordStore({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
final FlutterSecureStorage _storage;
static const _key = 'dufsImagesPassword';
/// The saved password, or '' when none is set or the keystore is
/// unavailable.
Future<String> read() async {
try {
return await _storage.read(key: _key) ?? '';
} on Exception {
return '';
}
}
/// Saves [password] (an empty one deletes it); false if the keystore is
/// unavailable.
Future<bool> write(String password) async {
try {
if (password.isEmpty) {
await _storage.delete(key: _key);
} else {
await _storage.write(key: _key, value: password);
}
return true;
} on Exception {
return false;
}
}
/// The full login, or null when no password is saved.
Future<DufsLogin?> login() async {
final password = await read();
if (password.isEmpty) return null;
return DufsLogin(user: kDufsImagesUser, password: password);
}
}

View File

@ -0,0 +1,7 @@
/// `openImageStore()` for the current platform: app storage + direct dufs on
/// Android, the local wrapper's `/images/*` routes on the desktop web build.
library;
export 'image_store_api.dart';
export 'image_store_io.dart'
if (dart.library.js_interop) 'image_store_web.dart';

View File

@ -0,0 +1,113 @@
/// What the UI asks of attached images, independent of platform.
///
/// Android keeps images itself (an `ImageCacheStore` in app storage, talking
/// to dufs directly); the desktop web page delegates everything to the local
/// wrapper's `/images/*` routes. Both implement [ImageStore], picked by the
/// conditional export in `image_store.dart`.
library;
import 'package:flutter/foundation.dart';
import 'package:todo/images/image_ref.dart';
/// Where to draw an image from right now.
sealed class ImageSource {
const ImageSource();
}
/// A file on this device.
final class LocalImageSource extends ImageSource {
/// Creates a source for the file at [path].
const LocalImageSource(this.path);
/// Absolute path of the cached JPEG.
final String path;
}
/// A same-origin URL served by the desktop wrapper.
final class NetworkImageSource extends ImageSource {
/// Creates a source for [url].
const NetworkImageSource(this.url);
/// The wrapper URL, e.g. `http://localhost:8730/images/<noteId>/<uuid>.jpg`.
final String url;
}
/// Not on this device yet (it downloads on the next reconcile).
final class MissingImageSource extends ImageSource {
/// The one instance.
const MissingImageSource();
}
/// Summary for the settings screen.
class ImageStoreStatus {
/// Creates a status.
const ImageStoreStatus({required this.configured, required this.pending});
/// Whether a dufs login is available to this device.
final bool configured;
/// Images waiting to upload.
final int pending;
}
/// Thrown by [ImageStore.attach] when the image could not be stored at all.
class ImageStoreException implements Exception {
/// Creates an exception carrying [message].
const ImageStoreException(this.message);
/// What went wrong, shown to the user.
final String message;
@override
String toString() => message;
}
/// Stores, shows and syncs the images notes link to. Notifies when an image
/// becomes available or changes state, so embeds can redraw.
abstract class ImageStore extends ChangeNotifier {
/// Stores [bytes] (any image format) for [noteId] and queues the upload.
///
/// Returns the new ref, or null when [bytes] is not an image. Throws
/// [ImageStoreException] when it cannot be stored.
Future<ImageRef?> attach(String noteId, Uint8List bytes);
/// Uploads the queue, fetches every image [noteTexts] link to that this
/// device lacks, and evicts cached images no note links to.
Future<void> reconcile(Iterable<String> noteTexts);
/// Where to draw [ref] from.
ImageSource sourceFor(ImageRef ref);
/// Whether [ref] is still waiting to upload (always false when unknown).
bool isPending(ImageRef ref);
/// Login and queue summary.
Future<ImageStoreStatus> status();
}
/// Every image ref across [noteTexts].
Set<ImageRef> imageRefsAcross(Iterable<String> noteTexts) => {
for (final text in noteTexts) ...imageRefsIn(text),
};
/// The store used where none was provided (tests, and any screen built
/// outside the app root): attaching is refused, nothing is ever available.
class NoImageStore extends ImageStore {
@override
Future<ImageRef?> attach(String noteId, Uint8List bytes) async =>
throw const ImageStoreException('Images are not available here');
@override
Future<void> reconcile(Iterable<String> noteTexts) async {}
@override
ImageSource sourceFor(ImageRef ref) => const MissingImageSource();
@override
bool isPending(ImageRef ref) => false;
@override
Future<ImageStoreStatus> status() async =>
const ImageStoreStatus(configured: false, pending: 0);
}

View File

@ -0,0 +1,101 @@
/// Android: images live in app storage and sync with dufs directly.
library;
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:todo/attachments/image_password_store.dart';
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/images/dufs_image_client.dart';
import 'package:todo/images/image_cache_store.dart';
import 'package:todo/images/image_processing.dart';
import 'package:todo/images/image_ref.dart';
/// Opens the store under the app-support directory's `images/`.
// coverage:ignore-start
// Platform wiring only: path_provider has no binding in unit tests, which
// construct [IoImageStore] over a temp directory instead.
Future<ImageStore> openImageStore() async {
final support = await getApplicationSupportDirectory();
final root = Directory(p.join(support.path, 'images'));
return IoImageStore(ImageCacheStore(root));
}
// coverage:ignore-end
/// [ImageStore] over a local [ImageCacheStore], uploading with the login
/// from [ImagePasswordStore].
class IoImageStore extends ImageStore {
/// Creates a store over [cache]; the rest is injectable for tests.
IoImageStore(
this.cache, {
this._passwords = const ImagePasswordStore(),
http.Client Function()? httpClient,
Future<Uint8List?> Function(Uint8List)? prepare,
}) : _httpClient = httpClient ?? http.Client.new,
_prepare = prepare ?? prepareImageInBackground;
/// The on-device images and upload queue.
final ImageCacheStore cache;
final ImagePasswordStore _passwords;
final http.Client Function() _httpClient;
final Future<Uint8List?> Function(Uint8List) _prepare;
@override
Future<ImageRef?> attach(String noteId, Uint8List bytes) async {
final jpeg = await _prepare(bytes);
if (jpeg == null) return null;
final ref = ImageRef.create(noteId);
try {
await cache.put(ref, jpeg);
} on FileSystemException catch (e) {
throw ImageStoreException('Could not save the image: ${e.message}');
}
notifyListeners();
// Upload right away when online. An empty ref set never evicts, so this
// cannot race the link reaching the note.
unawaited(_sync(const {}));
return ref;
}
@override
Future<void> reconcile(Iterable<String> noteTexts) =>
_sync(imageRefsAcross(noteTexts));
Future<void> _sync(Set<ImageRef> refs) async {
final login = await _passwords.login();
final client = login == null
? null
: DufsImageClient(login, httpClient: _httpClient());
try {
final report = await cache.reconcile(refs, client);
if (report.uploaded + report.downloaded + report.evicted > 0) {
notifyListeners();
}
} finally {
client?.close();
}
}
@override
ImageSource sourceFor(ImageRef ref) {
final file = cache.fileFor(ref);
return file.existsSync()
? LocalImageSource(file.path)
: const MissingImageSource();
}
@override
bool isPending(ImageRef ref) => cache.isPending(ref);
@override
Future<ImageStoreStatus> status() async => ImageStoreStatus(
configured: await _passwords.login() != null,
pending: cache.pending().length,
);
}

View File

@ -0,0 +1,29 @@
import 'package:flutter/widgets.dart';
import 'package:todo/attachments/image_store_api.dart';
/// Makes the app's one [ImageStore] reachable from any widget below it,
/// without threading it through every screen constructor.
///
/// Screens built without a scope (most widget tests) get a shared
/// [NoImageStore], so they render and simply cannot attach.
class ImageStoreScope extends InheritedNotifier<ImageStore> {
/// Provides [store] to [child].
const ImageStoreScope({
required ImageStore store,
required super.child,
super.key,
}) : super(notifier: store);
static final ImageStore _none = NoImageStore();
/// The nearest store, subscribing [context] to its changes.
static ImageStore of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<ImageStoreScope>()?.notifier ??
_none;
/// The nearest store, without subscribing (for callbacks).
static ImageStore read(BuildContext context) =>
context.getInheritedWidgetOfExactType<ImageStoreScope>()?.notifier ??
_none;
}

View File

@ -0,0 +1,103 @@
/// Desktop (web build): every image operation goes through the local
/// wrapper's `/images/*` routes; the page never sees the dufs password.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/sync/desktop_wrapper.dart';
/// Opens the store against the wrapper that served this page.
Future<ImageStore> openImageStore() async =>
WebImageStore(origin: servingWrapperOrigin());
/// [ImageStore] backed by the desktop wrapper at [origin].
class WebImageStore extends ImageStore {
/// Creates a store talking to [origin]; [httpClient] is injectable.
WebImageStore({required this.origin, http.Client? httpClient})
: _http = httpClient ?? http.Client();
/// The wrapper's origin, e.g. `http://localhost:8730`.
final String origin;
final http.Client _http;
// Bumped whenever the wrapper may hold new bytes, so an image that failed
// to load (not downloaded yet) is requested again under a fresh URL.
int _revision = 0;
Uri _images(String rest) => Uri.parse('$origin/images/$rest');
@override
Future<ImageRef?> attach(String noteId, Uint8List bytes) async {
final ref = ImageRef.create(noteId);
final http.Response response;
try {
response = await _http.post(_images(ref.relativePath), body: bytes);
} on Exception {
throw const ImageStoreException(
'The desktop helper is not running; the image was not saved',
);
}
if (response.statusCode == 415) return null;
if (response.statusCode != 204) {
throw ImageStoreException(
'The desktop helper refused the image (${response.statusCode})',
);
}
_revision++;
notifyListeners();
unawaited(_reconcile(const {}));
return ref;
}
@override
Future<void> reconcile(Iterable<String> noteTexts) =>
_reconcile(imageRefsAcross(noteTexts));
Future<void> _reconcile(Set<ImageRef> refs) async {
try {
final response = await _http.post(
_images('reconcile'),
headers: {'content-type': 'application/json'},
body: jsonEncode({
'refs': [for (final ref in refs) ref.relativePath],
}),
);
if (response.statusCode != 200) return;
final report = jsonDecode(response.body) as Map<String, dynamic>;
if ((report['downloaded'] as int? ?? 0) > 0) {
_revision++;
notifyListeners();
}
} on Exception {
// Best-effort: the next sync, resume or attach reconciles again.
}
}
@override
ImageSource sourceFor(ImageRef ref) =>
NetworkImageSource('$origin/images/${ref.relativePath}?r=$_revision');
@override
bool isPending(ImageRef ref) => false;
@override
Future<ImageStoreStatus> status() async {
try {
final response = await _http.get(_images('status'));
final body = jsonDecode(response.body) as Map<String, dynamic>;
return ImageStoreStatus(
configured: body['configured'] as bool? ?? false,
pending: body['pending'] as int? ?? 0,
);
} on Exception {
return const ImageStoreStatus(configured: false, pending: 0);
}
}
}

View File

@ -17,6 +17,7 @@
library;
import 'package:todo/data/note_template.dart';
import 'package:todo/images/image_ref.dart';
/// Matches a single `http(s)://…` token.
final RegExp _linkPattern = RegExp(r'^https?://\S+$');
@ -140,14 +141,25 @@ ParsedNote parse(NoteTemplate template, String text) {
}
/// The note's display title: the `#` heading text without its `#` marker, or
/// the first non-empty line for freeform notes.
/// the first non-empty line for freeform notes. Attached-image lines are
/// skipped (a 120-char dufs URL is no title); a note of nothing but images
/// is titled by their count.
String noteTitle(String text) {
var images = 0;
for (final line in text.split('\n')) {
final trimmed = line.trim();
if (trimmed.isEmpty) continue;
if (imageRefOfLine(trimmed) != null) {
images++;
continue;
}
return trimmed.replaceFirst(RegExp(r'^#+\s*'), '');
}
return '';
return switch (images) {
0 => '',
1 => 'Image',
_ => '$images images',
};
}
/// Returns the section whose `## label` heading is on [line], or null. Only

View File

@ -14,7 +14,7 @@ Future<NoteRepository> openRepository() async {
final database = await WebLogPersistence.openDatabase(idbFactoryBrowser);
final persistence = WebLogPersistence(
database: database,
backup: DesktopBackupClient(baseUrl: desktopWrapperOrigin),
backup: DesktopBackupClient(baseUrl: servingWrapperOrigin()),
);
final prefs = await SharedPreferences.getInstance();

View File

@ -0,0 +1,37 @@
/// Reads the dufs login `add_dufs_login.sh` wrote for the desktop wrapper.
///
/// `~/src/dufs-cloud/scripts/add_dufs_login.sh todo /todo-images rw` leaves a
/// 0600 `~/.config/dufs/logins/todo.env` of `KEY=value` lines. The wrapper
/// re-reads it on every reconcile, so creating or rotating the login takes
/// effect without restarting the desktop app.
library;
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:todo/images/dufs_image_client.dart';
/// `~/.config/dufs/logins/todo.env` under [home].
String defaultDufsLoginEnvPath(String home) =>
p.join(home, '.config', 'dufs', 'logins', 'todo.env');
/// The login in [envFile], or null when it is absent or incomplete.
DufsLogin? readDufsLoginEnv(File envFile) {
if (!envFile.existsSync()) return null;
final values = <String, String>{};
for (final line in envFile.readAsLinesSync()) {
final eq = line.indexOf('=');
if (eq <= 0 || line.startsWith('#')) continue;
values[line.substring(0, eq).trim()] = line.substring(eq + 1).trim();
}
final user = values['DUFS_USER'] ?? '';
final password = values['DUFS_PASSWORD'] ?? '';
final url = values['DUFS_URL'] ?? '';
if (user.isEmpty || password.isEmpty || url.isEmpty) return null;
return DufsLogin(
user: user,
password: password,
baseUrl: url.endsWith('/') ? url.substring(0, url.length - 1) : url,
);
}

View File

@ -0,0 +1,159 @@
/// The desktop wrapper's `/images/*` routes: the web page's only way to
/// store, show and sync attached images.
///
/// The page never talks to dufs itself. That keeps the dufs password out of
/// the browser, needs no CORS on dufs, and gives the desktop the same offline
/// store the phone has (an [ImageCacheStore] on disk). Same-origin with the
/// page, so an `<img src="/images/…">` just works.
///
/// * `POST /images/<noteId>/<uuid>.jpg` — raw bytes of any image; re-encoded
/// here (the page must not decode on its UI thread), stored, queued.
/// 415 when the bytes are not an image.
/// * `GET /images/<noteId>/<uuid>.jpg` — the cached JPEG, 404 when absent.
/// * `POST /images/reconcile` — `{"refs": ["<noteId>/<uuid>.jpg", …]}`: every
/// image the notes link to. Uploads the queue, fetches what is missing,
/// evicts the rest; answers with the [ReconcileReport].
/// * `GET /images/status` — `{"configured": bool, "pending": n}`.
library;
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:todo/desktop/dufs_login_env.dart';
import 'package:todo/desktop/wrapper_paths.dart';
import 'package:todo/images/dufs_image_client.dart';
import 'package:todo/images/image_cache_store.dart';
import 'package:todo/images/image_processing.dart';
import 'package:todo/images/image_ref.dart';
/// Largest upload accepted from the page, before re-encoding (a raw 50 MP
/// phone photo is ~25 MB).
const int kMaxImageUploadBytes = 64 * 1024 * 1024;
/// The routes the real wrapper uses: cache under
/// `~/.local/share/todo-desktop/images`, login from `todo.env`.
WrapperImageRoutes defaultWrapperImageRoutes(String home) => WrapperImageRoutes(
store: ImageCacheStore(Directory(defaultImageCachePath(home))),
loginEnvPath: defaultDufsLoginEnvPath(home),
);
/// Handles `/images/*` for the wrapper.
class WrapperImageRoutes {
/// Creates the routes over [store], reading the dufs login from
/// [loginEnvPath]. [httpClient] and [prepare] are injectable for tests.
WrapperImageRoutes({
required this.store,
required this.loginEnvPath,
http.Client Function()? httpClient,
Future<Uint8List?> Function(Uint8List)? prepare,
}) : _httpClient = httpClient ?? http.Client.new,
_prepare = prepare ?? prepareImageInBackground;
/// The on-disk image cache and upload queue.
final ImageCacheStore store;
/// `todo.env` written by `add_dufs_login.sh`.
final String loginEnvPath;
final http.Client Function() _httpClient;
final Future<Uint8List?> Function(Uint8List) _prepare;
static const _prefix = '/images/';
/// Answers [request] and returns true when it is an `/images/` route.
Future<bool> handle(HttpRequest request) async {
final path = request.uri.path;
if (!path.startsWith(_prefix)) return false;
final rest = path.substring(_prefix.length);
final response = request.response;
final method = request.method;
if (rest == 'reconcile' && method == 'POST') {
await _reconcile(request);
} else if (rest == 'status' && method == 'GET') {
_json(response, {
'configured': _login() != null,
'pending': store.pending().length,
});
} else if (ImageRef.tryParse(rest) case final ref?) {
if (method == 'GET') {
await _serve(response, ref);
} else if (method == 'POST') {
await _put(request, ref);
} else {
response.statusCode = HttpStatus.methodNotAllowed;
}
} else {
response.statusCode = HttpStatus.notFound;
}
return true;
}
DufsLogin? _login() => readDufsLoginEnv(File(loginEnvPath));
Future<void> _serve(HttpResponse response, ImageRef ref) async {
final file = store.fileFor(ref);
if (!file.existsSync()) {
response.statusCode = HttpStatus.notFound;
return;
}
response.headers
..contentType = ContentType('image', 'jpeg')
// The name is a fresh uuid per image, so the bytes never change.
..set(HttpHeaders.cacheControlHeader, 'private, max-age=31536000');
await response.addStream(file.openRead());
}
Future<void> _put(HttpRequest request, ImageRef ref) async {
final builder = BytesBuilder(copy: false);
var tooLarge = false;
// Read to the end even once over the cap: answering while the browser
// is still sending makes it see a dropped connection, not the 413.
await for (final chunk in request) {
tooLarge =
tooLarge || builder.length + chunk.length > kMaxImageUploadBytes;
if (!tooLarge) builder.add(chunk);
}
if (tooLarge) {
request.response.statusCode = HttpStatus.requestEntityTooLarge;
return;
}
final jpeg = await _prepare(builder.takeBytes());
if (jpeg == null) {
request.response.statusCode = HttpStatus.unsupportedMediaType;
return;
}
await store.put(ref, jpeg);
request.response.statusCode = HttpStatus.noContent;
}
Future<void> _reconcile(HttpRequest request) async {
final Set<ImageRef> refs;
try {
final body = jsonDecode(await utf8.decodeStream(request));
refs = {
for (final raw in (body as Map<String, dynamic>)['refs'] as List)
?ImageRef.tryParse(raw as String),
};
} on Object {
request.response.statusCode = HttpStatus.badRequest;
return;
}
final login = _login();
final client = login == null
? null
: DufsImageClient(login, httpClient: _httpClient());
try {
_json(request.response, (await store.reconcile(refs, client)).toJson());
} finally {
client?.close();
}
}
void _json(HttpResponse response, Object body) {
response.headers.contentType = ContentType.json;
response.write(jsonEncode(body));
}
}

View File

@ -0,0 +1,25 @@
/// Default on-disk locations the desktop wrapper reads and writes.
///
/// Split out of `wrapper_server.dart` (file-length cap); re-exported from
/// there, so callers keep importing the server.
library;
import 'package:path/path.dart' as p;
/// The canonical backlog file for [home], i.e. `~/src/todo/BACKLOG.md`.
///
/// Lives here rather than in `bin/todo_desktop.dart` so it is covered: the
/// entry point is `coverage:ignore-file` thin wiring, and this path silently
/// broke once already. The 2026-09-11 `~` reorganisation moved every repo
/// under `~/src`, but the migration's rewriter only matched *literal*
/// `/home/kuhy/todo` strings — a path assembled from segments was invisible
/// to it, so the wrapper went on exporting to the dead `~/todo` while the
/// `todo` MCP read `~/src/todo`. Every backlog read between 2026-09-11 and
/// 2026-09-12 got a stale file.
String defaultBacklogPath(String home) =>
p.join(home, 'src', 'todo', 'BACKLOG.md');
/// The wrapper's image cache and upload queue,
/// `~/.local/share/todo-desktop/images` — beside the note-log copy.
String defaultImageCachePath(String home) =>
p.join(home, '.local', 'share', 'todo-desktop', 'images');

View File

@ -5,6 +5,10 @@ import 'dart:io';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:path/path.dart' as p;
import 'package:todo/desktop/wrapper_image_routes.dart';
export 'wrapper_paths.dart';
part 'wrapper_sync_routes.dart';
/// Route serving `todo`'s own seeded Firebase session (a `FirebaseCredentials`
@ -16,19 +20,6 @@ part 'wrapper_sync_routes.dart';
/// [kSyncAccountEnvVar] as [kSyncAccountPath].
const kSyncCredentialsPath = '/sync-credentials';
/// The canonical backlog file for [home], i.e. `~/src/todo/BACKLOG.md`.
///
/// Lives here rather than in `bin/todo_desktop.dart` so it is covered: the
/// entry point is `coverage:ignore-file` thin wiring, and this path silently
/// broke once already. The 2026-09-11 `~` reorganisation moved every repo
/// under `~/src`, but the migration's rewriter only matched *literal*
/// `/home/kuhy/todo` strings — a path assembled from segments was invisible
/// to it, so the wrapper went on exporting to the dead `~/todo` while the
/// `todo` MCP read `~/src/todo`. Every backlog read between 2026-09-11 and
/// 2026-09-12 got a stale file.
String defaultBacklogPath(String home) =>
p.join(home, 'src', 'todo', 'BACKLOG.md');
/// Local HTTP server backing the desktop app.
///
/// The desktop app is a Flutter **web** build (Flutter's Linux embedder manages
@ -55,7 +46,11 @@ class WrapperServer {
bool? serveSyncAccount,
String? syncConfigDir,
String? todoCredentialsPath,
}) : serveSyncAccount =
WrapperImageRoutes? images,
}) : images =
images ??
defaultWrapperImageRoutes(Platform.environment['HOME'] ?? ''),
serveSyncAccount =
serveSyncAccount ??
(Platform.environment[kSyncAccountEnvVar] ?? '').isNotEmpty,
syncConfigDir =
@ -70,6 +65,9 @@ class WrapperServer {
'firebase_auth.json',
);
/// The `/images/*` routes (attached images; see [WrapperImageRoutes]).
final WrapperImageRoutes images;
/// Whether the sync-account route answers; off unless explicitly enabled.
final bool serveSyncAccount;
@ -152,6 +150,7 @@ class WrapperServer {
if (path == kSyncCredentialsPath) {
return await _syncCredentials(request);
}
if (await images.handle(request)) return;
return await _static(request, path);
}
@ -219,6 +218,8 @@ class WrapperServer {
return ContentType('text', 'css', charset: 'utf-8');
case '.png':
return ContentType('image', 'png');
case '.jpg' || '.jpeg':
return ContentType('image', 'jpeg');
case '.svg':
return ContentType('image', 'svg+xml');
case '.ttf':

View File

@ -0,0 +1,114 @@
/// The three WebDAV calls image sync needs, against kuhy's dufs.
///
/// Deliberately not dufs-cloud's full `DufsClient` (~700 lines with listing,
/// moves, streaming progress and an xml parser): todo only ever puts, gets and
/// checks one folder. dufs creates missing parent folders on PUT (verified
/// against dufs 0.46.0), so there is no MKCOL step.
///
/// Pure Dart: the desktop wrapper imports it.
library;
import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import 'package:todo/images/image_ref.dart';
/// A scoped dufs login, e.g. `todo` → `/todo-images`.
class DufsLogin {
/// Creates a login for [user] with [password] against [baseUrl].
const DufsLogin({
required this.user,
required this.password,
this.baseUrl = kDufsBaseUrl,
});
/// The login name (`add_dufs_login.sh`'s first argument).
final String user;
/// Its generated password.
final String password;
/// The dufs origin, without a trailing slash.
final String baseUrl;
}
/// Outcome of [DufsImageClient.check], for the settings screen.
enum DufsCheck {
/// The login can see its folder.
ok,
/// dufs refused the login (401/403).
rejected,
/// dufs answered something else, or could not be reached.
unreachable,
}
/// Upload / download / check for images under [kImagesDir].
class DufsImageClient {
/// Creates a client for [login]; [httpClient] is injectable for tests.
DufsImageClient(this.login, {http.Client? httpClient})
: _http = httpClient ?? http.Client();
/// The credentials every request carries.
final DufsLogin login;
final http.Client _http;
Map<String, String> get _auth {
final pair = utf8.encode('${login.user}:${login.password}');
return {'authorization': 'Basic ${base64.encode(pair)}'};
}
Uri _uri(String relative) =>
Uri.parse('${login.baseUrl}/$kImagesDir/$relative');
/// PUTs [bytes] as [ref]; true on a 2xx.
Future<bool> upload(ImageRef ref, Uint8List bytes) async {
try {
final response = await _http.put(
_uri(ref.relativePath),
headers: {..._auth, 'content-type': 'image/jpeg'},
body: bytes,
);
return response.statusCode >= 200 && response.statusCode < 300;
} on Exception {
return false;
}
}
/// GETs [ref]'s bytes, or null when it is missing or unreachable.
Future<Uint8List?> download(ImageRef ref) async {
try {
final response = await _http.get(
_uri(ref.relativePath),
headers: _auth,
);
return response.statusCode == 200 ? response.bodyBytes : null;
} on Exception {
return null;
}
}
/// Whether the login can see its folder (PROPFIND, depth 0).
Future<DufsCheck> check() async {
try {
final request = http.Request('PROPFIND', _uri(''))
..headers.addAll({..._auth, 'depth': '0'});
final response = await _http.send(request);
await response.stream.drain<void>();
return switch (response.statusCode) {
207 => DufsCheck.ok,
401 || 403 => DufsCheck.rejected,
_ => DufsCheck.unreachable,
};
} on Exception {
return DufsCheck.unreachable;
}
}
/// Releases the underlying HTTP client.
void close() => _http.close();
}

View File

@ -0,0 +1,207 @@
/// The on-device copy of every image a note links to, plus the upload queue.
///
/// One directory, `<root>/<noteId>/<uuid>.jpg`, mirrors dufs' `todo-images/`.
/// An image attached while offline gets a `<uuid>.jpg.pending` marker next to
/// it; the marker is the queue, so it survives restarts with no separate
/// bookkeeping, and it is only removed after dufs has accepted the upload.
///
/// Used as-is by the Android app and by the desktop wrapper (the web page
/// never talks to dufs). Pure Dart + `dart:io`.
library;
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:path/path.dart' as p;
import 'package:todo/images/dufs_image_client.dart';
import 'package:todo/images/image_ref.dart';
/// How long a cached, unreferenced image is kept before eviction.
///
/// Covers the gap between writing an image and its link reaching the note
/// store (and, on desktop, the page's next reconcile): without it a
/// reconcile landing in that gap would delete a fresh image.
const kEvictionGrace = Duration(minutes: 10);
/// Counts from one [ImageCacheStore.reconcile] pass.
class ReconcileReport {
/// Creates a report.
const ReconcileReport({
this.uploaded = 0,
this.downloaded = 0,
this.evicted = 0,
this.pending = 0,
this.missing = 0,
});
/// Queued images dufs accepted this pass.
final int uploaded;
/// Linked images fetched from dufs this pass.
final int downloaded;
/// Unlinked cached images deleted.
final int evicted;
/// Images still waiting to upload after the pass.
final int pending;
/// Linked images still not on this device after the pass.
final int missing;
/// JSON form, for the wrapper's `/images/reconcile` response.
Map<String, int> toJson() => {
'uploaded': uploaded,
'downloaded': downloaded,
'evicted': evicted,
'pending': pending,
'missing': missing,
};
}
/// Local image files and their upload queue under [root].
class ImageCacheStore {
/// Creates a store rooted at [root]; [now] is injectable for tests.
ImageCacheStore(this.root, {DateTime Function()? now})
: _now = now ?? DateTime.now;
/// Directory holding `<noteId>/<uuid>.jpg`.
final Directory root;
final DateTime Function() _now;
// Passes run one at a time. Calls arriving before the next pass starts
// merge into it (see [reconcile]).
Future<void> _tail = Future.value();
Future<ReconcileReport>? _scheduled;
_Pass? _scheduledPass;
/// The cached file for [ref] (it may not exist).
File fileFor(ImageRef ref) => File(p.join(root.path, ref.noteId, ref.file));
File _marker(ImageRef ref) => File('${fileFor(ref).path}.pending');
/// Whether [ref] is waiting to upload.
bool isPending(ImageRef ref) => _marker(ref).existsSync();
/// Stores [bytes] as [ref] and queues it for upload.
Future<void> put(ImageRef ref, Uint8List bytes) async {
final file = fileFor(ref);
await file.parent.create(recursive: true);
// Marker first: a crash between the two writes then leaves a marker with
// no file (skipped by reconcile), never a file that silently never uploads.
await _marker(ref).writeAsString('');
final tmp = File('${file.path}.tmp');
await tmp.writeAsBytes(bytes, flush: true);
await tmp.rename(file.path);
}
/// Every image waiting to upload.
List<ImageRef> pending() => [
for (final marker in _files('.pending'))
?ImageRef.tryParse(_relative(marker).replaceFirst('.pending', '')),
];
/// Uploads the queue, fetches linked images this device lacks, and evicts
/// cached images nothing links to any more.
///
/// Without a [client] (no password set yet) only eviction runs. Eviction is
/// skipped entirely when [referenced] is empty — an empty set far more
/// likely means "notes not loaded yet" than "every image unlinked" — and it
/// never touches a queued image or one younger than [kEvictionGrace].
///
/// Overlapping calls do not run in parallel: calls made before the next
/// pass starts share it, with their refs unioned and the newest client.
Future<ReconcileReport> reconcile(
Set<ImageRef> referenced,
DufsImageClient? client,
) {
// Merge into a pass that has not started yet: union the refs (an
// upload-only call right after an attach must not wipe a full pass's
// refs) and keep the newest client.
final waiting = _scheduledPass;
_scheduledPass = waiting == null
? _Pass(referenced, client)
: _Pass({
...waiting.referenced,
...referenced,
}, client ?? waiting.client);
final scheduled = _scheduled;
if (scheduled != null) return scheduled;
final future = _tail.then((_) {
final pass = _scheduledPass!;
_scheduledPass = null;
_scheduled = null;
return _run(pass);
});
_scheduled = future;
_tail = future.then<void>((_) {}, onError: (Object _) {});
return future;
}
Future<ReconcileReport> _run(_Pass pass) async {
var uploaded = 0;
var downloaded = 0;
final client = pass.client;
if (client != null) {
for (final ref in pending()) {
final file = fileFor(ref);
if (!file.existsSync()) continue;
if (await client.upload(ref, await file.readAsBytes())) {
await _marker(ref).delete();
uploaded++;
}
}
for (final ref in pass.referenced) {
if (fileFor(ref).existsSync()) continue;
final bytes = await client.download(ref);
if (bytes == null) continue;
final file = fileFor(ref);
await file.parent.create(recursive: true);
await file.writeAsBytes(bytes, flush: true);
downloaded++;
}
}
final evicted = pass.referenced.isEmpty ? 0 : _evict(pass.referenced);
return ReconcileReport(
uploaded: uploaded,
downloaded: downloaded,
evicted: evicted,
pending: pending().length,
missing: pass.referenced.where((r) => !fileFor(r).existsSync()).length,
);
}
int _evict(Set<ImageRef> referenced) {
final cutoff = _now().subtract(kEvictionGrace);
var evicted = 0;
for (final file in _files('.jpg')) {
final ref = ImageRef.tryParse(_relative(file));
if (ref == null || referenced.contains(ref) || isPending(ref)) continue;
if (file.lastModifiedSync().isAfter(cutoff)) continue;
file.deleteSync();
evicted++;
}
return evicted;
}
List<File> _files(String suffix) {
if (!root.existsSync()) return const [];
return [
for (final entity in root.listSync(recursive: true))
if (entity is File && entity.path.endsWith(suffix)) entity,
];
}
String _relative(File file) =>
p.split(p.relative(file.path, from: root.path)).join('/');
}
class _Pass {
const _Pass(this.referenced, this.client);
final Set<ImageRef> referenced;
final DufsImageClient? client;
}

View File

@ -0,0 +1,66 @@
/// Shrinks a picked or pasted image to what a note needs, before it is stored
/// or uploaded.
///
/// Phone photos are 3–8 MB with GPS coordinates in their EXIF. Every image is
/// re-encoded here: the recorded rotation is applied to the pixels first (so
/// dropping EXIF does not leave a portrait photo lying on its side), the long
/// side is capped at [kMaxImageSide], and the result is a JPEG with no
/// metadata at all.
///
/// Pure Dart (`package:image`), so the Android app and the desktop wrapper
/// run the same code; the web page never decodes images, it hands the raw
/// bytes to the wrapper.
library;
import 'dart:isolate';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:image/image.dart' as img;
/// Longest side, in pixels, of a stored image.
const kMaxImageSide = 2560;
/// JPEG quality of a stored image.
const kJpegQuality = 85;
/// Re-encodes [bytes] as a metadata-free JPEG no larger than [kMaxImageSide]
/// on its long side, or returns null when [bytes] is not a decodable image.
Uint8List? prepareImage(Uint8List bytes) {
final decoded = _decode(bytes);
if (decoded == null) return null;
var image = img.bakeOrientation(decoded);
final longSide = math.max(image.width, image.height);
if (longSide > kMaxImageSide) {
image = image.width >= image.height
? img.copyResize(image, width: kMaxImageSide)
: img.copyResize(image, height: kMaxImageSide);
}
if (image.hasAlpha) image = _flattenOntoWhite(image);
image.exif = img.ExifData();
return img.encodeJpg(image, quality: kJpegQuality);
}
/// [prepareImage] off the calling isolate: decoding a 12 MP photo takes long
/// enough to drop frames if it runs on the UI isolate.
Future<Uint8List?> prepareImageInBackground(Uint8List bytes) =>
Isolate.run(() => prepareImage(bytes));
/// `decodeImage` returns null for most non-images but *throws* (a
/// RangeError from its PSD sniffer, seen on a 3-byte input) for others; both
/// mean "not an image" here.
img.Image? _decode(Uint8List bytes) {
try {
return img.decodeImage(bytes);
} on Object {
return null;
}
}
/// JPEG has no alpha: without this a transparent screenshot's clear pixels
/// come out as whatever colour their RGB happened to hold, usually black.
img.Image _flattenOntoWhite(img.Image image) {
final background = img.Image(width: image.width, height: image.height)
..clear(img.ColorRgb8(255, 255, 255));
return img.compositeImage(background, image);
}

91
lib/images/image_ref.dart Normal file
View File

@ -0,0 +1,91 @@
/// Where a note's image lives, and how a note's text points at it.
///
/// A note never stores image bytes: it carries a plain markdown link
/// `![](https://kuhy-cloud.duckdns.org/todo-images/<noteId>/<uuid>.jpg)`, so the
/// CRDT log, the sync format and every older build stay untouched (an old
/// build just shows the link as text). This file is the single source of truth
/// for that link's shape; everything else goes through it.
///
/// Pure Dart: the desktop wrapper (`bin/todo_desktop.dart`) imports it.
library;
import 'package:uuid/uuid.dart';
/// Public origin of kuhy's dufs server (Caddy → dufs on 127.0.0.1:5000).
const kDufsBaseUrl = 'https://kuhy-cloud.duckdns.org';
/// The dufs folder the `todo` login is scoped to.
const kImagesDir = 'todo-images';
/// Where the same folder sits on kuhy's PC, as written into `BACKLOG.md` so an
/// agent reading the backlog can open the image straight from disk.
const kBacklogImagesRoot = '~/data/cloud/$kImagesDir';
// Segments are restricted to URL- and filesystem-safe characters, which is
// what makes a ref safe to join onto a cache directory without a traversal
// check of its own.
const _segment = '[A-Za-z0-9_-]+';
const _path = '$_segment/$_segment\\.jpg';
const _label = r'!\[[^\]\n]*\]\(';
final _refPath = RegExp('^($_segment)/($_segment\\.jpg)\$');
final _urlLink = RegExp(
'$_label${RegExp.escape('$kDufsBaseUrl/$kImagesDir/')}($_path)\\)',
);
final _backlogLink = RegExp(
'($_label)${RegExp.escape('$kBacklogImagesRoot/')}($_path\\))',
);
final _wholeLine = RegExp('^\\s*${_urlLink.pattern}\\s*\$');
/// One image, addressed as `<noteId>/<file>` under [kImagesDir].
///
/// An extension type over that relative path: equality and hashing are the
/// string's, so refs drop straight into sets.
extension type const ImageRef._(String relativePath) {
/// A new, unique ref for an image being attached to [noteId].
factory ImageRef.create(String noteId) {
final ref = ImageRef.tryParse('$noteId/${const Uuid().v4()}.jpg');
if (ref == null) throw ArgumentError.value(noteId, 'noteId');
return ref;
}
/// Parses `<noteId>/<file>.jpg`, or null when it is not a safe image path.
static ImageRef? tryParse(String relativePath) =>
_refPath.hasMatch(relativePath) ? ImageRef._(relativePath) : null;
/// The note the image was attached to (it names the folder only).
String get noteId => relativePath.substring(0, relativePath.indexOf('/'));
/// `<uuid>.jpg`.
String get file => relativePath.substring(relativePath.indexOf('/') + 1);
/// The public dufs URL stored in the note.
String get url => '$kDufsBaseUrl/$kImagesDir/$relativePath';
/// The markdown line inserted into a note.
String get markdown => '![]($url)';
}
/// Every image [text] links to, in order, without duplicates.
Set<ImageRef> imageRefsIn(String text) => {
for (final match in _urlLink.allMatches(text))
ImageRef.tryParse(match.group(1)!)!,
};
/// The image when [line] is nothing but one image link, else null.
ImageRef? imageRefOfLine(String line) {
final match = _wholeLine.firstMatch(line);
return match == null ? null : ImageRef.tryParse(match.group(1)!);
}
/// Rewrites image links to their on-disk path under [kBacklogImagesRoot].
String toBacklogImagePaths(String text) => text.replaceAllMapped(
_urlLink,
(m) => m[0]!.replaceFirst('$kDufsBaseUrl/$kImagesDir', kBacklogImagesRoot),
);
/// Exact inverse of [toBacklogImagePaths], so an exported backlog imports
/// back with its links intact.
String fromBacklogImagePaths(String text) => text.replaceAllMapped(
_backlogLink,
(m) => '${m[1]}$kDufsBaseUrl/$kImagesDir/${m[2]}',
);

View File

@ -4,6 +4,8 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:todo/analytics/analytics_service.dart';
import 'package:todo/attachments/image_store.dart';
import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/repository_factory.dart';
@ -20,12 +22,15 @@ Future<void> main() async {
final repository = await openRepository();
final appSettings = ValueNotifier(await AppSettings.load());
final analytics = AnalyticsService(nodeId: repository.nodeId);
// App storage + direct dufs on Android; the wrapper's /images on desktop.
final imageStore = await openImageStore();
runApp(
TodoApp(
repository: repository,
appSettings: appSettings,
analytics: analytics,
imageStore: imageStore,
),
);
}
@ -39,6 +44,7 @@ class TodoApp extends StatelessWidget {
required this.repository,
required this.appSettings,
required this.analytics,
required this.imageStore,
super.key,
});
@ -53,9 +59,15 @@ class TodoApp extends StatelessWidget {
/// reports interaction events.
final AnalyticsService analytics;
/// The single, app-wide store for attached images.
final ImageStore imageStore;
@override
Widget build(BuildContext context) {
return MaterialApp(
// Above MaterialApp so pushed routes (note detail) see it too.
return ImageStoreScope(
store: imageStore,
child: MaterialApp(
title: 'todo',
theme: buildLightTheme(),
darkTheme: buildDarkTheme(),
@ -64,6 +76,7 @@ class TodoApp extends StatelessWidget {
appSettings: appSettings,
analytics: analytics,
),
),
);
}
}

View File

@ -5,7 +5,7 @@ import 'package:todo/sync/desktop_wrapper.dart';
/// Export still lands at the canonical `~/src/todo/BACKLOG.md` path the user's
/// tooling reads — the browser itself cannot write there.
Future<String> exportBacklog(String markdown, int noteCount) async {
final client = DesktopBackupClient(baseUrl: desktopWrapperOrigin);
final client = DesktopBackupClient(baseUrl: servingWrapperOrigin());
await client.writeBacklog(markdown);
return 'Exported $noteCount notes to ~/src/todo/BACKLOG.md';
}

View File

@ -9,3 +9,19 @@ const desktopWrapperPort = 8730;
/// Origin of the desktop wrapper, e.g. `http://localhost:8730`.
const desktopWrapperOrigin = 'http://localhost:$desktopWrapperPort';
/// Origin of the wrapper that served this page.
///
/// In the real app that is always [desktopWrapperOrigin]; a test wrapper on
/// another port then talks only to *itself*. With the hard-coded origin a
/// test page on :8731 would POST its backlog to the real wrapper on :8730 --
/// a CORS "simple request" the browser sends without a preflight -- and
/// overwrite the real `BACKLOG.md`. Off the web (unit tests: `file:` base)
/// it falls back to [desktopWrapperOrigin].
/// [pageUrl] defaults to the page's own URL; tests pass one.
String servingWrapperOrigin([Uri? pageUrl]) {
final base = pageUrl ?? Uri.base;
return base.scheme == 'http' || base.scheme == 'https'
? base.origin
: desktopWrapperOrigin;
}

View File

@ -10,7 +10,7 @@ import 'package:todo/sync/local_backup.dart';
/// tooling and the `todo` MCP server read it, and without this the move to a
/// web build would let it silently go stale.
LocalBackup createLocalBackup(NoteRepository repository) {
final client = DesktopBackupClient(baseUrl: desktopWrapperOrigin);
final client = DesktopBackupClient(baseUrl: servingWrapperOrigin());
return LocalBackup(
fetch: repository.listNotes,
// Recovery reads the same file back through the wrapper, so a cleared

View File

@ -1,4 +1,5 @@
import 'package:todo/data/note.dart';
import 'package:todo/images/image_ref.dart';
import 'package:uuid/uuid.dart';
/// Serialises notes to (and parses them back from) a single Markdown file.
@ -9,6 +10,11 @@ import 'package:uuid/uuid.dart';
/// lets [NoteRepository.importNotes] re-import a file as a *merge* (by id)
/// rather than creating duplicates — the basis for "never lose ideas"
/// recovery and round-tripping a backup.
///
/// Attached-image links are written as their on-disk path on kuhy's PC
/// (`~/data/cloud/todo-images/…`) so an agent reading `BACKLOG.md` can open
/// the picture directly; [parse] turns them back into the dufs URL, so a
/// backup still round-trips exactly.
class NotesMarkdown {
// Private ctor: this is a static-only utility class, never instantiated.
const NotesMarkdown._(); // coverage:ignore-line
@ -41,7 +47,7 @@ class NotesMarkdown {
'created="${note.createdAt.toIso8601String()}" '
'updated="${note.updatedAt.toIso8601String()}" -->',
)
..writeln(note.text)
..writeln(toBacklogImagePaths(note.text))
..writeln();
}
return buffer.toString();
@ -62,7 +68,9 @@ class NotesMarkdown {
final bodyEnd = i + 1 < markers.length
? markers[i + 1].start
: content.length;
final body = content.substring(bodyStart, bodyEnd).trim();
final body = fromBacklogImagePaths(
content.substring(bodyStart, bodyEnd).trim(),
);
final id = attrs['id'];
notes.add(

View File

@ -4,10 +4,13 @@
/// screen still owns every action; this just lays the row out.
library;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/frame_stats.dart';
import 'package:todo/ui/image_picking.dart';
/// App bar for the capture screen.
class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
@ -20,6 +23,8 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
required this.onSync,
required this.onOpenList,
required this.onOpenSettings,
required this.onAttachImages,
this.imagePicking,
super.key,
});
@ -44,6 +49,12 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
/// Opens the settings screen.
final VoidCallback onOpenSettings;
/// Receives images picked with the 📎 action.
final ValueChanged<List<Uint8List>> onAttachImages;
/// The picker behind 📎; null uses the platform one.
final ImagePicking? imagePicking;
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@ -68,6 +79,8 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
),
),
// coverage:ignore-end
// Not gated on advanced mode: attaching is basic capture.
AttachImageButton(onImages: onAttachImages, picking: imagePicking),
IconButton(
tooltip: 'New note',
onPressed: onNewNote,

View File

@ -34,6 +34,10 @@ class CaptureDraft {
String? _id;
DateTime? _createdAt;
/// Whether [_id] was handed out by [reserveId] and no write has used it
/// yet, so the first write still reports the note as created.
bool _reserved = false;
/// The draft's priority, applied to every write.
Priority priority = Priority.defaultValue;
@ -54,17 +58,33 @@ class CaptureDraft {
_id = _uuid.v4();
_createdAt = DateTime.now();
created = true;
} else if (_reserved) {
if (text.trim().isEmpty) return false;
_reserved = false;
created = true;
}
await _save(live: live);
return created;
}
/// The draft's note id, reserving one (without saving anything) when the
/// draft has none yet: an attached image is filed under its note's id
/// before the link that creates the note is inserted.
String reserveId() {
if (_id == null) {
_id = _uuid.v4();
_createdAt = DateTime.now();
_reserved = true;
}
return _id!;
}
/// Re-saves when only priority/status changed.
///
/// Does nothing before the row exists: the new value is already held here
/// and will be applied by the first keystroke that creates it.
Future<void> persistMetadata({required bool Function() live}) async {
if (_id == null) return;
if (_id == null || _reserved) return;
await _save(live: live);
}
@ -91,6 +111,7 @@ class CaptureDraft {
_text = '';
_id = null;
_createdAt = null;
_reserved = false;
priority = Priority.defaultValue;
status = Status.todo;
lastSavedAt.value = null;

View File

@ -1,10 +1,12 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:todo/analytics/analytics_event.dart';
import 'package:todo/analytics/analytics_service.dart';
import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
@ -17,10 +19,14 @@ import 'package:todo/ui/capture_draft.dart';
import 'package:todo/ui/capture_status.dart';
import 'package:todo/ui/capture_status_footer.dart';
import 'package:todo/ui/capture_sync_runner.dart';
import 'package:todo/ui/image_attach.dart';
import 'package:todo/ui/image_input.dart';
import 'package:todo/ui/image_picking.dart';
import 'package:todo/ui/note_form.dart';
import 'package:todo/ui/notes_list_screen.dart';
import 'package:todo/ui/settings_screen.dart';
part 'capture_screen_images.dart';
part 'capture_screen_sync.dart';
part 'capture_screen_widget.dart';
@ -59,6 +65,9 @@ class _CaptureScreenState extends State<CaptureScreen>
/// The note being captured, and its write-through to storage.
late final CaptureDraft _draft = CaptureDraft(widget.repository);
/// Inserts attached-image links into the mounted editor.
final ImageAttachController _attach = ImageAttachController();
final SyncStatusStore _syncStatus = SyncStatusStore();
/// Runs sync passes and records their outcome; see [CaptureSyncRunner].
@ -138,6 +147,8 @@ class _CaptureScreenState extends State<CaptureScreen>
onSync: _sync,
onOpenList: _openList,
onOpenSettings: _openSettings,
onAttachImages: _attachImages,
imagePicking: widget.imagePicking,
),
body: _captureBody(advanced: advanced),
);
@ -167,33 +178,11 @@ class _CaptureScreenState extends State<CaptureScreen>
// flicker doesn't fire a request per focus change.
_autoSyncDebounce?.cancel();
_autoSyncDebounce = Timer(CaptureScreen.autoSyncDebounce, _autoSync);
} else if (state == AppLifecycleState.resumed) {
unawaited(_reconcileImages());
}
}
/// Opens the settings screen and adopts any saved configuration.
Future<void> _openSettings() async {
_logEvent('screen_view', params: {'screen': 'settings'});
final current = _settings ?? await SyncSettings.load();
if (!mounted) return;
await Navigator.of(context).push<SyncSettings>(
MaterialPageRoute(
builder: (_) => SettingsScreen(
initial: current,
repository: widget.repository,
appSettings: widget.appSettings,
analytics: widget.analytics,
httpClient: widget.httpClient,
),
),
);
if (!mounted) return;
// Always reload from storage: a device-flow "Connect" saves the token
// without popping a result, so relying on the pop value would miss it and
// leave us syncing with stale (token-less) settings.
final fresh = await SyncSettings.load();
setState(() => _settings = fresh);
}
/// Runs a full sync, routing to settings first if not yet configured.
Future<void> _sync() async {
final settings = _settings ?? await SyncSettings.load();
@ -210,6 +199,7 @@ class _CaptureScreenState extends State<CaptureScreen>
live: () => mounted,
);
if (outcome.ok) _adoptReconciledSettings(outcome.reconciled);
unawaited(_reconcileImages());
_logEvent(
'sync_result',
params: {

View File

@ -0,0 +1,29 @@
/// The capture screen's image behaviour: attaching to the draft, and
/// reconciling the device's image store with every note.
///
/// A `part` of `capture_screen.dart` because it reads the private draft and
/// attach controller.
part of 'capture_screen.dart';
extension _CaptureImages on _CaptureScreenState {
/// Stores [images] under the draft's note (reserving its id) and inserts
/// their links at the cursor. [rejected] counts dropped non-images.
Future<void> _attachImages(List<Uint8List> images, {int rejected = 0}) =>
attachImages(
context,
noteId: _draft.reserveId,
target: _attach,
images: images,
rejected: rejected,
);
/// Uploads queued images, prefetches every image any note links to, and
/// evicts what no note links to. Runs after every sync attempt, on resume
/// and at launch (via the launch sync).
Future<void> _reconcileImages() async {
if (!mounted) return;
final store = ImageStoreScope.read(context);
final notes = await widget.repository.listNotes();
await store.reconcile([for (final note in notes) note.text]);
}
}

View File

@ -9,10 +9,18 @@
part of 'capture_screen.dart';
extension _CaptureSyncBehaviour on _CaptureScreenState {
/// Background sync, then an image reconcile over the notes it produced.
/// The reconcile runs even when sync is skipped (unconfigured, offline):
/// images have their own login and must still upload and prefetch.
Future<void> _autoSync() async {
await _autoSyncPass();
await _reconcileImages();
}
/// Best-effort background sync: no snackbar, skips when unconfigured, and
/// never overlaps itself. Failures are not swallowed silently — the outcome
/// lands in the status line so drift can't go unnoticed for days.
Future<void> _autoSync() async {
Future<void> _autoSyncPass() async {
final settings = _settings;
if (_autoSyncing || settings == null) return;
// Either backend counts. `isConfigured` means "has a GitHub token", so
@ -52,6 +60,30 @@ extension _CaptureSyncBehaviour on _CaptureScreenState {
widget.appSettings.value = widget.appSettings.value.adopt(reconciled);
}
/// Opens the settings screen and adopts any saved configuration.
Future<void> _openSettings() async {
_logEvent('screen_view', params: {'screen': 'settings'});
final current = _settings ?? await SyncSettings.load();
if (!mounted) return;
await Navigator.of(context).push<SyncSettings>(
MaterialPageRoute(
builder: (_) => SettingsScreen(
initial: current,
repository: widget.repository,
appSettings: widget.appSettings,
analytics: widget.analytics,
httpClient: widget.httpClient,
),
),
);
if (!mounted) return;
// Always reload from storage: a device-flow "Connect" saves the token
// without popping a result, so relying on the pop value would miss it and
// leave us syncing with stale (token-less) settings.
final fresh = await SyncSettings.load();
_rebuild(() => _settings = fresh);
}
void _openList() {
_logEvent('screen_view', params: {'screen': 'notes_list'});
Navigator.of(context).push<void>(
@ -95,8 +127,17 @@ extension _CaptureSyncBehaviour on _CaptureScreenState {
if (recovered.isNotEmpty) await widget.repository.importNotes(recovered);
}
/// The composer column: metadata row, editor, and status footer.
/// The composer column: metadata row, editor, and status footer. Images
/// pasted or dropped anywhere on the (desktop) page attach to the draft.
Widget _captureBody({required bool advanced}) {
return ImageInputListener(
onImages: (images, rejected) =>
unawaited(_attachImages(images, rejected: rejected)),
child: _composer(advanced: advanced),
);
}
Widget _composer({required bool advanced}) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
@ -120,6 +161,7 @@ extension _CaptureSyncBehaviour on _CaptureScreenState {
onStatusChanged: _setStatus,
autofocus: true,
onChanged: _onChanged,
attachController: _attach,
),
),
CaptureStatusFooter(

View File

@ -22,6 +22,7 @@ class CaptureScreen extends StatefulWidget {
this.localBackup,
this.firebaseFactory,
this.stateStore,
this.imagePicking,
super.key,
});
@ -55,6 +56,9 @@ class CaptureScreen extends StatefulWidget {
/// file-backed instance is created); tests pass a fake with in-memory IO.
final LocalBackup? localBackup;
/// The 📎 picker; null uses the platform one. Injected by tests.
final ImagePicking? imagePicking;
/// How long focus must stay lost before a background sync fires. Public so
/// tests can pump exactly past it.
static const autoSyncDebounce = Duration(seconds: 5);

View File

@ -0,0 +1,176 @@
/// Settings → Cloud images: the dufs login attached images upload with.
///
/// On the phone that is a password field (the `todo` login's password,
/// printed and put on the PC clipboard by `add_dufs_login.sh`) plus a Test
/// button. The desktop page has no field: its wrapper reads the login from
/// `~/.config/dufs/logins/todo.env`, so the screen only reports whether it
/// found one.
library;
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:todo/attachments/image_password_store.dart';
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/images/dufs_image_client.dart';
/// The command that creates the login, shown wherever it is missing.
const kAddLoginCommand =
'~/src/dufs-cloud/scripts/add_dufs_login.sh todo /todo-images rw';
/// The settings-list entry that opens [CloudImagesScreen].
class CloudImagesTile extends StatelessWidget {
/// Creates the tile; [httpClient] reaches the Test button (tests).
const CloudImagesTile({this.httpClient, super.key});
/// Injected HTTP client for the Test button.
final http.Client? httpClient;
@override
Widget build(BuildContext context) => ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Cloud images'),
subtitle: const Text('Where attached images upload (dufs)'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => CloudImagesScreen(httpClient: httpClient),
),
),
);
}
/// Password + status for image uploads.
class CloudImagesScreen extends StatefulWidget {
/// Creates the screen; everything injectable is for tests.
const CloudImagesScreen({
this.passwords = const ImagePasswordStore(),
this.httpClient,
this.showPasswordField = !kIsWeb,
super.key,
});
/// Where the phone keeps the password.
final ImagePasswordStore passwords;
/// Injected HTTP client for the Test button.
final http.Client? httpClient;
/// Whether this device enters the password itself (not the desktop page).
final bool showPasswordField;
@override
State<CloudImagesScreen> createState() => _CloudImagesScreenState();
}
class _CloudImagesScreenState extends State<CloudImagesScreen> {
final _password = TextEditingController();
ImageStoreStatus? _status;
String? _checkResult;
bool _checking = false;
@override
void initState() {
super.initState();
unawaited(_load());
}
@override
void dispose() {
_password.dispose();
super.dispose();
}
Future<void> _load() async {
final store = ImageStoreScope.read(context);
if (widget.showPasswordField) {
_password.text = await widget.passwords.read();
}
final status = await store.status();
if (mounted) setState(() => _status = status);
}
/// Saves the password, then checks it against dufs.
Future<void> _saveAndTest() async {
final password = _password.text.trim();
setState(() => _checking = true);
final saved = await widget.passwords.write(password);
var result = 'Could not save: the keystore is unavailable';
if (saved) {
final client = DufsImageClient(
DufsLogin(user: kDufsImagesUser, password: password),
httpClient: widget.httpClient,
);
final check = await client.check();
result = switch (check) {
DufsCheck.ok => 'Saved — dufs accepted the login',
DufsCheck.rejected => 'Saved, but dufs rejected the password',
DufsCheck.unreachable => 'Saved — dufs is unreachable right now',
};
// Upload what queued up while there was no password, now rather than
// on the next sync. No note texts: an empty ref set only uploads, it
// never evicts.
if (check == DufsCheck.ok && mounted) {
await ImageStoreScope.read(context).reconcile(const []);
}
}
if (!mounted) return;
setState(() {
_checking = false;
_checkResult = result;
});
await _load();
}
@override
Widget build(BuildContext context) {
final status = _status;
return Scaffold(
appBar: AppBar(title: const Text('Cloud images')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
if (widget.showPasswordField) ...[
TextField(
controller: _password,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Password for the "todo" dufs login',
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: _checking ? null : _saveAndTest,
child: const Text('Save & test'),
),
if (_checkResult case final result?) ...[
const SizedBox(height: 8),
Text(result),
],
const SizedBox(height: 16),
],
if (status != null) ...[
Text(
status.configured
? 'Login: configured'
: 'Login: missing — create it on the PC with',
),
if (!status.configured) const SelectableText(kAddLoginCommand),
const SizedBox(height: 8),
Text('Waiting to upload: ${status.pending}'),
],
const SizedBox(height: 16),
Text(
'Images are shrunk to 2560px JPEGs without location data before '
'they leave this device.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
}

81
lib/ui/image_attach.dart Normal file
View File

@ -0,0 +1,81 @@
/// Turning picked, pasted or dropped image bytes 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 every image source through [attachImages].
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/image_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 dropped 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 = <ImageRef>[];
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.markdown]);
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',
);
}
}

6
lib/ui/image_input.dart Normal file
View File

@ -0,0 +1,6 @@
/// [ImageInputListener] for the current platform: paste and drag-and-drop on
/// the desktop web build, a pass-through elsewhere.
library;
export 'image_input_io.dart'
if (dart.library.js_interop) 'image_input_web.dart';

View File

@ -0,0 +1,15 @@
/// Which of a paste's or drop's files are images: split out of the web-only
/// listener so the decision is unit-testable on the VM.
library;
/// Indices of the files whose MIME type is an image, and how many were not.
///
/// Order is preserved: files dropped together are attached in the order the
/// browser lists them.
({List<int> images, int rejected}) partitionImageFiles(List<String> mimeTypes) {
final images = <int>[
for (var i = 0; i < mimeTypes.length; i++)
if (mimeTypes[i].toLowerCase().startsWith('image/')) i,
];
return (images: images, rejected: mimeTypes.length - images.length);
}

View File

@ -0,0 +1,27 @@
import 'dart:typed_data';
import 'package:flutter/widgets.dart';
/// Receives pasted or dropped images and the count of non-image files.
typedef ImageInputCallback =
void Function(List<Uint8List> images, int rejected);
/// Off the web there is no document to paste or drop onto (Android attaches
/// through the 📎 button), so this is just [child].
class ImageInputListener extends StatelessWidget {
/// Wraps [child]; [onImages] is never called on this platform.
const ImageInputListener({
required this.onImages,
required this.child,
super.key,
});
/// Unused off the web.
final ImageInputCallback onImages;
/// The screen body.
final Widget child;
@override
Widget build(BuildContext context) => child;
}

103
lib/ui/image_input_web.dart Normal file
View File

@ -0,0 +1,103 @@
/// Ctrl+V and drag-and-drop of images onto the desktop (web) page.
///
/// Flutter web has no drop target of its own, so this listens on the DOM
/// `document` directly (`package:web`, no native plugin): `dragover` must be
/// cancelled for the page to accept a drop at all, and `drop` must be, or
/// Chrome navigates away to the dropped file. A paste is only taken over when
/// the clipboard carries files; plain-text pastes stay the text field's.
library;
import 'dart:async';
import 'dart:js_interop';
import 'dart:typed_data';
import 'package:flutter/widgets.dart';
import 'package:todo/ui/image_input_files.dart';
import 'package:web/web.dart' as web;
/// Receives pasted or dropped images and the count of non-image files.
typedef ImageInputCallback =
void Function(List<Uint8List> images, int rejected);
/// Calls [onImages] for images pasted or dropped anywhere on the page while
/// this widget's route is the visible one.
class ImageInputListener extends StatefulWidget {
/// Wraps [child], listening while mounted.
const ImageInputListener({
required this.onImages,
required this.child,
super.key,
});
/// Receives the image bytes in order, plus the non-image count.
final ImageInputCallback onImages;
/// The screen body.
final Widget child;
@override
State<ImageInputListener> createState() => _ImageInputListenerState();
}
class _ImageInputListenerState extends State<ImageInputListener> {
late final JSFunction _onDragOver = _dragOver.toJS;
late final JSFunction _onDrop = _drop.toJS;
late final JSFunction _onPaste = _paste.toJS;
@override
void initState() {
super.initState();
web.document
..addEventListener('dragover', _onDragOver)
..addEventListener('drop', _onDrop)
..addEventListener('paste', _onPaste);
}
@override
void dispose() {
web.document
..removeEventListener('dragover', _onDragOver)
..removeEventListener('drop', _onDrop)
..removeEventListener('paste', _onPaste);
super.dispose();
}
// The capture screen stays mounted under a pushed detail screen, and both
// listen on the same document: only the visible route may take the input,
// or one drop would attach to two notes.
bool get _active => mounted && (ModalRoute.of(context)?.isCurrent ?? true);
void _dragOver(web.DragEvent event) {
final types = event.dataTransfer?.types.toDart ?? const <JSString>[];
if (_active && types.any((t) => t.toDart == 'Files')) {
event.preventDefault();
}
}
void _drop(web.DragEvent event) {
final files = event.dataTransfer?.files;
if (!_active || files == null || files.length == 0) return;
event.preventDefault();
unawaited(_take(files));
}
void _paste(web.ClipboardEvent event) {
final files = event.clipboardData?.files;
if (!_active || files == null || files.length == 0) return;
event.preventDefault();
unawaited(_take(files));
}
Future<void> _take(web.FileList list) async {
final files = [for (var i = 0; i < list.length; i++) list.item(i)!];
final split = partitionImageFiles([for (final f in files) f.type]);
final images = <Uint8List>[
for (final i in split.images)
(await files[i].arrayBuffer().toDart).toDart.asUint8List(),
];
if (mounted) widget.onImages(images, split.rejected);
}
@override
Widget build(BuildContext context) => widget.child;
}

104
lib/ui/image_picking.dart Normal file
View File

@ -0,0 +1,104 @@
/// The 📎 button: pick images from the camera or gallery (Android) or from
/// disk (desktop), and hand their bytes on.
library;
import 'package:file_selector/file_selector.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
/// Where the phone takes an image from.
enum ImagePickSource {
/// A new photo.
camera,
/// Existing photos, several at once.
gallery,
}
/// Picks images and returns their bytes; empty when the user cancels.
///
/// Injectable so widget tests never reach a platform picker.
typedef ImagePicking = Future<List<Uint8List>> Function(BuildContext context);
/// The platform picker: a Camera/Gallery sheet on Android, a multi-select
/// file dialog filtered to images on the desktop (web) build.
///
/// [fromPhone] is the platform call behind the sheet, injectable for tests.
Future<List<Uint8List>> pickImages(
BuildContext context, {
Future<List<Uint8List>> Function(ImagePickSource) fromPhone = _pickFromPhone,
}) async {
// The desktop build has no sheet: straight to the file dialog.
if (kIsWeb) return await _pickFiles(); // coverage:ignore-line
final source = await showModalBottomSheet<ImagePickSource>(
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, ImagePickSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('Gallery'),
onTap: () => Navigator.pop(context, ImagePickSource.gallery),
),
],
),
),
);
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 bytes are covered.
Future<List<Uint8List>> _pickFromPhone(ImagePickSource source) async {
final picker = ImagePicker();
final files = switch (source) {
ImagePickSource.camera => [
?await picker.pickImage(source: ImageSource.camera),
],
ImagePickSource.gallery => await picker.pickMultiImage(),
};
return [for (final file in files) await file.readAsBytes()];
}
Future<List<Uint8List>> _pickFiles() async {
final files = await openFiles(
acceptedTypeGroups: const [
XTypeGroup(label: 'Images', mimeTypes: ['image/*']),
],
);
return [for (final file in files) await file.readAsBytes()];
}
// coverage:ignore-end
/// App-bar action that picks images and passes them to [onImages].
class AttachImageButton extends StatelessWidget {
/// Creates the button; [picking] defaults to [pickImages].
const AttachImageButton({required this.onImages, this.picking, super.key});
/// Receives the picked images (never called for a cancelled pick).
final ValueChanged<List<Uint8List>> onImages;
/// The picker, injectable for tests.
final ImagePicking? picking;
@override
Widget build(BuildContext context) {
return IconButton(
tooltip: 'Attach image',
icon: const Icon(Icons.attach_file),
onPressed: () async {
final images = await (picking ?? pickImages)(context);
if (images.isNotEmpty) onImages(images);
},
);
}
}

View File

@ -0,0 +1,115 @@
/// One attached image as it appears in a note: the picture, or a placeholder
/// saying why it is not here yet. Tapping opens it full screen.
library;
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/image_ref.dart';
import 'package:todo/ui/images/image_viewer_screen.dart';
import 'package:todo/ui/images/local_image.dart';
/// Placeholder text for an image this device does not have yet.
const kImageNotDownloaded = 'Not downloaded yet';
/// Tooltip on the badge of an image dufs has not accepted yet.
const kImageWaitingToUpload = 'Waiting to upload';
/// Renders [ref] from the nearest [ImageStoreScope].
class ImageEmbed extends StatelessWidget {
/// Creates an embed; [fit] is `cover` for thumbnails, `contain` inline.
const ImageEmbed({
required this.ref,
this.fit = BoxFit.cover,
super.key,
});
/// The image to show.
final ImageRef ref;
/// How the picture fills the embed's box.
final BoxFit fit;
@override
Widget build(BuildContext context) {
final store = ImageStoreScope.of(context);
final source = store.sourceFor(ref);
final picture = imageFor(source, fit: fit);
return Semantics(
label: 'Attached image',
button: source is! MissingImageSource,
child: GestureDetector(
onTap: source is MissingImageSource
? null
: () => Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => ImageViewerScreen(source: source),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
fit: StackFit.passthrough,
children: [
picture,
if (store.isPending(ref))
const Positioned(
right: 4,
top: 4,
child: Tooltip(
message: kImageWaitingToUpload,
child: Icon(Icons.cloud_upload_outlined, size: 18),
),
),
],
),
),
),
);
}
}
/// The picture for [source], falling back to the not-downloaded placeholder
/// (also when a network load fails: the wrapper 404s until it has the file).
Widget imageFor(ImageSource source, {required BoxFit fit}) {
Widget placeholder(BuildContext context, Object error, StackTrace? stack) =>
const ImagePlaceholder();
return switch (source) {
LocalImageSource(:final path) => localImage(
path,
fit: fit,
errorBuilder: placeholder,
),
NetworkImageSource(:final url) => Image.network(
url,
fit: fit,
errorBuilder: placeholder,
),
MissingImageSource() => const ImagePlaceholder(),
};
}
/// Grey box with [kImageNotDownloaded].
class ImagePlaceholder extends StatelessWidget {
/// Creates the placeholder.
const ImagePlaceholder({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ColoredBox(
color: theme.colorScheme.surfaceContainerHighest,
child: Center(
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
kImageNotDownloaded,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall,
),
),
),
);
}
}

View File

@ -0,0 +1,48 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/ui/images/image_embed.dart';
/// Height of the thumbnail row under the editor.
const kImageStripHeight = 88.0;
/// A row of thumbnails for every image [text] links to, shown under the
/// editing field so a note's pictures are visible while writing it. The
/// editing modes show the links as text; without this the default (Raw)
/// mode would never show an image at all. Empty when there are none.
class ImageStrip extends StatelessWidget {
/// Creates the strip for [text].
const ImageStrip({required this.text, super.key});
/// The note text being edited.
final String text;
@override
Widget build(BuildContext context) {
final refs = imageRefsIn(text).toList();
if (refs.isEmpty) return const SizedBox.shrink();
// Same centred column as the editor above it (rule 21), so on the wide
// desktop window the thumbnails sit under the text, not at the far edge.
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: kProseMaxWidth,
maxHeight: kImageStripHeight,
),
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: refs.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, i) => SizedBox(
width: kImageStripHeight,
child: ImageEmbed(ref: refs[i]),
),
),
),
),
);
}
}

View File

@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/ui/images/image_embed.dart';
/// One image, full screen, pinch/scroll to zoom.
class ImageViewerScreen extends StatelessWidget {
/// Creates the viewer for [source].
const ImageViewerScreen({required this.source, super.key});
/// Where the image is drawn from.
final ImageSource source;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: InteractiveViewer(
maxScale: 8,
child: Center(child: imageFor(source, fit: BoxFit.contain)),
),
);
}
}

View File

@ -0,0 +1,5 @@
/// `Image.file` without importing `dart:io` into web code.
library;
export 'local_image_io.dart'
if (dart.library.js_interop) 'local_image_web.dart';

View File

@ -0,0 +1,10 @@
import 'dart:io';
import 'package:flutter/widgets.dart';
/// A cached image file on this device.
Widget localImage(
String path, {
required BoxFit fit,
required ImageErrorWidgetBuilder errorBuilder,
}) => Image.file(File(path), fit: fit, errorBuilder: errorBuilder);

View File

@ -0,0 +1,8 @@
import 'package:flutter/widgets.dart';
/// Never reached on the web: the web store only returns network sources.
Widget localImage(
String path, {
required BoxFit fit,
required ImageErrorWidgetBuilder errorBuilder,
}) => const SizedBox.shrink();

View File

@ -1,11 +1,18 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/ui/images/image_embed.dart';
/// Tallest an image is drawn inside a rendered note.
const kInlineImageHeight = 320.0;
/// A lightweight, read-only renderer for the note format.
///
/// The app's notes use a small, known Markdown subset — an `#` title, `##`
/// section headings, italic `_guidance_` lines, `-` bullet lists and plain
/// paragraphs — so a tailored line-based renderer covers everything we emit
/// paragraphs, plus one attached image per line (see `image_ref.dart`) — so a
/// tailored line-based renderer covers everything we emit
/// without pulling in a full Markdown engine. It keeps the quick-capture app
/// lean and is trivial to test.
///
@ -30,6 +37,18 @@ class MarkdownView extends StatelessWidget {
widgets.add(const SizedBox(height: 8));
continue;
}
if (imageRefOfLine(trimmed) case final image?) {
widgets.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: kInlineImageHeight),
child: ImageEmbed(ref: image, fit: BoxFit.contain),
),
),
);
continue;
}
if (trimmed.startsWith('## ')) {
widgets.add(
Padding(

View File

@ -1,9 +1,15 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/note_template.dart';
import 'package:todo/ui/image_attach.dart';
import 'package:todo/ui/image_input.dart';
import 'package:todo/ui/image_picking.dart';
import 'package:todo/ui/note_form.dart';
/// Full-screen view of a single note: read it in full, edit its body through
@ -18,6 +24,7 @@ class NoteDetailScreen extends StatefulWidget {
required this.note,
required this.repository,
required this.appSettings,
this.imagePicking,
super.key,
});
@ -32,6 +39,9 @@ class NoteDetailScreen extends StatefulWidget {
/// exactly what made editing look different from capture.
final ValueNotifier<AppSettings> appSettings;
/// The 📎 picker; null uses the platform one. Injected by tests.
final ImagePicking? imagePicking;
@override
State<NoteDetailScreen> createState() => _NoteDetailScreenState();
}
@ -39,6 +49,18 @@ class NoteDetailScreen extends StatefulWidget {
class _NoteDetailScreenState extends State<NoteDetailScreen> {
late Note _note = widget.note;
/// Inserts attached-image links into the editor.
final ImageAttachController _attach = ImageAttachController();
Future<void> _attachImages(List<Uint8List> images, {int rejected = 0}) =>
attachImages(
context,
noteId: () => _note.id,
target: _attach,
images: images,
rejected: rejected,
);
Future<void> _persist(Note next) async {
setState(() => _note = next);
await widget.repository.upsert(next);
@ -65,6 +87,10 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
appBar: AppBar(
title: Text(title.isEmpty ? '(empty)' : title),
actions: [
AttachImageButton(
onImages: _attachImages,
picking: widget.imagePicking,
),
IconButton(
tooltip: 'Delete note',
icon: const Icon(Icons.delete_outline),
@ -72,7 +98,10 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
),
],
),
body: Padding(
body: ImageInputListener(
onImages: (images, rejected) =>
unawaited(_attachImages(images, rejected: rejected)),
child: Padding(
padding: const EdgeInsets.all(16),
// The same form capture renders, gated on the same setting, so an
// edited note and a fresh one cannot look different.
@ -86,9 +115,12 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
onPriorityChanged: (p) => _persist(
_note.copyWith(priority: p, updatedAt: DateTime.now()),
),
onStatusChanged: (s) =>
_persist(_note.copyWith(status: s, updatedAt: DateTime.now())),
onStatusChanged: (s) => _persist(
_note.copyWith(status: s, updatedAt: DateTime.now()),
),
onChanged: _onTextChanged,
attachController: _attach,
),
),
),
),

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.dart';
import 'package:todo/ui/image_attach.dart';
import 'package:todo/ui/markdown_view.dart';
import 'package:todo/ui/note_editor_chrome.dart';
import 'package:todo/ui/note_editor_document.dart';
@ -13,6 +14,7 @@ import 'package:todo/ui/note_editor_wizard.dart';
export 'package:todo/ui/note_editor_mode.dart';
part 'note_editor_images.dart';
part 'note_editor_views.dart';
part 'note_editor_widget.dart';
@ -31,6 +33,9 @@ class _NoteEditorState extends State<NoteEditor> {
/// [_retemplateDraft] from overriding that choice as they keep typing.
bool _templatePickedByUser = false;
/// Detaches this editor from [NoteEditor.attachController].
VoidCallback? _unbindImages;
@override
void initState() {
super.initState();
@ -40,10 +45,20 @@ class _NoteEditorState extends State<NoteEditor> {
openingRaw: widget.initialMode == NoteEditorMode.raw,
);
_mode = _resolveMode(widget.initialMode);
_unbindImages = widget.attachController?.bind(_insertImageLinks);
}
@override
void didUpdateWidget(NoteEditor oldWidget) {
super.didUpdateWidget(oldWidget);
if (identical(oldWidget.attachController, widget.attachController)) return;
_unbindImages?.call();
_unbindImages = widget.attachController?.bind(_insertImageLinks);
}
@override
void dispose() {
_unbindImages?.call();
_doc.dispose();
super.dispose();
}
@ -140,6 +155,7 @@ class _NoteEditorState extends State<NoteEditor> {
widget.onChromeVisibleChanged(
!_enteringGuided && _mode != NoteEditorMode.guided,
);
widget.onModeChanged?.call(_mode);
}
/// Commits the wizard's template choice and enters the bare stepper.

View File

@ -0,0 +1,55 @@
/// Inserting attached-image links into whichever field is being edited.
///
/// A `part` of `note_editor.dart` because it writes the editor's private
/// controllers (`_doc`) and follows its mode (`_mode`, `_currentStep`).
part of 'note_editor.dart';
extension _NoteEditorImages on _NoteEditorState {
/// Puts [lines] on their own lines at the cursor of the field in view:
/// the current step's field in Guided, the body in Raw. With no visible
/// cursor (View mode, or a field never focused) they go at the end.
void _insertImageLinks(List<String> lines) {
final TextEditingController target;
var atEnd = _mode == NoteEditorMode.preview;
if (_mode == NoteEditorMode.guided && !_doc.rawSource) {
_doc.ensureControllers(_doc.template);
final sections = _doc.template.sections;
target = _doc
.section[sections[_currentStep.clamp(0, sections.length - 1)].key]!;
} else if (_doc.rawSource) {
target = _doc.body;
} else {
// View mode over guided sections: the last section is the end.
_doc.ensureControllers(_doc.template);
target = _doc.section[_doc.template.sections.last.key]!;
atEnd = true;
}
_insertBlock(target, lines.join('\n'), atEnd: atEnd);
_retemplateDraft();
_onStepFieldChanged();
}
}
/// Replaces [controller]'s selection (or appends, when [atEnd] or there is
/// no selection) with [block], padded with newlines so it sits on its own
/// lines, and leaves the cursor just after it.
void _insertBlock(
TextEditingController controller,
String block, {
required bool atEnd,
}) {
final text = controller.text;
final selection = controller.selection;
final useCursor = !atEnd && selection.isValid;
final start = useCursor ? selection.start : text.length;
final end = useCursor ? selection.end : text.length;
final before = text.substring(0, start);
final after = text.substring(end);
final lead = before.isEmpty || before.endsWith('\n') ? '' : '\n';
final trail = after.startsWith('\n') ? '' : '\n';
final inserted = '$lead$block$trail';
controller.value = TextEditingValue(
text: '$before$inserted$after',
selection: TextSelection.collapsed(offset: before.length + inserted.length),
);
}

View File

@ -41,6 +41,8 @@ class NoteEditor extends StatefulWidget {
this.initialMode = NoteEditorMode.guided,
this.autofocus = false,
this.advancedMode = true,
this.attachController,
this.onModeChanged,
super.key,
});
@ -85,6 +87,14 @@ class NoteEditor extends StatefulWidget {
/// in the gating.
final bool advancedMode;
/// Bound to this editor's insert-at-cursor while it is mounted, so the
/// screen can drop attached-image links into the field being edited.
final ImageAttachController? attachController;
/// Called with the displayed mode after each mode switch (the thumbnail
/// strip hides in View mode, which renders images inline).
final ValueChanged<NoteEditorMode>? onModeChanged;
@override
State<NoteEditor> createState() => _NoteEditorState();
}

View File

@ -12,12 +12,15 @@ import 'package:flutter/material.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.dart';
import 'package:todo/ui/image_attach.dart';
import 'package:todo/ui/images/image_strip.dart';
import 'package:todo/ui/note_editor.dart';
import 'package:todo/ui/note_form_chrome_gate.dart';
import 'package:todo/ui/note_meta_row.dart';
/// The metadata row above a [NoteEditor], gated on advanced mode.
class NoteForm extends StatelessWidget {
/// The metadata row above a [NoteEditor], gated on advanced mode, and the
/// attached-image thumbnails below it.
class NoteForm extends StatefulWidget {
/// Creates the form for the given note values.
const NoteForm({
required this.advancedMode,
@ -29,6 +32,7 @@ class NoteForm extends StatelessWidget {
this.initialText = '',
this.initialTemplate,
this.autofocus = false,
this.attachController,
super.key,
});
@ -60,10 +64,34 @@ class NoteForm extends StatelessWidget {
/// Autofocus the editor, so a fresh capture needs zero taps before typing.
final bool autofocus;
/// Where the screen's attach actions insert image links.
final ImageAttachController? attachController;
@override
State<NoteForm> createState() => _NoteFormState();
}
class _NoteFormState extends State<NoteForm> {
// The live text, for the thumbnail strip only: a notifier so a keystroke
// rebuilds the strip, not the editor.
late final ValueNotifier<String> _text = ValueNotifier(widget.initialText);
bool _previewing = false;
@override
void dispose() {
_text.dispose();
super.dispose();
}
void _onChanged(String text) {
_text.value = text;
widget.onChanged(text);
}
@override
Widget build(BuildContext context) {
return NoteFormChromeGate(
advancedMode: advancedMode,
advancedMode: widget.advancedMode,
builder:
(context, {required chromeVisible, required onChromeVisibleChanged}) {
return Column(
@ -71,26 +99,38 @@ class NoteForm extends StatelessWidget {
children: [
if (chromeVisible) ...[
NoteMetaRow(
priority: priority,
status: status,
onPriorityChanged: onPriorityChanged,
onStatusChanged: onStatusChanged,
priority: widget.priority,
status: widget.status,
onPriorityChanged: widget.onPriorityChanged,
onStatusChanged: widget.onStatusChanged,
),
const SizedBox(height: 12),
],
Expanded(
child: NoteEditor(
initialText: initialText,
initialTemplate: initialTemplate,
initialText: widget.initialText,
initialTemplate: widget.initialTemplate,
initialMode: NoteEditorMode.raw,
advancedMode: advancedMode,
priority: priority,
onPriorityChanged: onPriorityChanged,
advancedMode: widget.advancedMode,
priority: widget.priority,
onPriorityChanged: widget.onPriorityChanged,
onChromeVisibleChanged: onChromeVisibleChanged,
autofocus: autofocus,
onChanged: onChanged,
autofocus: widget.autofocus,
onChanged: _onChanged,
attachController: widget.attachController,
onModeChanged: (mode) => setState(
() => _previewing = mode == NoteEditorMode.preview,
),
),
),
// A sibling of the editor's Expanded, not nested inside it:
// an extra Column around the expanding field breaks its
// height in release builds (see NoteEditorRawField).
if (!_previewing)
ValueListenableBuilder<String>(
valueListenable: _text,
builder: (context, text, _) => ImageStrip(text: text),
),
],
);
},

View File

@ -14,6 +14,7 @@ import 'package:todo/sync/firebase_backend.dart';
import 'package:todo/sync/google_sign_in_backend.dart';
import 'package:todo/sync/notes_markdown.dart';
import 'package:todo/sync/sync_settings.dart';
import 'package:todo/ui/cloud_images_settings.dart';
import 'package:todo/ui/github_mirror_screen.dart';
/// Settings screen: the "Enable advanced" toggle plus links to the two sync
@ -177,11 +178,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
googleFirebaseFactory:
widget.googleFirebaseFactory ?? openFirebaseWithGoogle,
googleAvailable: widget.googleAvailable ?? googleSignInSupported,
backup: BackupSlot(
label: 'notes',
export: _export,
import: _import,
),
backup: BackupSlot(label: 'notes', export: _export, import: _import),
),
),
);
@ -231,6 +228,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Icons.chevron_right),
onTap: () => unawaited(_openSyncSettings()),
),
CloudImagesTile(httpClient: widget.httpClient),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Advanced sync (GitHub)'),

View File

@ -264,14 +264,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.14.4"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
url: "https://pub.dev"
source: hosted
version: "2.0.35"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: d87713a152ee2f255117bdbbf43da1dea1797e0551e499e0334f0c9dcfafddd2
sha256: d4e1fb6b2cb524868929e78dc0282fa000554b22060fb53789dc481c9fc95bb8
url: "https://pub.dev"
source: hosted
version: "11.1.1"
version: "11.2.0"
flutter_secure_storage_darwin:
dependency: transitive
description:
@ -420,13 +428,77 @@ packages:
source: hosted
version: "2.9.9"
image:
dependency: transitive
dependency: "direct main"
description:
name: image
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89
url: "https://pub.dev"
source: hosted
version: "4.9.1"
version: "4.10.1"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667
url: "https://pub.dev"
source: hosted
version: "1.2.3"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: "0a55d645a670d6ae11efa948f955ae904afa472f1344fc15a7ddacc20c1e7219"
url: "https://pub.dev"
source: hosted
version: "0.8.13+23"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: "852a7762962f3bb3ad54ef1d342669e41557f466033a676baa8876b367cf4050"
url: "https://pub.dev"
source: hosted
version: "0.8.13+8"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
url: "https://pub.dev"
source: hosted
version: "0.2.2+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
url: "https://pub.dev"
source: hosted
version: "0.2.2"
jni:
dependency: transitive
description:
@ -595,14 +667,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
@ -970,7 +1034,7 @@ packages:
source: hosted
version: "15.2.0"
web:
dependency: transitive
dependency: "direct main"
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
@ -993,14 +1057,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
yaml:
dependency: transitive
description:

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.36+1
version: 1.0.37+1
environment:
sdk: ^3.12.2
@ -53,7 +53,7 @@ dependencies:
# Stores the GitHub sync token in the OS keystore (Android Keystore /
# libsecret) instead of plaintext SharedPreferences.
flutter_secure_storage: 11.1.1
flutter_secure_storage: 11.2.0
github_device_auth:
git:
@ -66,6 +66,11 @@ dependencies:
google_sign_in: 7.2.0
http: 1.6.0
idb_shim: 2.9.9
# Re-encodes attached images (resize, EXIF strip) in pure Dart, so the
# phone and the desktop wrapper share one code path.
image: 4.10.1
# Camera + gallery picks for image attachments on Android.
image_picker: 1.2.3
path: 1.9.1
path_provider: 2.1.6
share_plus: 13.3.0
@ -86,6 +91,8 @@ dependencies:
path: sync_settings_ui
url_launcher: 6.3.2
uuid: 4.6.0
# Paste and drag-and-drop listeners on the desktop (web) page.
web: 1.1.1
dev_dependencies:
# Virtual clock for unit-testing debounced timers (e.g. LocalBackup).
fake_async: 1.3.3

View File

@ -0,0 +1,27 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/attachments/image_password_store.dart';
import '../fake_secure_storage.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const store = ImagePasswordStore();
test('write, read, login and delete', () async {
installFakeSecureStorage();
expect(await store.read(), '');
expect(await store.login(), isNull);
expect(await store.write('pw'), isTrue);
expect(await store.read(), 'pw');
final login = (await store.login())!;
expect((login.user, login.password), (kDufsImagesUser, 'pw'));
expect(await store.write(''), isTrue);
expect(await store.read(), '');
});
test('a missing keystore reads as no password and refuses writes', () async {
installFakeSecureStorage(throwing: true);
expect(await store.read(), '');
expect(await store.write('pw'), isFalse);
});
}

View File

@ -0,0 +1,70 @@
import 'dart:typed_data';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/images/image_ref.dart';
void main() {
final ref = ImageRef.create('n1');
test('NoImageStore refuses to attach and has nothing', () async {
final store = NoImageStore();
await expectLater(
store.attach('n1', Uint8List(1)),
throwsA(isA<ImageStoreException>()),
);
await store.reconcile(['x']);
expect(store.sourceFor(ref), isA<MissingImageSource>());
expect(store.isPending(ref), isFalse);
final status = await store.status();
expect((status.configured, status.pending), (false, 0));
});
test('ImageStoreException shows its message', () {
expect(const ImageStoreException('boom').toString(), 'boom');
});
test('imageRefsAcross unions every note', () {
final other = ImageRef.create('n2');
expect(
imageRefsAcross([ref.markdown, '${other.markdown}\n${ref.markdown}']),
{ref, other},
);
});
testWidgets('the scope provides its store, else a NoImageStore', (
tester,
) async {
final store = NoImageStore();
late ImageStore found;
late ImageStore read;
late ImageStore fallback;
await tester.pumpWidget(
Column(
children: [
ImageStoreScope(
store: store,
child: Builder(
builder: (context) {
found = ImageStoreScope.of(context);
read = ImageStoreScope.read(context);
return const SizedBox();
},
),
),
Builder(
builder: (context) {
fallback = ImageStoreScope.of(context);
return const SizedBox();
},
),
],
),
);
expect(found, same(store));
expect(read, same(store));
expect(fallback, isA<NoImageStore>());
});
}

View File

@ -0,0 +1,107 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/attachments/image_password_store.dart';
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_io.dart';
import 'package:todo/images/image_cache_store.dart';
import '../fake_secure_storage.dart';
import '../images/fake_dufs.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory root;
late FakeDufs dufs;
late IoImageStore store;
late int notified;
IoImageStore make({Future<Uint8List?> Function(Uint8List)? prepare}) {
final s = IoImageStore(
ImageCacheStore(root),
httpClient: dufs.httpClient,
prepare: prepare ?? (b) async => b.first == 0xFF ? b : null,
);
notified = 0;
s.addListener(() => notified++);
return s;
}
setUp(() {
root = Directory.systemTemp.createTempSync('io_store');
dufs = FakeDufs();
installFakeSecureStorage(initial: {'dufsImagesPassword': 'pw'});
store = make();
});
tearDown(() => root.deleteSync(recursive: true));
test('attach stores, notifies and uploads straight away', () async {
final ref = (await store.attach('n1', Uint8List.fromList([0xFF, 1])))!;
expect(ref.noteId, 'n1');
expect(store.sourceFor(ref), isA<LocalImageSource>());
expect(notified, greaterThanOrEqualTo(1));
await pumpEventQueue();
expect(dufs.files[ref.relativePath], [0xFF, 1]);
expect(store.isPending(ref), isFalse);
});
test('attach returns null for a non-image and stores nothing', () async {
expect(await store.attach('n1', Uint8List.fromList([1])), isNull);
expect(root.listSync(), isEmpty);
});
test('attach fails loudly when the image cannot be written', () async {
root.deleteSync(recursive: true);
File(root.path).writeAsStringSync('not a dir');
await expectLater(
store.attach('n1', Uint8List.fromList([0xFF])),
throwsA(isA<ImageStoreException>()),
);
File(root.path).deleteSync();
root.createSync();
});
test('without a password attach queues offline', () async {
installFakeSecureStorage();
final ref = (await store.attach('n1', Uint8List.fromList([0xFF])))!;
await pumpEventQueue();
expect(dufs.requests, isEmpty);
expect(store.isPending(ref), isTrue);
expect((await store.status()).configured, isFalse);
expect((await store.status()).pending, 1);
});
test('reconcile prefetches what notes link to and notifies', () async {
dufs.files['n2/b.jpg'] = [7];
final before = notified;
await store.reconcile([
'x\n![](https://kuhy-cloud.duckdns.org/todo-images/n2/b.jpg)',
]);
expect(notified, before + 1);
final status = await store.status();
expect((status.configured, status.pending), (true, 0));
});
test('a reconcile with nothing to do does not notify', () async {
await store.reconcile(['no images']);
expect(notified, 0);
});
test('a missing image has a missing source', () {
final ref = imageRefsAcross([
'![](https://kuhy-cloud.duckdns.org/todo-images/n9/z.jpg)',
]).single;
expect(store.sourceFor(ref), isA<MissingImageSource>());
});
test('the default prepare and client are the real ones', () {
expect(
IoImageStore(
ImageCacheStore(root),
passwords: const ImagePasswordStore(),
),
isA<ImageStore>(),
);
});
}

View File

@ -0,0 +1,119 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_web.dart';
import 'package:todo/images/image_ref.dart';
void main() {
final requests = <http.Request>[];
late int notified;
WebImageStore make(MockClientHandler handler) {
requests.clear();
final store = WebImageStore(
origin: 'http://localhost:8731',
httpClient: MockClient((r) {
requests.add(r);
return handler(r);
}),
);
notified = 0;
store.addListener(() => notified++);
return store;
}
http.Response report({int downloaded = 0}) =>
http.Response(jsonEncode({'downloaded': downloaded}), 200);
test('openImageStore targets the serving wrapper', () async {
final store = await openImageStore() as WebImageStore;
expect(store.origin, 'http://localhost:8730');
});
test('attach POSTs the raw bytes and kicks an upload', () async {
final store = make(
(r) async =>
r.url.path.endsWith('reconcile') ? report() : http.Response('', 204),
);
final ref = (await store.attach('n1', Uint8List.fromList([1, 2])))!;
await pumpEventQueue();
expect(requests.first.url.path, '/images/${ref.relativePath}');
expect(requests.first.bodyBytes, [1, 2]);
expect(requests.last.url.path, '/images/reconcile');
expect(jsonDecode(requests.last.body), {'refs': <String>[]});
expect(notified, 1);
expect(store.sourceFor(ref), isA<NetworkImageSource>());
expect(
(store.sourceFor(ref) as NetworkImageSource).url,
'http://localhost:8731/images/${ref.relativePath}?r=1',
);
expect(store.isPending(ref), isFalse);
});
test(
'attach: 415 is not an image, other codes and no wrapper throw',
() async {
expect(
await make(
(_) async => http.Response('', 415),
).attach('n1', Uint8List(1)),
isNull,
);
await expectLater(
make((_) async => http.Response('', 500)).attach('n1', Uint8List(1)),
throwsA(isA<ImageStoreException>()),
);
await expectLater(
make(
(_) => throw const SocketException('no wrapper'),
).attach('n1', Uint8List(1)),
throwsA(
isA<ImageStoreException>().having(
(e) => e.toString(),
'message',
contains('not running'),
),
),
);
},
);
test('reconcile sends every linked ref; a download bumps the url', () async {
final ref = ImageRef.create('n1');
final store = make((_) async => report(downloaded: 1));
await store.reconcile(['a ${ref.markdown}', 'none']);
expect(jsonDecode(requests.single.body), {
'refs': [ref.relativePath],
});
expect(notified, 1);
expect((store.sourceFor(ref) as NetworkImageSource).url, endsWith('r=1'));
});
test('reconcile ignores failures and no-op reports', () async {
await make((_) async => report()).reconcile([]);
expect(notified, 0);
await make((_) async => http.Response('', 500)).reconcile([]);
await make((_) => throw const SocketException('x')).reconcile([]);
expect(notified, 0);
});
test('status reads the wrapper, and degrades when it is gone', () async {
final ok = await make(
(_) async => http.Response('{"configured":true,"pending":2}', 200),
).status();
expect((ok.configured, ok.pending), (true, 2));
final empty = await make((_) async => http.Response('{}', 200)).status();
expect((empty.configured, empty.pending), (false, 0));
final gone = await make((_) => throw const SocketException('x')).status();
expect(gone.configured, isFalse);
});
test('the default http client is created', () {
expect(WebImageStore(origin: 'http://x'), isA<ImageStore>());
});
}

View File

@ -17,6 +17,8 @@ import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:todo/analytics/analytics_service.dart';
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.dart';
@ -24,6 +26,7 @@ import 'package:todo/sync/local_backup.dart';
import 'package:todo/sync/notes_markdown.dart';
import 'package:todo/ui/capture_screen.dart';
import 'package:todo/ui/github_mirror_screen.dart';
import 'package:todo/ui/image_picking.dart';
import 'package:todo/ui/settings_screen.dart';
import 'fake_note_repository.dart';
@ -46,6 +49,8 @@ Future<FakeNoteRepository> pumpCapture(
// behavior pass advancedMode: false explicitly.
ValueNotifier<AppSettings>? appSettings,
AnalyticsService? analytics,
ImageStore? imageStore,
ImagePicking? imagePicking,
}) async {
SharedPreferences.setMockInitialValues(prefs);
installFakeSecureStorage();
@ -67,8 +72,7 @@ Future<FakeNoteRepository> pumpCapture(
writer: (_) async {},
debounce: Duration.zero,
);
await tester.pumpWidget(
MaterialApp(
final app = MaterialApp(
home: CaptureScreen(
repository: repo,
appSettings:
@ -81,8 +85,11 @@ Future<FakeNoteRepository> pumpCapture(
firebaseFactory: firebaseFactory ?? () async => null,
stateStore: InMemorySyncStateStore(),
localBackup: backup,
imagePicking: imagePicking,
),
),
);
await tester.pumpWidget(
imageStore == null ? app : ImageStoreScope(store: imageStore, child: app),
);
await tester.pump(); // flush initial stream + settings load
return repo;

View File

@ -0,0 +1,45 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:todo/desktop/dufs_login_env.dart';
void main() {
late Directory dir;
setUp(() => dir = Directory.systemTemp.createTempSync('dufs_env'));
tearDown(() => dir.deleteSync(recursive: true));
File env(String body) =>
File(p.join(dir.path, 'todo.env'))..writeAsStringSync(body);
test('default path is ~/.config/dufs/logins/todo.env', () {
expect(defaultDufsLoginEnvPath('/h'), '/h/.config/dufs/logins/todo.env');
});
test('reads the login add_dufs_login.sh writes', () {
final login = readDufsLoginEnv(
env(
'# written by add_dufs_login.sh\n'
'DUFS_URL=https://kuhy-cloud.duckdns.org/\n'
'DUFS_USER=todo\nDUFS_PASSWORD=a=b\nDUFS_PATH=/todo-images\nnoise\n',
),
)!;
expect(login.user, 'todo');
expect(login.password, 'a=b', reason: 'only the first = splits');
expect(login.baseUrl, 'https://kuhy-cloud.duckdns.org');
});
test('keeps a url without a trailing slash as is', () {
final login = readDufsLoginEnv(
env('DUFS_URL=http://x\nDUFS_USER=u\nDUFS_PASSWORD=p\n'),
)!;
expect(login.baseUrl, 'http://x');
});
test('absent or incomplete files give no login', () {
expect(readDufsLoginEnv(File(p.join(dir.path, 'nope.env'))), isNull);
expect(readDufsLoginEnv(env('DUFS_USER=u\nDUFS_PASSWORD=p\n')), isNull);
expect(readDufsLoginEnv(env('DUFS_URL=x\nDUFS_USER=u\n')), isNull);
expect(readDufsLoginEnv(env('DUFS_URL=x\nDUFS_PASSWORD=p\n')), isNull);
});
}

View File

@ -0,0 +1,164 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:todo/desktop/wrapper_image_routes.dart';
import 'package:todo/desktop/wrapper_server.dart';
import 'package:todo/images/image_cache_store.dart';
import 'package:todo/images/image_ref.dart';
import '../images/fake_dufs.dart';
void main() {
late Directory root;
late WrapperServer server;
late String base;
late FakeDufs dufs;
late ImageCacheStore store;
const rel = 'n1/0815ff8a-203e-4f54-9cc5-8a58b27982ec.jpg';
File envFile() => File(p.join(root.path, 'dufs', 'todo.env'));
void writeLogin() {
envFile()
..parent.createSync(recursive: true)
..writeAsStringSync(
'DUFS_URL=https://d\nDUFS_USER=todo\nDUFS_PASSWORD=pw\n',
);
}
setUp(() async {
root = Directory.systemTemp.createTempSync('wrapper_images');
dufs = FakeDufs();
store = ImageCacheStore(Directory(p.join(root.path, 'images')));
server = WrapperServer(
webRoot: root.path,
backlogPath: p.join(root.path, 'BACKLOG.md'),
logPath: p.join(root.path, 'log.json'),
todoCredentialsPath: p.join(root.path, 'none.json'),
images: WrapperImageRoutes(
store: store,
loginEnvPath: envFile().path,
httpClient: dufs.httpClient,
// Identity "re-encode" that still refuses non-images, so the
// routes are tested without decoding real photos.
prepare: (bytes) async => bytes.first == 0xFF ? bytes : null,
),
);
await server.start(0);
base = 'http://localhost:${server.port}';
});
tearDown(() async {
await server.stop();
root.deleteSync(recursive: true);
});
Future<http.Response> reconcile(Object body) =>
http.post(Uri.parse('$base/images/reconcile'), body: jsonEncode(body));
test('the default routes live under the given home', () {
final routes = defaultWrapperImageRoutes('/h');
expect(routes.store.root.path, '/h/.local/share/todo-desktop/images');
expect(routes.loginEnvPath, '/h/.config/dufs/logins/todo.env');
});
test('POST stores and queues an image, GET serves it', () async {
final post = await http.post(
Uri.parse('$base/images/$rel'),
body: Uint8List.fromList([0xFF, 1, 2]),
);
expect(post.statusCode, 204);
expect(store.isPending(ImageRef.tryParse(rel)!), isTrue);
final get = await http.get(Uri.parse('$base/images/$rel'));
expect(get.statusCode, 200);
expect(get.headers['content-type'], 'image/jpeg');
expect(get.headers['cache-control'], contains('max-age'));
expect(get.bodyBytes, [0xFF, 1, 2]);
});
test('non-images are refused with 415 and nothing is stored', () async {
final post = await http.post(
Uri.parse('$base/images/$rel'),
body: Uint8List.fromList([1, 2]),
);
expect(post.statusCode, 415);
expect(store.pending(), isEmpty);
});
test('oversized uploads are refused with 413', () async {
final big = Uint8List(kMaxImageUploadBytes + 1)..[0] = 0xFF;
final post = await http.post(Uri.parse('$base/images/$rel'), body: big);
expect(post.statusCode, 413);
});
test('unknown image, bad path and bad method', () async {
expect((await http.get(Uri.parse('$base/images/$rel'))).statusCode, 404);
expect(
(await http.get(Uri.parse('$base/images/n1/x.png'))).statusCode,
404,
);
expect((await http.delete(Uri.parse('$base/images/$rel'))).statusCode, 405);
expect(
(await http.get(Uri.parse('$base/images/reconcile'))).statusCode,
404,
);
});
test('status reports login and queue', () async {
Future<Map<String, dynamic>> status() async =>
jsonDecode((await http.get(Uri.parse('$base/images/status'))).body)
as Map<String, dynamic>;
expect(await status(), {'configured': false, 'pending': 0});
writeLogin();
await http.post(
Uri.parse('$base/images/$rel'),
body: Uint8List.fromList([0xFF]),
);
expect(await status(), {'configured': true, 'pending': 1});
});
test('reconcile uploads with the env login and prefetches', () async {
writeLogin();
await http.post(
Uri.parse('$base/images/$rel'),
body: Uint8List.fromList([0xFF, 9]),
);
dufs.files['n2/b.jpg'] = [5];
final response = await reconcile({
'refs': [rel, 'n2/b.jpg', '../bad.jpg'],
});
expect(response.statusCode, 200);
expect(jsonDecode(response.body), {
'uploaded': 1,
'downloaded': 1,
'evicted': 0,
'pending': 0,
'missing': 0,
});
expect(dufs.files[rel], [0xFF, 9]);
});
test('reconcile without a login only reports', () async {
final response = await reconcile({'refs': <String>[]});
expect(jsonDecode(response.body)['uploaded'], 0);
expect(dufs.requests, isEmpty);
});
test('reconcile rejects malformed bodies', () async {
for (final body in ['not json', '[]', '{"refs": [1]}', '{}']) {
final response = await http.post(
Uri.parse('$base/images/reconcile'),
body: body,
);
expect(response.statusCode, 400, reason: body);
}
});
test('jpg is served with an image content type', () {
expect(WrapperServer.contentTypeFor('a.JPG').mimeType, 'image/jpeg');
expect(WrapperServer.contentTypeFor('a.jpeg').mimeType, 'image/jpeg');
});
}

View File

@ -0,0 +1,74 @@
/// An in-memory [ImageStore] for widget tests.
///
/// Deliberately NOT named `*_test.dart`: the runner would collect it.
library;
import 'dart:async';
import 'dart:typed_data';
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/images/image_ref.dart';
/// Records attaches and reconciles; every image is "local" at a fake path
/// unless listed in [missing].
class FakeImageStore extends ImageStore {
/// Bytes accepted per ref, in attach order.
final Map<ImageRef, Uint8List> attached = {};
/// Note texts passed to each reconcile.
final List<List<String>> reconciles = [];
/// Refs reported as not on this device.
final Set<ImageRef> missing = {};
/// Refs reported as waiting to upload.
final Set<ImageRef> pending = {};
/// Inputs whose first byte is this are "not an image".
int notImageMarker = 0;
/// When set, attach throws this.
ImageStoreException? failure;
/// Where local images appear to live, e.g. a real temp file.
String localPath = '/nonexistent/image.jpg';
/// Returns [NetworkImageSource]s instead of local ones.
bool network = false;
/// When set, attach waits for it first (to act mid-attach).
Future<void>? gate;
@override
Future<ImageRef?> attach(String noteId, Uint8List bytes) async {
await gate;
final failure = this.failure;
if (failure != null) throw failure;
if (bytes.isNotEmpty && bytes.first == notImageMarker) return null;
final ref = ImageRef.create(noteId);
attached[ref] = bytes;
return ref;
}
@override
Future<void> reconcile(Iterable<String> noteTexts) async =>
reconciles.add(noteTexts.toList());
@override
ImageSource sourceFor(ImageRef ref) {
if (missing.contains(ref)) return const MissingImageSource();
return network
? NetworkImageSource('http://localhost/images/$ref')
: LocalImageSource(localPath);
}
@override
bool isPending(ImageRef ref) => pending.contains(ref);
@override
Future<ImageStoreStatus> status() async =>
ImageStoreStatus(configured: true, pending: pending.length);
/// Lets a test fire a change notification.
void changed() => notifyListeners();
}

View File

@ -0,0 +1,110 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:todo/images/dufs_image_client.dart';
import 'package:todo/images/image_ref.dart';
void main() {
final ref = ImageRef.tryParse('n1/a.jpg')!;
const login = DufsLogin(user: 'todo', password: 'pw', baseUrl: 'https://d');
final auth = 'Basic ${base64.encode(utf8.encode('todo:pw'))}';
DufsImageClient client(MockClientHandler handler) =>
DufsImageClient(login, httpClient: MockClient(handler));
test('the default login points at kuhy-cloud', () {
expect(const DufsLogin(user: 'u', password: 'p').baseUrl, kDufsBaseUrl);
});
test('upload PUTs the bytes with basic auth; true on 2xx', () async {
late http.Request seen;
final ok = await client((r) async {
seen = r;
return http.Response('', 201);
}).upload(ref, Uint8List.fromList([1, 2]));
expect(ok, isTrue);
expect(seen.method, 'PUT');
expect(seen.url.toString(), 'https://d/todo-images/n1/a.jpg');
expect(seen.headers['authorization'], auth);
expect(seen.headers['content-type'], 'image/jpeg');
expect(seen.bodyBytes, [1, 2]);
});
test('upload is false on a refusal or a network error', () async {
expect(
await client(
(_) async => http.Response('', 403),
).upload(ref, Uint8List(1)),
isFalse,
);
expect(
await client(
(_) => throw const SocketException('down'),
).upload(ref, Uint8List(1)),
isFalse,
);
});
test('download returns bytes on 200, null otherwise', () async {
expect(
await client((r) async {
expect(r.headers['authorization'], auth);
return http.Response.bytes([7, 8], 200);
}).download(ref),
[7, 8],
);
expect(
await client((_) async => http.Response('', 404)).download(ref),
isNull,
);
expect(
await client((_) => throw const SocketException('down')).download(ref),
isNull,
);
});
test('check maps PROPFIND answers', () async {
Future<DufsCheck> answer(int code) => client((r) async {
expect(r.method, 'PROPFIND');
expect(r.headers['depth'], '0');
expect(r.url.toString(), 'https://d/todo-images/');
return http.Response('', code);
}).check();
expect(await answer(207), DufsCheck.ok);
expect(await answer(401), DufsCheck.rejected);
expect(await answer(403), DufsCheck.rejected);
expect(await answer(502), DufsCheck.unreachable);
expect(
await client((_) => throw const SocketException('down')).check(),
DufsCheck.unreachable,
);
});
test('a default http client is created when none is given', () {
DufsImageClient(login).close();
});
test('close closes the http client', () {
var closed = false;
final inner = _ClosingClient(() => closed = true);
DufsImageClient(login, httpClient: inner).close();
expect(closed, isTrue);
});
}
class _ClosingClient extends http.BaseClient {
_ClosingClient(this.onClose);
final void Function() onClose;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) =>
throw UnimplementedError();
@override
void close() => onClose();
}

View File

@ -0,0 +1,48 @@
/// An in-memory dufs for image-sync tests: a map of `todo-images/...` paths
/// to bytes behind a MockClient, with an [online] switch.
///
/// Deliberately NOT named `*_test.dart`: the runner would collect it.
library;
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:todo/images/dufs_image_client.dart';
/// Fake server state plus a client factory over it.
class FakeDufs {
/// Stored files, keyed by the path after `/todo-images/`.
final Map<String, List<int>> files = {};
/// When false every request fails like a dropped connection.
bool online = true;
/// Every request's `METHOD path`, in order.
final List<String> requests = [];
/// The HTTP client the image client talks through.
http.Client httpClient() => MockClient((request) async {
requests.add('${request.method} ${request.url.path}');
if (!online) throw const SocketException('offline');
final key = request.url.path.replaceFirst('/todo-images/', '');
switch (request.method) {
case 'PUT':
files[key] = request.bodyBytes;
return http.Response('', 201);
case 'GET':
final bytes = files[key];
return bytes == null
? http.Response('', 404)
: http.Response.bytes(bytes, 200);
default:
return http.Response('', 207);
}
});
/// A client over this fake.
DufsImageClient client() => DufsImageClient(
const DufsLogin(user: 'todo', password: 'pw', baseUrl: 'https://d'),
httpClient: httpClient(),
);
}

View File

@ -0,0 +1,185 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:todo/images/image_cache_store.dart';
import 'package:todo/images/image_ref.dart';
import 'fake_dufs.dart';
void main() {
late Directory root;
late ImageCacheStore store;
late FakeDufs dufs;
var now = DateTime(2026, 9, 25, 12);
final a = ImageRef.tryParse('n1/a.jpg')!;
final b = ImageRef.tryParse('n2/b.jpg')!;
final bytes = Uint8List.fromList([1, 2, 3]);
setUp(() {
root = Directory.systemTemp.createTempSync('image_cache');
now = DateTime.now();
store = ImageCacheStore(root, now: () => now);
dufs = FakeDufs();
});
tearDown(() => root.deleteSync(recursive: true));
void age(ImageRef ref, Duration by) =>
store.fileFor(ref).setLastModifiedSync(now.subtract(by));
test('put stores the file and queues it', () async {
await store.put(a, bytes);
expect(store.fileFor(a).readAsBytesSync(), bytes);
expect(store.isPending(a), isTrue);
expect(store.pending(), [a]);
expect(File('${store.fileFor(a).path}.tmp').existsSync(), isFalse);
});
test('an empty or missing root has nothing pending', () {
root.deleteSync(recursive: true);
expect(store.pending(), isEmpty);
root.createSync();
});
test('reconcile uploads the queue and clears the marker', () async {
await store.put(a, bytes);
final report = await store.reconcile({a}, dufs.client());
expect(dufs.files['n1/a.jpg'], bytes);
expect(store.isPending(a), isFalse);
expect(report.toJson(), {
'uploaded': 1,
'downloaded': 0,
'evicted': 0,
'pending': 0,
'missing': 0,
});
});
test('a failed upload keeps the marker for next time', () async {
await store.put(a, bytes);
dufs.online = false;
final report = await store.reconcile({a}, dufs.client());
expect(store.isPending(a), isTrue);
expect(report.pending, 1);
dufs.online = true;
expect((await store.reconcile({a}, dufs.client())).uploaded, 1);
});
test('a marker whose file never landed is skipped, not uploaded', () async {
await store.put(a, bytes);
store.fileFor(a).deleteSync();
final report = await store.reconcile({}, dufs.client());
expect(report.uploaded, 0);
expect(dufs.files, isEmpty);
});
test('without a client only eviction runs', () async {
await store.put(a, bytes);
final report = await store.reconcile({b}, null);
expect(dufs.requests, isEmpty);
expect(report.pending, 1);
expect(report.missing, 1);
});
test('reconcile downloads linked images this device lacks', () async {
dufs.files['n2/b.jpg'] = [9];
final report = await store.reconcile({b}, dufs.client());
expect(store.fileFor(b).readAsBytesSync(), [9]);
expect(report.downloaded, 1);
expect(report.missing, 0);
});
test('an image missing on dufs stays missing', () async {
final report = await store.reconcile({b}, dufs.client());
expect(report.downloaded, 0);
expect(report.missing, 1);
});
test('evicts old unlinked cached images only', () async {
dufs.files
..['n1/a.jpg'] = [1]
..['n2/b.jpg'] = [2];
await store.reconcile({a, b}, dufs.client());
age(a, const Duration(hours: 1));
age(b, const Duration(hours: 1));
final report = await store.reconcile({b}, dufs.client());
expect(report.evicted, 1);
expect(store.fileFor(a).existsSync(), isFalse);
expect(store.fileFor(b).existsSync(), isTrue);
expect(dufs.files.keys, contains('n1/a.jpg'), reason: 'never on dufs');
});
test('eviction spares queued and unparseable files', () async {
await store.put(a, bytes);
age(a, const Duration(hours: 1));
File(p.join(root.path, 'n1', 'odd name.jpg'))
..writeAsBytesSync([1])
..setLastModifiedSync(now.subtract(const Duration(hours: 1)));
dufs.online = false; // a stays queued
final report = await store.reconcile({b}, dufs.client());
expect(report.evicted, 0);
expect(store.fileFor(a).existsSync(), isTrue);
dufs.online = true; // uploaded now, so old + unlinked => evicted
expect((await store.reconcile({b}, dufs.client())).evicted, 1);
expect(store.fileFor(a).existsSync(), isFalse);
expect(File(p.join(root.path, 'n1', 'odd name.jpg')).existsSync(), isTrue);
});
test('a young unlinked image survives the grace period', () async {
dufs.files['n1/a.jpg'] = [1];
await store.reconcile({a}, dufs.client());
final report = await store.reconcile({b}, dufs.client());
expect(report.evicted, 0);
expect(store.fileFor(a).existsSync(), isTrue);
});
test('an empty ref set never evicts', () async {
dufs.files['n1/a.jpg'] = [1];
await store.reconcile({a}, dufs.client());
age(a, const Duration(days: 1));
expect((await store.reconcile({}, dufs.client())).evicted, 0);
expect(store.fileFor(a).existsSync(), isTrue);
});
test('calls before a pass starts merge into it', () async {
dufs.files
..['n1/a.jpg'] = [1]
..['n2/b.jpg'] = [2];
final first = store.reconcile({a}, null);
final second = store.reconcile({}, dufs.client()); // upload-only
final third = store.reconcile({b}, null);
expect(identical(first, second) && identical(second, third), isTrue);
final report = await first;
// One pass: refs unioned, and the only client given was kept.
expect(report.downloaded, 2);
});
test('a call during a running pass gets one follow-up pass', () async {
dufs.files['n2/b.jpg'] = [2];
final running = store.reconcile({}, dufs.client());
await Future<void>.delayed(Duration.zero); // let it start
final next = store.reconcile({b}, dufs.client());
expect(identical(running, next), isFalse);
await running;
expect((await next).downloaded, 1);
});
test('a failing pass does not wedge the queue', () async {
final boom = ImageCacheStore(
Directory(p.join(root.path, 'file-not-dir')),
now: () => now,
);
File(boom.root.path).writeAsStringSync('x');
dufs.files['n1/a.jpg'] = [1]; // downloading it must create a dir: fails
await expectLater(
boom.reconcile({a}, dufs.client()),
throwsA(isA<FileSystemException>()),
);
File(boom.root.path).deleteSync();
final report = await boom.reconcile({}, null);
expect(report.pending, 0);
});
}

View File

@ -0,0 +1,57 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:todo/images/image_processing.dart';
/// A JPEG built in-test (the binary gate forbids committed fixtures), with
/// GPS coordinates and an EXIF rotation, like a phone photo.
Uint8List phonePhoto({int width = 4000, int height = 3000}) {
final photo = img.Image(width: width, height: height)
..clear(img.ColorRgb8(200, 30, 30));
photo.exif.imageIfd.orientation = 6;
photo.exif.gpsIfd['GPSLatitude'] = img.IfdValueRational(52, 1);
return img.encodeJpg(photo);
}
void main() {
test('the fixture really carries GPS and a rotation', () {
final exif = img.decodeJpgExif(phonePhoto(width: 40, height: 30))!;
expect(exif.gpsIfd.isEmpty, isFalse);
expect(exif.imageIfd.orientation, 6);
});
test('shrinks, applies the rotation and drops all EXIF', () async {
final out = (await prepareImageInBackground(phonePhoto()))!;
final back = img.decodeJpg(out)!;
// Landscape pixels + "rotate 90" => upright portrait, long side capped.
expect(back.width, 1920);
expect(back.height, kMaxImageSide);
expect(img.decodeJpgExif(out)?.isEmpty ?? true, isTrue);
});
test('caps a landscape image on its width', () {
final wide = img.encodeJpg(img.Image(width: 3000, height: 1000));
final back = img.decodeJpg(prepareImage(wide)!)!;
expect(back.width, kMaxImageSide);
});
test('leaves a small image at its size', () {
final small = img.encodePng(img.Image(width: 300, height: 200));
final back = img.decodeJpg(prepareImage(small)!)!;
expect((back.width, back.height), (300, 200));
});
test('flattens transparency onto white', () {
final clear = img.Image(width: 8, height: 8, numChannels: 4);
final back = img.decodeJpg(prepareImage(img.encodePng(clear))!)!;
final pixel = back.getPixel(4, 4);
expect([pixel.r, pixel.g, pixel.b], everyElement(greaterThan(250)));
});
test('non-images come back null, including ones the decoder throws on', () {
expect(prepareImage(Uint8List.fromList([1, 2, 3])), isNull);
expect(prepareImage(Uint8List.fromList('hello world'.codeUnits)), isNull);
expect(prepareImage(Uint8List(0)), isNull);
});
}

View File

@ -0,0 +1,74 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/images/image_ref.dart';
void main() {
const rel = 'note-1/0815ff8a-203e-4f54-9cc5-8a58b27982ec.jpg';
final ref = ImageRef.tryParse(rel)!;
test('a ref exposes its parts, url and markdown', () {
expect(ref.noteId, 'note-1');
expect(ref.file, '0815ff8a-203e-4f54-9cc5-8a58b27982ec.jpg');
expect(ref.relativePath, rel);
expect(ref.url, '$kDufsBaseUrl/$kImagesDir/$rel');
expect(ref.markdown, '![](${ref.url})');
});
test('create files a fresh uuid jpg under the note', () {
final a = ImageRef.create('n1');
final b = ImageRef.create('n1');
expect(a.noteId, 'n1');
expect(a.file, endsWith('.jpg'));
expect(a, isNot(b));
});
test('create refuses an unsafe note id', () {
expect(() => ImageRef.create('../x'), throwsArgumentError);
});
test('tryParse rejects traversal, nesting and other extensions', () {
for (final bad in [
'../x/y.jpg',
'a/../b.jpg',
'a/b/c.jpg',
'a/b.png',
'a/.jpg',
'/a/b.jpg',
'a b/c.jpg',
]) {
expect(ImageRef.tryParse(bad), isNull, reason: bad);
}
});
test('refs compare by path, so sets dedupe', () {
expect({ref, ImageRef.tryParse(rel)!}, hasLength(1));
});
test('imageRefsIn finds every dufs image link, in order, once', () {
final other = ImageRef.create('n2');
final text =
'hi ${ref.markdown} and\n${other.markdown}\n${ref.markdown}\n'
'![x](https://example.com/a.jpg) ![]($kDufsBaseUrl/Keepass/a.jpg)';
expect(imageRefsIn(text).toList(), [ref, other]);
expect(imageRefsIn('no images'), isEmpty);
});
test('imageRefOfLine only matches a line that is just one image', () {
expect(imageRefOfLine(' ${ref.markdown} '), ref);
expect(imageRefOfLine('see ${ref.markdown}'), isNull);
expect(imageRefOfLine('![alt text](${ref.url})'), ref);
expect(imageRefOfLine('plain'), isNull);
});
test('backlog paths round-trip exactly', () {
final text = 'a\n${ref.markdown}\n![](https://example.com/x.jpg)';
final disk = toBacklogImagePaths(text);
expect(disk, contains('![]($kBacklogImagesRoot/$rel)'));
expect(disk, isNot(contains(kDufsBaseUrl)));
expect(fromBacklogImagePaths(disk), text);
});
test('a bare path outside an image link is left alone', () {
const text = 'see $kBacklogImagesRoot/$rel';
expect(fromBacklogImagePaths(text), text);
});
}

View File

@ -0,0 +1,119 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note.dart';
import 'package:todo/ui/image_input.dart';
import 'package:todo/ui/images/image_strip.dart';
import '../capture_screen_harness.dart';
import '../fake_image_store.dart';
void main() {
testWidgets('📎 on an empty draft creates the note under the image id', (
tester,
) async {
final store = FakeImageStore();
final repo = await pumpCapture(
tester,
imageStore: store,
imagePicking: (_) async => [
Uint8List.fromList([1]),
],
appSettings: ValueNotifier(const AppSettings(advancedMode: false)),
);
await tester.tap(find.byTooltip('Attach image'));
await tester.pump();
await tester.pump();
final ref = store.attached.keys.single;
final notes = await repo.listNotes();
expect(notes.single.id, ref.noteId);
expect(notes.single.text, contains(ref.markdown));
expect(find.byType(ImageStrip), findsOneWidget);
});
testWidgets('📎 on a started draft files under that note', (tester) async {
final store = FakeImageStore();
final repo = await pumpCapture(
tester,
imageStore: store,
imagePicking: (_) async => [
Uint8List.fromList([1]),
],
appSettings: ValueNotifier(const AppSettings(advancedMode: false)),
);
await tester.enterText(find.byType(TextField), 'idea');
await tester.pump();
final id = (await repo.listNotes()).single.id;
await tester.tap(find.byTooltip('Attach image'));
await tester.pump();
await tester.pump();
expect(store.attached.keys.single.noteId, id);
expect((await repo.listNotes()).single.text, startsWith('idea\n![]('));
});
testWidgets('launch and resume reconcile with every note', (tester) async {
final store = FakeImageStore();
await pumpCapture(
tester,
imageStore: store,
seed: [
Note(
id: 'a',
text: 'one',
priority: Priority.medium,
status: Status.done,
createdAt: DateTime(2026),
updatedAt: DateTime(2026),
),
],
);
await tester.pump();
expect(store.reconciles, isNotEmpty);
expect(store.reconciles.last, ['one']);
final before = store.reconciles.length;
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
expect(store.reconciles.length, before + 1);
});
testWidgets('a manual sync reconciles images after it', (tester) async {
final store = FakeImageStore();
await pumpCapture(
tester,
imageStore: store,
prefs: configuredPrefs,
httpClient: recordingMock([]),
);
await tester.pump();
final before = store.reconciles.length;
await tester.tap(find.byTooltip('Sync'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(store.reconciles.length, greaterThan(before));
});
testWidgets('a paste or drop attaches to the draft', (tester) async {
final store = FakeImageStore();
final repo = await pumpCapture(
tester,
imageStore: store,
appSettings: ValueNotifier(const AppSettings(advancedMode: false)),
);
tester.widget<ImageInputListener>(find.byType(ImageInputListener)).onImages(
[
Uint8List.fromList([1]),
],
1,
);
await tester.pump();
await tester.pump();
expect(store.attached, hasLength(1));
expect(
(await repo.listNotes()).single.id,
store.attached.keys.single.noteId,
);
expect(find.text('Not an image'), findsOneWidget);
});
}

View File

@ -0,0 +1,130 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/ui/cloud_images_settings.dart';
import '../fake_image_store.dart';
import '../fake_secure_storage.dart';
void main() {
Future<void> pump(
WidgetTester tester, {
ImageStore? store,
http.Client? client,
bool field = true,
}) async {
final app = MaterialApp(
home: CloudImagesScreen(httpClient: client, showPasswordField: field),
);
await tester.pumpWidget(
store == null ? app : ImageStoreScope(store: store, child: app),
);
await tester.pump();
}
MockClient answer(int code) =>
MockClient((_) async => http.Response('', code));
Future<void> saveWith(WidgetTester tester, String password) async {
await tester.enterText(find.byType(TextField), password);
await tester.tap(find.text('Save & test'));
await tester.pump();
await tester.pump();
}
testWidgets('the settings tile opens the screen', (tester) async {
installFakeSecureStorage();
await tester.pumpWidget(
const MaterialApp(home: Scaffold(body: CloudImagesTile())),
);
await tester.tap(find.text('Cloud images'));
await tester.pumpAndSettle();
expect(find.byType(CloudImagesScreen), findsOneWidget);
});
testWidgets('loads the saved password and the store status', (tester) async {
installFakeSecureStorage(initial: {'dufsImagesPassword': 'saved'});
await pump(tester, store: FakeImageStore());
expect(
tester.widget<TextField>(find.byType(TextField)).controller!.text,
'saved',
);
expect(find.text('Login: configured'), findsOneWidget);
expect(find.text('Waiting to upload: 0'), findsOneWidget);
});
testWidgets('an accepted password is saved and the queue uploads', (
tester,
) async {
installFakeSecureStorage();
final store = FakeImageStore();
await pump(tester, store: store, client: answer(207));
await saveWith(tester, ' pw ');
expect(find.text('Saved — dufs accepted the login'), findsOneWidget);
expect(store.reconciles.single, isEmpty);
});
testWidgets('a rejected password says so and uploads nothing', (
tester,
) async {
installFakeSecureStorage();
final store = FakeImageStore();
await pump(tester, store: store, client: answer(401));
await saveWith(tester, 'bad');
expect(find.text('Saved, but dufs rejected the password'), findsOneWidget);
expect(store.reconciles, isEmpty);
});
testWidgets('an unreachable dufs is reported', (tester) async {
installFakeSecureStorage();
await pump(
tester,
store: FakeImageStore(),
client: MockClient((_) => throw const SocketException('down')),
);
await saveWith(tester, 'pw');
expect(find.text('Saved — dufs is unreachable right now'), findsOneWidget);
});
testWidgets('no keystore means nothing is saved', (tester) async {
installFakeSecureStorage(throwing: true);
await pump(tester, store: FakeImageStore(), client: answer(207));
await saveWith(tester, 'pw');
expect(
find.text('Could not save: the keystore is unavailable'),
findsOneWidget,
);
});
testWidgets('the desktop shows status only, with the setup command', (
tester,
) async {
await pump(tester, field: false);
expect(find.byType(TextField), findsNothing);
expect(
find.text('Login: missing — create it on the PC with'),
findsOneWidget,
);
expect(find.text(kAddLoginCommand), findsOneWidget);
});
testWidgets('closing the screen mid-test is harmless', (tester) async {
installFakeSecureStorage();
final reply = Completer<http.Response>();
final store = FakeImageStore();
await pump(tester, store: store, client: MockClient((_) => reply.future));
await tester.enterText(find.byType(TextField), 'pw');
await tester.tap(find.text('Save & test'));
await tester.pump();
await tester.pumpWidget(const SizedBox());
reply.complete(http.Response('', 207));
await tester.pump();
expect(store.reconciles, isEmpty);
});
}

View File

@ -0,0 +1,171 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_template_parser.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/sync/desktop_wrapper.dart';
import 'package:todo/sync/notes_markdown.dart';
import 'package:todo/ui/capture_draft.dart';
import 'package:todo/ui/image_input.dart';
import 'package:todo/ui/image_input_files.dart';
import 'package:todo/ui/images/image_embed.dart';
import 'package:todo/ui/images/image_strip.dart';
import 'package:todo/ui/markdown_view.dart';
import 'package:todo/ui/note_detail_screen.dart';
import '../fake_image_store.dart';
import '../fake_note_repository.dart';
Note note(String text) => Note(
id: 'n1',
text: text,
priority: Priority.medium,
status: Status.todo,
createdAt: DateTime(2026, 9, 25),
updatedAt: DateTime(2026, 9, 25),
);
void main() {
final ref = ImageRef.create('n1');
testWidgets('detail: 📎 inserts at the cursor and persists', (tester) async {
tester.view.physicalSize = const Size(1200, 2400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final store = FakeImageStore();
final repo = FakeNoteRepository([note('first\nsecond')]);
addTearDown(repo.close);
await tester.pumpWidget(
ImageStoreScope(
store: store,
child: MaterialApp(
home: NoteDetailScreen(
note: note('first\nsecond'),
repository: repo,
appSettings: ValueNotifier(const AppSettings(advancedMode: true)),
imagePicking: (_) async => [
Uint8List.fromList([1]),
],
),
),
),
);
await tester.pump();
tester.widget<TextField>(find.byType(TextField)).controller!.selection =
const TextSelection.collapsed(offset: 5);
await tester.tap(find.byTooltip('Attach image'));
await tester.pump();
await tester.pump();
final added = store.attached.keys.single;
expect(added.noteId, 'n1');
expect(
(await repo.listNotes()).single.text,
'first\n${added.markdown}\nsecond',
);
expect(find.byType(ImageStrip), findsOneWidget);
// A paste or drop lands in the same note.
tester.widget<ImageInputListener>(find.byType(ImageInputListener)).onImages(
[
Uint8List.fromList([2]),
],
0,
);
await tester.pump();
await tester.pump();
expect(store.attached, hasLength(2));
expect(store.attached.keys.last.noteId, 'n1');
// View mode renders inline and hides the strip.
await tester.tap(find.text('View'));
await tester.pump();
expect(find.byType(ImageStrip), findsNothing);
expect(find.byType(ImageEmbed), findsNWidgets(2));
});
testWidgets('markdown view draws an image line as an embed', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Scaffold(body: MarkdownView(text: 'text\n${ref.markdown}\n')),
),
);
expect(find.byType(ImageEmbed), findsOneWidget);
expect(find.text('text'), findsOneWidget);
});
testWidgets('the io input listener is a pass-through', (tester) async {
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: ImageInputListener(onImages: (_, _) {}, child: const Text('x')),
),
);
expect(find.text('x'), findsOneWidget);
});
test('partitionImageFiles keeps order and counts the rest', () {
final split = partitionImageFiles([
'image/png',
'application/pdf',
'IMAGE/JPEG',
'',
]);
expect(split.images, [0, 2]);
expect(split.rejected, 2);
});
test('noteTitle skips image lines', () {
expect(noteTitle('${ref.markdown}\n# Real title'), 'Real title');
expect(noteTitle(ref.markdown), 'Image');
expect(noteTitle('${ref.markdown}\n\n${ref.markdown}'), '2 images');
expect(noteTitle(''), '');
});
test('the backlog export uses disk paths and parses back', () {
final exported = NotesMarkdown.export([note('t\n${ref.markdown}')]);
expect(exported, contains('![]($kBacklogImagesRoot/${ref.relativePath})'));
expect(NotesMarkdown.parse(exported).single.text, 't\n${ref.markdown}');
});
test('the page talks to the wrapper that served it', () {
expect(
servingWrapperOrigin(Uri.parse('http://localhost:8731/#/x')),
'http://localhost:8731',
);
expect(servingWrapperOrigin(Uri.parse('file:///x')), desktopWrapperOrigin);
expect(servingWrapperOrigin(), desktopWrapperOrigin);
});
group('capture draft id reservation', () {
late FakeNoteRepository repo;
late CaptureDraft draft;
setUp(() {
repo = FakeNoteRepository([]);
draft = CaptureDraft(repo);
});
tearDown(() async {
draft.dispose();
await repo.close();
});
test('a reserved id saves nothing until text arrives', () async {
final id = draft.reserveId();
expect(draft.reserveId(), id);
await draft.persistMetadata(live: () => true);
expect(await repo.listNotes(), isEmpty);
expect(await draft.write(' ', live: () => true), isFalse);
expect(await draft.write('x', live: () => true), isTrue);
expect((await repo.listNotes()).single.id, id);
expect(await draft.write('xy', live: () => true), isFalse);
});
test('reset drops the reservation', () async {
final id = draft.reserveId();
draft.reset();
expect(draft.reserveId(), isNot(id));
});
});
}

View File

@ -0,0 +1,148 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/attachments/image_store_api.dart';
import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/ui/image_attach.dart';
import '../fake_image_store.dart';
void main() {
test('the controller inserts only while an editor is bound', () {
final controller = ImageAttachController();
final got = <List<String>>[];
expect(controller.insert(['a']), isFalse);
final unbind = controller.bind(got.add);
expect(controller.insert([]), isFalse);
expect(controller.insert(['a']), isTrue);
// A later editor's bind wins; the earlier unbind no longer detaches it.
final unbindSecond = controller.bind(got.add);
unbind();
expect(controller.insert(['b']), isTrue);
unbindSecond();
expect(controller.insert(['c']), isFalse);
expect(got, [
['a'],
['b'],
]);
});
Future<BuildContext> pumpScope(WidgetTester tester, ImageStore store) async {
late BuildContext context;
await tester.pumpWidget(
ImageStoreScope(
store: store,
child: MaterialApp(
home: Scaffold(
body: Builder(
builder: (c) {
context = c;
return const SizedBox();
},
),
),
),
),
);
return context;
}
testWidgets('attaches in order and inserts every link', (tester) async {
final store = FakeImageStore();
final context = await pumpScope(tester, store);
final controller = ImageAttachController();
final inserted = <List<String>>[];
controller.bind(inserted.add);
var asked = 0;
await attachImages(
context,
noteId: () {
asked++;
return 'n1';
},
target: controller,
images: [
Uint8List.fromList([1]),
Uint8List.fromList([2]),
],
);
expect(asked, 2);
expect(store.attached.values.map((b) => b.first), [1, 2]);
expect(inserted.single, [
for (final ref in store.attached.keys) ref.markdown,
]);
await tester.pump();
expect(find.byType(SnackBar), findsNothing);
});
testWidgets('reports one non-image', (tester) async {
final store = FakeImageStore();
final context = await pumpScope(tester, store);
await attachImages(
context,
noteId: () => 'n1',
target: ImageAttachController(),
images: [
Uint8List.fromList([0]),
],
);
await tester.pump();
expect(find.text('Not an image'), findsOneWidget);
});
testWidgets('counts dropped non-images together', (tester) async {
final store = FakeImageStore();
final context = await pumpScope(tester, store);
await attachImages(
context,
noteId: () => 'n1',
target: ImageAttachController(),
images: [
Uint8List.fromList([0]),
],
rejected: 2,
);
await tester.pump();
expect(find.text('3 files were not images'), findsOneWidget);
});
testWidgets('a store failure is shown and stops the batch', (tester) async {
final store = FakeImageStore()
..failure = const ImageStoreException('helper down');
final context = await pumpScope(tester, store);
await attachImages(
context,
noteId: () => 'n1',
target: ImageAttachController(),
images: [
Uint8List.fromList([1]),
Uint8List.fromList([2]),
],
);
await tester.pump();
expect(find.text('helper down'), findsOneWidget);
});
testWidgets('nothing is shown once the screen closed mid-attach', (
tester,
) async {
final gate = Completer<void>();
final store = FakeImageStore()..gate = gate.future;
final context = await pumpScope(tester, store);
final pending = attachImages(
context,
noteId: () => 'n1',
target: ImageAttachController(),
images: [
Uint8List.fromList([0]),
],
);
await tester.pumpWidget(const SizedBox());
gate.complete();
await pending;
await tester.pump();
expect(find.byType(SnackBar), findsNothing);
});
}

View File

@ -0,0 +1,97 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:todo/attachments/image_store_scope.dart';
import 'package:todo/images/image_ref.dart';
import 'package:todo/ui/images/image_embed.dart';
import 'package:todo/ui/images/image_strip.dart';
import 'package:todo/ui/images/image_viewer_screen.dart';
import '../fake_image_store.dart';
void main() {
final ref = ImageRef.create('n1');
late FakeImageStore store;
late Directory dir;
setUp(() {
store = FakeImageStore();
dir = Directory.systemTemp.createTempSync('embed');
final file = File('${dir.path}/a.jpg')
..writeAsBytesSync(img.encodeJpg(img.Image(width: 4, height: 4)));
store.localPath = file.path;
});
tearDown(() => dir.deleteSync(recursive: true));
Future<void> pump(WidgetTester tester, Widget child) => tester.pumpWidget(
ImageStoreScope(
store: store,
child: MaterialApp(
home: Scaffold(body: SizedBox(width: 200, height: 200, child: child)),
),
),
);
testWidgets('a local image renders and opens full screen', (tester) async {
await pump(tester, ImageEmbed(ref: ref));
expect(find.byType(Image), findsOneWidget);
expect(find.byTooltip(kImageWaitingToUpload), findsNothing);
await tester.tap(find.byType(ImageEmbed));
await tester.pumpAndSettle();
expect(find.byType(ImageViewerScreen), findsOneWidget);
expect(find.byType(InteractiveViewer), findsOneWidget);
});
testWidgets('a queued image carries the upload badge', (tester) async {
store.pending.add(ref);
await pump(tester, ImageEmbed(ref: ref));
expect(find.byTooltip(kImageWaitingToUpload), findsOneWidget);
});
testWidgets('a missing image is a placeholder and not tappable', (
tester,
) async {
store.missing.add(ref);
await pump(tester, ImageEmbed(ref: ref));
expect(find.text(kImageNotDownloaded), findsOneWidget);
await tester.tap(find.byType(ImageEmbed));
await tester.pumpAndSettle();
expect(find.byType(ImageViewerScreen), findsNothing);
});
testWidgets('a broken file or url falls back to the placeholder', (
tester,
) async {
store.localPath = '${dir.path}/gone.jpg';
await pump(tester, ImageEmbed(ref: ref));
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
expect(find.text(kImageNotDownloaded), findsOneWidget);
store.network = true;
store.changed();
await tester.pump();
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 50)),
);
await tester.pump();
expect(find.text(kImageNotDownloaded), findsOneWidget);
});
testWidgets('the strip shows one thumbnail per linked image', (tester) async {
final other = ImageRef.create('n2');
await pump(
tester,
ImageStrip(text: 'x\n${ref.markdown}\n${other.markdown}'),
);
expect(find.byType(ImageEmbed), findsNWidgets(2));
});
testWidgets('the strip is empty without images', (tester) async {
await pump(tester, const ImageStrip(text: 'no images'));
expect(find.byType(ImageEmbed), findsNothing);
});
}

View File

@ -0,0 +1,85 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/ui/image_picking.dart';
void main() {
testWidgets('the button passes picked images on, ignores a cancel', (
tester,
) async {
final got = <List<Uint8List>>[];
var result = <Uint8List>[];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: AttachImageButton(
onImages: got.add,
picking: (_) async => result,
),
),
),
);
await tester.tap(find.byTooltip('Attach image'));
await tester.pump();
expect(got, isEmpty);
result = [
Uint8List.fromList([1]),
];
await tester.tap(find.byTooltip('Attach image'));
await tester.pump();
expect(got.single.single, [1]);
});
Future<List<Uint8List>?> openSheet(
WidgetTester tester,
List<ImagePickSource> asked,
) async {
List<Uint8List>? result;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () async => result = await pickImages(
context,
fromPhone: (source) async {
asked.add(source);
return [
Uint8List.fromList([source.index]),
];
},
),
child: const Text('pick'),
),
),
),
),
);
await tester.tap(find.text('pick'));
await tester.pumpAndSettle();
return result;
}
testWidgets('the phone sheet offers Camera and Gallery', (tester) async {
final asked = <ImagePickSource>[];
await openSheet(tester, asked);
expect(find.text('Camera'), findsOneWidget);
await tester.tap(find.text('Gallery'));
await tester.pumpAndSettle();
expect(asked, [ImagePickSource.gallery]);
await tester.tap(find.text('pick'));
await tester.pumpAndSettle();
await tester.tap(find.text('Camera'));
await tester.pumpAndSettle();
expect(asked, [ImagePickSource.gallery, ImagePickSource.camera]);
});
testWidgets('dismissing the sheet picks nothing', (tester) async {
final asked = <ImagePickSource>[];
await openSheet(tester, asked);
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
expect(asked, isEmpty);
});
}

View File

@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_template.dart';
import 'package:todo/data/note_template_parser.dart';
import 'package:todo/ui/image_attach.dart';
import 'package:todo/ui/note_editor.dart';
void main() {
late ImageAttachController attach;
late List<String> emitted;
Future<void> pump(
WidgetTester tester, {
String text = '',
NoteTemplate? template,
NoteEditorMode mode = NoteEditorMode.raw,
ValueChanged<NoteEditorMode>? onModeChanged,
Key? key,
}) async {
attach = ImageAttachController();
emitted = [];
tester.view.physicalSize = const Size(1200, 2400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: NoteEditor(
key: key,
initialText: text,
initialTemplate: template,
initialMode: mode,
priority: Priority.medium,
onPriorityChanged: (_) {},
onChromeVisibleChanged: (_) {},
onChanged: emitted.add,
attachController: attach,
onModeChanged: onModeChanged,
),
),
),
);
await tester.pump();
}
TextEditingController field(WidgetTester tester) =>
tester.widget<TextField>(find.byType(TextField)).controller!;
testWidgets('raw: inserts on its own line at the cursor', (tester) async {
await pump(tester, text: 'line1\nline2', template: NoteTemplate.blank);
field(tester).selection = const TextSelection.collapsed(offset: 5);
attach.insert(['IMG1', 'IMG2']);
await tester.pump();
expect(field(tester).text, 'line1\nIMG1\nIMG2\nline2');
expect(field(tester).selection.baseOffset, 'line1\nIMG1\nIMG2'.length);
expect(emitted.last, 'line1\nIMG1\nIMG2\nline2');
});
testWidgets('raw: a selection is replaced; no cursor appends', (
tester,
) async {
await pump(tester, text: 'abc', template: NoteTemplate.blank);
field(tester).selection = const TextSelection(
baseOffset: 1,
extentOffset: 2,
);
attach.insert(['I']);
await tester.pump();
expect(field(tester).text, 'a\nI\nc');
field(tester).selection = const TextSelection.collapsed(offset: -1);
attach.insert(['J']);
await tester.pump();
expect(field(tester).text, 'a\nI\nc\nJ\n');
});
testWidgets('raw: an empty note gets just the link', (tester) async {
await pump(tester, template: NoteTemplate.blank);
attach.insert(['I']);
await tester.pump();
expect(field(tester).text, 'I\n');
});
testWidgets('guided: goes into the current step', (tester) async {
await pump(
tester,
template: NoteTemplate.llmDesignSpec,
mode: NoteEditorMode.guided,
);
expect(find.text('Exit guided'), findsNothing);
attach.insert(['IMG']);
await tester.pump();
expect(field(tester).text, 'IMG\n');
expect(emitted.last, contains('IMG'));
});
testWidgets('view over a raw note appends to the end', (tester) async {
final modes = <NoteEditorMode>[];
await pump(
tester,
text: 'freeform text',
template: NoteTemplate.blank,
onModeChanged: modes.add,
);
await tester.tap(find.text('View'));
await tester.pump();
expect(modes, [NoteEditorMode.preview]);
attach.insert(['IMG']);
await tester.pump();
expect(emitted.last, 'freeform text\nIMG\n');
});
testWidgets('view over guided sections appends to the last section', (
tester,
) async {
const spec = NoteTemplate.llmDesignSpec;
final text = assemble(spec, {
for (final s in spec.sections) s.key: s.isTitle ? 'Title' : 'v ${s.key}',
});
await pump(
tester,
text: text,
template: spec,
mode: NoteEditorMode.preview,
);
attach.insert(['IMG']);
await tester.pump();
final last = spec.sections.last;
final tail = emitted.last.substring(
emitted.last.indexOf('## ${last.label}'),
);
expect(tail, contains('v ${last.key}\nIMG'));
});
testWidgets('a new controller on the same editor is rebound', (tester) async {
await pump(tester, text: 'a', template: NoteTemplate.blank);
final first = attach;
await pump(tester, text: 'a', template: NoteTemplate.blank);
expect(first.insert(['x']), isFalse);
expect(attach.insert(['y']), isTrue);
// Same controller again: nothing to rebind.
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: NoteEditor(
priority: Priority.medium,
onPriorityChanged: (_) {},
onChromeVisibleChanged: (_) {},
onChanged: (_) {},
attachController: attach,
),
),
),
);
expect(attach.insert(['z']), isTrue);
});
testWidgets('disposing the editor unbinds it', (tester) async {
await pump(tester, template: NoteTemplate.blank);
await tester.pumpWidget(const SizedBox());
expect(attach.insert(['x']), isFalse);
});
}

View File

@ -12,7 +12,9 @@ import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:path/path.dart' as p;
import 'package:todo/desktop/wrapper_image_routes.dart';
import 'package:todo/desktop/wrapper_server.dart';
import 'package:todo/images/image_cache_store.dart';
/// One test's temp web root and the [WrapperServer] serving it.
///
@ -45,6 +47,7 @@ class WrapperHarness {
webRoot: webRoot.path,
backlogPath: p.join(root.path, 'todo', 'BACKLOG.md'),
logPath: p.join(root.path, 'state', 'todo_notes.json'),
images: images(),
// Without this, the default falls back to the real
// $HOME/.config/todo/firebase_auth.json -- on a machine that has
// actually signed in (a real desktop session), that file exists, so
@ -67,6 +70,14 @@ class WrapperHarness {
});
}
/// Image routes rooted in this test's temp dir. Without them the server
/// defaults to the real `~/.local/share/todo-desktop/images` and the real
/// dufs login in `~/.config/dufs/logins/todo.env`.
WrapperImageRoutes images() => WrapperImageRoutes(
store: ImageCacheStore(Directory(p.join(root.path, 'images'))),
loginEnvPath: p.join(root.path, 'dufs', 'todo.env'),
);
Future<String> enabledOrigin(
Map<String, String> files, {
String? credentialsJson,
@ -87,6 +98,7 @@ class WrapperHarness {
webRoot: p.join(root.path, 'web'),
backlogPath: p.join(root.path, 'todo', 'BACKLOG.md'),
logPath: p.join(root.path, 'state', 'todo_notes.json'),
images: images(),
serveSyncAccount: true,
syncConfigDir: configDir.path,
todoCredentialsPath:

View File

@ -0,0 +1,32 @@
// A throwaway desktop wrapper for end-to-end runs of the web build, on a
// port and HOME of its own, so a test page can never write the real
// BACKLOG.md, note-log copy or image cache (the page talks only to the
// wrapper that served it; see servingWrapperOrigin).
//
// HOME=$(mktemp -d) dart run tool/image_e2e_wrapper.dart \
// --port 8731 --web-root build/web
//
// Images use $HOME/.local/share/todo-desktop/images and the login in
// $HOME/.config/dufs/logins/todo.env, exactly like the real wrapper.
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:todo/desktop/wrapper_server.dart';
Future<void> main(List<String> args) async {
String arg(String name, String fallback) {
final i = args.indexOf(name);
return i >= 0 && i + 1 < args.length ? args[i + 1] : fallback;
}
final home = Platform.environment['HOME']!;
final port = int.parse(arg('--port', '8731'));
final server = WrapperServer(
webRoot: p.absolute(arg('--web-root', 'build/web')),
// The real wrapper's layout, under the throwaway HOME.
backlogPath: defaultBacklogPath(home),
logPath: p.join(home, '.local', 'share', 'todo-desktop', 'todo_notes.json'),
);
await server.start(port);
stdout.writeln('e2e wrapper on http://localhost:$port (HOME=$home)');
}