From 0798f7b1d0032fa3640946b2eb28df622cbae4b4 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 25 Sep 2026 14:11:15 +0200 Subject: [PATCH] Attach images to notes, stored on dufs and kept offline Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 25 ++- lib/attachments/image_password_store.dart | 56 +++++ lib/attachments/image_store.dart | 7 + lib/attachments/image_store_api.dart | 113 ++++++++++ lib/attachments/image_store_io.dart | 101 +++++++++ lib/attachments/image_store_scope.dart | 29 +++ lib/attachments/image_store_web.dart | 103 +++++++++ lib/data/note_template_parser.dart | 16 +- lib/data/repository_factory_web.dart | 2 +- lib/desktop/dufs_login_env.dart | 37 ++++ lib/desktop/wrapper_image_routes.dart | 159 ++++++++++++++ lib/desktop/wrapper_paths.dart | 25 +++ lib/desktop/wrapper_server.dart | 29 +-- lib/images/dufs_image_client.dart | 114 ++++++++++ lib/images/image_cache_store.dart | 207 ++++++++++++++++++ lib/images/image_processing.dart | 66 ++++++ lib/images/image_ref.dart | 91 ++++++++ lib/main.dart | 29 ++- lib/sync/backlog_export_web.dart | 2 +- lib/sync/desktop_wrapper.dart | 16 ++ lib/sync/local_backup_factory_web.dart | 2 +- lib/sync/notes_markdown.dart | 12 +- lib/ui/capture_app_bar.dart | 13 ++ lib/ui/capture_draft.dart | 23 +- lib/ui/capture_screen.dart | 38 ++-- lib/ui/capture_screen_images.dart | 29 +++ lib/ui/capture_screen_sync.dart | 46 +++- lib/ui/capture_screen_widget.dart | 4 + lib/ui/cloud_images_settings.dart | 176 +++++++++++++++ lib/ui/image_attach.dart | 81 +++++++ lib/ui/image_input.dart | 6 + lib/ui/image_input_files.dart | 15 ++ lib/ui/image_input_io.dart | 27 +++ lib/ui/image_input_web.dart | 103 +++++++++ lib/ui/image_picking.dart | 104 +++++++++ lib/ui/images/image_embed.dart | 115 ++++++++++ lib/ui/images/image_strip.dart | 48 ++++ lib/ui/images/image_viewer_screen.dart | 24 ++ lib/ui/images/local_image.dart | 5 + lib/ui/images/local_image_io.dart | 10 + lib/ui/images/local_image_web.dart | 8 + lib/ui/markdown_view.dart | 21 +- lib/ui/note_detail_screen.dart | 64 ++++-- lib/ui/note_editor.dart | 16 ++ lib/ui/note_editor_images.dart | 55 +++++ lib/ui/note_editor_widget.dart | 10 + lib/ui/note_form.dart | 68 ++++-- lib/ui/settings_screen.dart | 8 +- pubspec.lock | 100 +++++++-- pubspec.yaml | 11 +- .../image_password_store_test.dart | 27 +++ test/attachments/image_store_api_test.dart | 70 ++++++ test/attachments/image_store_io_test.dart | 107 +++++++++ test/attachments/image_store_web_test.dart | 119 ++++++++++ test/capture_screen_harness.dart | 37 ++-- test/desktop/dufs_login_env_test.dart | 45 ++++ test/desktop/wrapper_image_routes_test.dart | 164 ++++++++++++++ test/fake_image_store.dart | 74 +++++++ test/images/dufs_image_client_test.dart | 110 ++++++++++ test/images/fake_dufs.dart | 48 ++++ test/images/image_cache_store_test.dart | 185 ++++++++++++++++ test/images/image_processing_test.dart | 57 +++++ test/images/image_ref_test.dart | 74 +++++++ test/ui_images/capture_images_test.dart | 119 ++++++++++ .../ui_images/cloud_images_settings_test.dart | 130 +++++++++++ .../detail_and_misc_images_test.dart | 171 +++++++++++++++ test/ui_images/image_attach_test.dart | 148 +++++++++++++ test/ui_images/image_embed_test.dart | 97 ++++++++ test/ui_images/image_picking_test.dart | 85 +++++++ test/ui_images/note_editor_images_test.dart | 164 ++++++++++++++ test/wrapper_server_harness.dart | 12 + tool/image_e2e_wrapper.dart | 32 +++ 72 files changed, 4410 insertions(+), 134 deletions(-) create mode 100644 lib/attachments/image_password_store.dart create mode 100644 lib/attachments/image_store.dart create mode 100644 lib/attachments/image_store_api.dart create mode 100644 lib/attachments/image_store_io.dart create mode 100644 lib/attachments/image_store_scope.dart create mode 100644 lib/attachments/image_store_web.dart create mode 100644 lib/desktop/dufs_login_env.dart create mode 100644 lib/desktop/wrapper_image_routes.dart create mode 100644 lib/desktop/wrapper_paths.dart create mode 100644 lib/images/dufs_image_client.dart create mode 100644 lib/images/image_cache_store.dart create mode 100644 lib/images/image_processing.dart create mode 100644 lib/images/image_ref.dart create mode 100644 lib/ui/capture_screen_images.dart create mode 100644 lib/ui/cloud_images_settings.dart create mode 100644 lib/ui/image_attach.dart create mode 100644 lib/ui/image_input.dart create mode 100644 lib/ui/image_input_files.dart create mode 100644 lib/ui/image_input_io.dart create mode 100644 lib/ui/image_input_web.dart create mode 100644 lib/ui/image_picking.dart create mode 100644 lib/ui/images/image_embed.dart create mode 100644 lib/ui/images/image_strip.dart create mode 100644 lib/ui/images/image_viewer_screen.dart create mode 100644 lib/ui/images/local_image.dart create mode 100644 lib/ui/images/local_image_io.dart create mode 100644 lib/ui/images/local_image_web.dart create mode 100644 lib/ui/note_editor_images.dart create mode 100644 test/attachments/image_password_store_test.dart create mode 100644 test/attachments/image_store_api_test.dart create mode 100644 test/attachments/image_store_io_test.dart create mode 100644 test/attachments/image_store_web_test.dart create mode 100644 test/desktop/dufs_login_env_test.dart create mode 100644 test/desktop/wrapper_image_routes_test.dart create mode 100644 test/fake_image_store.dart create mode 100644 test/images/dufs_image_client_test.dart create mode 100644 test/images/fake_dufs.dart create mode 100644 test/images/image_cache_store_test.dart create mode 100644 test/images/image_processing_test.dart create mode 100644 test/images/image_ref_test.dart create mode 100644 test/ui_images/capture_images_test.dart create mode 100644 test/ui_images/cloud_images_settings_test.dart create mode 100644 test/ui_images/detail_and_misc_images_test.dart create mode 100644 test/ui_images/image_attach_test.dart create mode 100644 test/ui_images/image_embed_test.dart create mode 100644 test/ui_images/image_picking_test.dart create mode 100644 test/ui_images/note_editor_images_test.dart create mode 100644 tool/image_e2e_wrapper.dart diff --git a/AGENTS.md b/AGENTS.md index 1a10ad8..ee51e02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `` 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//.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` diff --git a/lib/attachments/image_password_store.dart b/lib/attachments/image_password_store.dart new file mode 100644 index 0000000..c1ccad4 --- /dev/null +++ b/lib/attachments/image_password_store.dart @@ -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 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 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 login() async { + final password = await read(); + if (password.isEmpty) return null; + return DufsLogin(user: kDufsImagesUser, password: password); + } +} diff --git a/lib/attachments/image_store.dart b/lib/attachments/image_store.dart new file mode 100644 index 0000000..60c4788 --- /dev/null +++ b/lib/attachments/image_store.dart @@ -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'; diff --git a/lib/attachments/image_store_api.dart b/lib/attachments/image_store_api.dart new file mode 100644 index 0000000..09ed042 --- /dev/null +++ b/lib/attachments/image_store_api.dart @@ -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//.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 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 reconcile(Iterable 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 status(); +} + +/// Every image ref across [noteTexts]. +Set imageRefsAcross(Iterable 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 attach(String noteId, Uint8List bytes) async => + throw const ImageStoreException('Images are not available here'); + + @override + Future reconcile(Iterable noteTexts) async {} + + @override + ImageSource sourceFor(ImageRef ref) => const MissingImageSource(); + + @override + bool isPending(ImageRef ref) => false; + + @override + Future status() async => + const ImageStoreStatus(configured: false, pending: 0); +} diff --git a/lib/attachments/image_store_io.dart b/lib/attachments/image_store_io.dart new file mode 100644 index 0000000..85b2d0e --- /dev/null +++ b/lib/attachments/image_store_io.dart @@ -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 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 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 Function(Uint8List) _prepare; + + @override + Future 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 reconcile(Iterable noteTexts) => + _sync(imageRefsAcross(noteTexts)); + + Future _sync(Set 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 status() async => ImageStoreStatus( + configured: await _passwords.login() != null, + pending: cache.pending().length, + ); +} diff --git a/lib/attachments/image_store_scope.dart b/lib/attachments/image_store_scope.dart new file mode 100644 index 0000000..b4b4fae --- /dev/null +++ b/lib/attachments/image_store_scope.dart @@ -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 { + /// 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()?.notifier ?? + _none; + + /// The nearest store, without subscribing (for callbacks). + static ImageStore read(BuildContext context) => + context.getInheritedWidgetOfExactType()?.notifier ?? + _none; +} diff --git a/lib/attachments/image_store_web.dart b/lib/attachments/image_store_web.dart new file mode 100644 index 0000000..9864b20 --- /dev/null +++ b/lib/attachments/image_store_web.dart @@ -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 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 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 reconcile(Iterable noteTexts) => + _reconcile(imageRefsAcross(noteTexts)); + + Future _reconcile(Set 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; + 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 status() async { + try { + final response = await _http.get(_images('status')); + final body = jsonDecode(response.body) as Map; + return ImageStoreStatus( + configured: body['configured'] as bool? ?? false, + pending: body['pending'] as int? ?? 0, + ); + } on Exception { + return const ImageStoreStatus(configured: false, pending: 0); + } + } +} diff --git a/lib/data/note_template_parser.dart b/lib/data/note_template_parser.dart index 8848779..4850d8d 100644 --- a/lib/data/note_template_parser.dart +++ b/lib/data/note_template_parser.dart @@ -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 diff --git a/lib/data/repository_factory_web.dart b/lib/data/repository_factory_web.dart index b3569c5..95ec243 100644 --- a/lib/data/repository_factory_web.dart +++ b/lib/data/repository_factory_web.dart @@ -14,7 +14,7 @@ Future 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(); diff --git a/lib/desktop/dufs_login_env.dart b/lib/desktop/dufs_login_env.dart new file mode 100644 index 0000000..2dbe0e2 --- /dev/null +++ b/lib/desktop/dufs_login_env.dart @@ -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 = {}; + 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, + ); +} diff --git a/lib/desktop/wrapper_image_routes.dart b/lib/desktop/wrapper_image_routes.dart new file mode 100644 index 0000000..ee394fd --- /dev/null +++ b/lib/desktop/wrapper_image_routes.dart @@ -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 `` just works. +/// +/// * `POST /images//.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//.jpg` — the cached JPEG, 404 when absent. +/// * `POST /images/reconcile` — `{"refs": ["/.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 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 Function(Uint8List) _prepare; + + static const _prefix = '/images/'; + + /// Answers [request] and returns true when it is an `/images/` route. + Future 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 _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 _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 _reconcile(HttpRequest request) async { + final Set refs; + try { + final body = jsonDecode(await utf8.decodeStream(request)); + refs = { + for (final raw in (body as Map)['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)); + } +} diff --git a/lib/desktop/wrapper_paths.dart b/lib/desktop/wrapper_paths.dart new file mode 100644 index 0000000..f3109cf --- /dev/null +++ b/lib/desktop/wrapper_paths.dart @@ -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'); diff --git a/lib/desktop/wrapper_server.dart b/lib/desktop/wrapper_server.dart index 1608229..f0ab5b2 100644 --- a/lib/desktop/wrapper_server.dart +++ b/lib/desktop/wrapper_server.dart @@ -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': diff --git a/lib/images/dufs_image_client.dart b/lib/images/dufs_image_client.dart new file mode 100644 index 0000000..69338f9 --- /dev/null +++ b/lib/images/dufs_image_client.dart @@ -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 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 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 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 check() async { + try { + final request = http.Request('PROPFIND', _uri('')) + ..headers.addAll({..._auth, 'depth': '0'}); + final response = await _http.send(request); + await response.stream.drain(); + 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(); +} diff --git a/lib/images/image_cache_store.dart b/lib/images/image_cache_store.dart new file mode 100644 index 0000000..6698a08 --- /dev/null +++ b/lib/images/image_cache_store.dart @@ -0,0 +1,207 @@ +/// The on-device copy of every image a note links to, plus the upload queue. +/// +/// One directory, `//.jpg`, mirrors dufs' `todo-images/`. +/// An image attached while offline gets a `.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 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 `/.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 _tail = Future.value(); + Future? _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 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 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 reconcile( + Set 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((_) {}, onError: (Object _) {}); + return future; + } + + Future _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 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 _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 referenced; + final DufsImageClient? client; +} diff --git a/lib/images/image_processing.dart b/lib/images/image_processing.dart new file mode 100644 index 0000000..625ca08 --- /dev/null +++ b/lib/images/image_processing.dart @@ -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 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); +} diff --git a/lib/images/image_ref.dart b/lib/images/image_ref.dart new file mode 100644 index 0000000..4b9bd67 --- /dev/null +++ b/lib/images/image_ref.dart @@ -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//.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 `/` 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 `/.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('/')); + + /// `.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 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]}', +); diff --git a/lib/main.dart b/lib/main.dart index aa0674a..cec9509 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 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,16 +59,23 @@ 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( - title: 'todo', - theme: buildLightTheme(), - darkTheme: buildDarkTheme(), - home: CaptureScreen( - repository: repository, - appSettings: appSettings, - analytics: analytics, + // Above MaterialApp so pushed routes (note detail) see it too. + return ImageStoreScope( + store: imageStore, + child: MaterialApp( + title: 'todo', + theme: buildLightTheme(), + darkTheme: buildDarkTheme(), + home: CaptureScreen( + repository: repository, + appSettings: appSettings, + analytics: analytics, + ), ), ); } diff --git a/lib/sync/backlog_export_web.dart b/lib/sync/backlog_export_web.dart index cba0ef2..81f3715 100644 --- a/lib/sync/backlog_export_web.dart +++ b/lib/sync/backlog_export_web.dart @@ -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 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'; } diff --git a/lib/sync/desktop_wrapper.dart b/lib/sync/desktop_wrapper.dart index e60cf5e..e632f93 100644 --- a/lib/sync/desktop_wrapper.dart +++ b/lib/sync/desktop_wrapper.dart @@ -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; +} diff --git a/lib/sync/local_backup_factory_web.dart b/lib/sync/local_backup_factory_web.dart index bc2f282..68c2199 100644 --- a/lib/sync/local_backup_factory_web.dart +++ b/lib/sync/local_backup_factory_web.dart @@ -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 diff --git a/lib/sync/notes_markdown.dart b/lib/sync/notes_markdown.dart index 7a7d998..73ee3fe 100644 --- a/lib/sync/notes_markdown.dart +++ b/lib/sync/notes_markdown.dart @@ -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( diff --git a/lib/ui/capture_app_bar.dart b/lib/ui/capture_app_bar.dart index 78ca419..53604bb 100644 --- a/lib/ui/capture_app_bar.dart +++ b/lib/ui/capture_app_bar.dart @@ -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> 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, diff --git a/lib/ui/capture_draft.dart b/lib/ui/capture_draft.dart index 29e16a4..9adde3a 100644 --- a/lib/ui/capture_draft.dart +++ b/lib/ui/capture_draft.dart @@ -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 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; diff --git a/lib/ui/capture_screen.dart b/lib/ui/capture_screen.dart index 956c1d2..b5b115b 100644 --- a/lib/ui/capture_screen.dart +++ b/lib/ui/capture_screen.dart @@ -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 /// 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 onSync: _sync, onOpenList: _openList, onOpenSettings: _openSettings, + onAttachImages: _attachImages, + imagePicking: widget.imagePicking, ), body: _captureBody(advanced: advanced), ); @@ -167,33 +178,11 @@ class _CaptureScreenState extends State // 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 _openSettings() async { - _logEvent('screen_view', params: {'screen': 'settings'}); - final current = _settings ?? await SyncSettings.load(); - if (!mounted) return; - await Navigator.of(context).push( - 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 _sync() async { final settings = _settings ?? await SyncSettings.load(); @@ -210,6 +199,7 @@ class _CaptureScreenState extends State live: () => mounted, ); if (outcome.ok) _adoptReconciledSettings(outcome.reconciled); + unawaited(_reconcileImages()); _logEvent( 'sync_result', params: { diff --git a/lib/ui/capture_screen_images.dart b/lib/ui/capture_screen_images.dart new file mode 100644 index 0000000..d7bf78b --- /dev/null +++ b/lib/ui/capture_screen_images.dart @@ -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 _attachImages(List 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 _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]); + } +} diff --git a/lib/ui/capture_screen_sync.dart b/lib/ui/capture_screen_sync.dart index 96bc22a..d6ab9f9 100644 --- a/lib/ui/capture_screen_sync.dart +++ b/lib/ui/capture_screen_sync.dart @@ -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 _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 _autoSync() async { + Future _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 _openSettings() async { + _logEvent('screen_view', params: {'screen': 'settings'}); + final current = _settings ?? await SyncSettings.load(); + if (!mounted) return; + await Navigator.of(context).push( + 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( @@ -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( diff --git a/lib/ui/capture_screen_widget.dart b/lib/ui/capture_screen_widget.dart index 25cc767..688b4c5 100644 --- a/lib/ui/capture_screen_widget.dart +++ b/lib/ui/capture_screen_widget.dart @@ -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); diff --git a/lib/ui/cloud_images_settings.dart b/lib/ui/cloud_images_settings.dart new file mode 100644 index 0000000..f9a1007 --- /dev/null +++ b/lib/ui/cloud_images_settings.dart @@ -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( + 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 createState() => _CloudImagesScreenState(); +} + +class _CloudImagesScreenState extends State { + 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 _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 _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, + ), + ], + ), + ); + } +} diff --git a/lib/ui/image_attach.dart b/lib/ui/image_attach.dart new file mode 100644 index 0000000..eb318af --- /dev/null +++ b/lib/ui/image_attach.dart @@ -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 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 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 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 attachImages( + BuildContext context, { + required String Function() noteId, + required ImageAttachController target, + required List images, + int rejected = 0, +}) async { + final store = ImageStoreScope.read(context); + final refs = []; + 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', + ); + } +} diff --git a/lib/ui/image_input.dart b/lib/ui/image_input.dart new file mode 100644 index 0000000..27c6b32 --- /dev/null +++ b/lib/ui/image_input.dart @@ -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'; diff --git a/lib/ui/image_input_files.dart b/lib/ui/image_input_files.dart new file mode 100644 index 0000000..64edac4 --- /dev/null +++ b/lib/ui/image_input_files.dart @@ -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 images, int rejected}) partitionImageFiles(List mimeTypes) { + final images = [ + for (var i = 0; i < mimeTypes.length; i++) + if (mimeTypes[i].toLowerCase().startsWith('image/')) i, + ]; + return (images: images, rejected: mimeTypes.length - images.length); +} diff --git a/lib/ui/image_input_io.dart b/lib/ui/image_input_io.dart new file mode 100644 index 0000000..f5bb2d2 --- /dev/null +++ b/lib/ui/image_input_io.dart @@ -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 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; +} diff --git a/lib/ui/image_input_web.dart b/lib/ui/image_input_web.dart new file mode 100644 index 0000000..5a7ada4 --- /dev/null +++ b/lib/ui/image_input_web.dart @@ -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 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 createState() => _ImageInputListenerState(); +} + +class _ImageInputListenerState extends State { + 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 []; + 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 _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 = [ + 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; +} diff --git a/lib/ui/image_picking.dart b/lib/ui/image_picking.dart new file mode 100644 index 0000000..ed05680 --- /dev/null +++ b/lib/ui/image_picking.dart @@ -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> 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> pickImages( + BuildContext context, { + Future> 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( + 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> _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> _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> 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); + }, + ); + } +} diff --git a/lib/ui/images/image_embed.dart b/lib/ui/images/image_embed.dart new file mode 100644 index 0000000..014f3dd --- /dev/null +++ b/lib/ui/images/image_embed.dart @@ -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( + 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, + ), + ), + ), + ); + } +} diff --git a/lib/ui/images/image_strip.dart b/lib/ui/images/image_strip.dart new file mode 100644 index 0000000..c2010d3 --- /dev/null +++ b/lib/ui/images/image_strip.dart @@ -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]), + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui/images/image_viewer_screen.dart b/lib/ui/images/image_viewer_screen.dart new file mode 100644 index 0000000..89567e4 --- /dev/null +++ b/lib/ui/images/image_viewer_screen.dart @@ -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)), + ), + ); + } +} diff --git a/lib/ui/images/local_image.dart b/lib/ui/images/local_image.dart new file mode 100644 index 0000000..49d90cd --- /dev/null +++ b/lib/ui/images/local_image.dart @@ -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'; diff --git a/lib/ui/images/local_image_io.dart b/lib/ui/images/local_image_io.dart new file mode 100644 index 0000000..2ae26e8 --- /dev/null +++ b/lib/ui/images/local_image_io.dart @@ -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); diff --git a/lib/ui/images/local_image_web.dart b/lib/ui/images/local_image_web.dart new file mode 100644 index 0000000..1778d30 --- /dev/null +++ b/lib/ui/images/local_image_web.dart @@ -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(); diff --git a/lib/ui/markdown_view.dart b/lib/ui/markdown_view.dart index ab4d098..8ebc9f1 100644 --- a/lib/ui/markdown_view.dart +++ b/lib/ui/markdown_view.dart @@ -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( diff --git a/lib/ui/note_detail_screen.dart b/lib/ui/note_detail_screen.dart index 12fc136..c5de417 100644 --- a/lib/ui/note_detail_screen.dart +++ b/lib/ui/note_detail_screen.dart @@ -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; + /// The 📎 picker; null uses the platform one. Injected by tests. + final ImagePicking? imagePicking; + @override State createState() => _NoteDetailScreenState(); } @@ -39,6 +49,18 @@ class NoteDetailScreen extends StatefulWidget { class _NoteDetailScreenState extends State { late Note _note = widget.note; + /// Inserts attached-image links into the editor. + final ImageAttachController _attach = ImageAttachController(); + + Future _attachImages(List images, {int rejected = 0}) => + attachImages( + context, + noteId: () => _note.id, + target: _attach, + images: images, + rejected: rejected, + ); + Future _persist(Note next) async { setState(() => _note = next); await widget.repository.upsert(next); @@ -65,6 +87,10 @@ class _NoteDetailScreenState extends State { 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,23 +98,29 @@ class _NoteDetailScreenState extends State { ), ], ), - body: 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. - child: ValueListenableBuilder( - valueListenable: widget.appSettings, - builder: (context, settings, _) => NoteForm( - advancedMode: settings.advancedMode, - initialText: _note.text, - priority: _note.priority, - status: _note.status, - onPriorityChanged: (p) => _persist( - _note.copyWith(priority: p, updatedAt: DateTime.now()), + 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. + child: ValueListenableBuilder( + valueListenable: widget.appSettings, + builder: (context, settings, _) => NoteForm( + advancedMode: settings.advancedMode, + initialText: _note.text, + priority: _note.priority, + status: _note.status, + onPriorityChanged: (p) => _persist( + _note.copyWith(priority: p, updatedAt: DateTime.now()), + ), + onStatusChanged: (s) => _persist( + _note.copyWith(status: s, updatedAt: DateTime.now()), + ), + onChanged: _onTextChanged, + attachController: _attach, ), - onStatusChanged: (s) => - _persist(_note.copyWith(status: s, updatedAt: DateTime.now())), - onChanged: _onTextChanged, ), ), ), diff --git a/lib/ui/note_editor.dart b/lib/ui/note_editor.dart index bc70c16..e9ecb45 100644 --- a/lib/ui/note_editor.dart +++ b/lib/ui/note_editor.dart @@ -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 { /// [_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 { 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 { widget.onChromeVisibleChanged( !_enteringGuided && _mode != NoteEditorMode.guided, ); + widget.onModeChanged?.call(_mode); } /// Commits the wizard's template choice and enters the bare stepper. diff --git a/lib/ui/note_editor_images.dart b/lib/ui/note_editor_images.dart new file mode 100644 index 0000000..956eb51 --- /dev/null +++ b/lib/ui/note_editor_images.dart @@ -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 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), + ); +} diff --git a/lib/ui/note_editor_widget.dart b/lib/ui/note_editor_widget.dart index f6f333c..428e300 100644 --- a/lib/ui/note_editor_widget.dart +++ b/lib/ui/note_editor_widget.dart @@ -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? onModeChanged; + @override State createState() => _NoteEditorState(); } diff --git a/lib/ui/note_form.dart b/lib/ui/note_form.dart index 193456c..65b27d2 100644 --- a/lib/ui/note_form.dart +++ b/lib/ui/note_form.dart @@ -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 createState() => _NoteFormState(); +} + +class _NoteFormState extends State { + // The live text, for the thumbnail strip only: a notifier so a keystroke + // rebuilds the strip, not the editor. + late final ValueNotifier _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( + valueListenable: _text, + builder: (context, text, _) => ImageStrip(text: text), + ), ], ); }, diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart index 69fd2a9..11d0b7e 100644 --- a/lib/ui/settings_screen.dart +++ b/lib/ui/settings_screen.dart @@ -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 { 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 { trailing: const Icon(Icons.chevron_right), onTap: () => unawaited(_openSyncSettings()), ), + CloudImagesTile(httpClient: widget.httpClient), ListTile( contentPadding: EdgeInsets.zero, title: const Text('Advanced sync (GitHub)'), diff --git a/pubspec.lock b/pubspec.lock index c421047..3cf487f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 1b67d61..470ac82 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/test/attachments/image_password_store_test.dart b/test/attachments/image_password_store_test.dart new file mode 100644 index 0000000..a7191f4 --- /dev/null +++ b/test/attachments/image_password_store_test.dart @@ -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); + }); +} diff --git a/test/attachments/image_store_api_test.dart b/test/attachments/image_store_api_test.dart new file mode 100644 index 0000000..9e6eb7d --- /dev/null +++ b/test/attachments/image_store_api_test.dart @@ -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()), + ); + await store.reconcile(['x']); + expect(store.sourceFor(ref), isA()); + 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()); + }); +} diff --git a/test/attachments/image_store_io_test.dart b/test/attachments/image_store_io_test.dart new file mode 100644 index 0000000..c4dba21 --- /dev/null +++ b/test/attachments/image_store_io_test.dart @@ -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 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()); + 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()), + ); + 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()); + }); + + test('the default prepare and client are the real ones', () { + expect( + IoImageStore( + ImageCacheStore(root), + passwords: const ImagePasswordStore(), + ), + isA(), + ); + }); +} diff --git a/test/attachments/image_store_web_test.dart b/test/attachments/image_store_web_test.dart new file mode 100644 index 0000000..ba27793 --- /dev/null +++ b/test/attachments/image_store_web_test.dart @@ -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 = []; + 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': []}); + expect(notified, 1); + expect(store.sourceFor(ref), isA()); + 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()), + ); + await expectLater( + make( + (_) => throw const SocketException('no wrapper'), + ).attach('n1', Uint8List(1)), + throwsA( + isA().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()); + }); +} diff --git a/test/capture_screen_harness.dart b/test/capture_screen_harness.dart index 797bfdc..1646fb2 100644 --- a/test/capture_screen_harness.dart +++ b/test/capture_screen_harness.dart @@ -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 pumpCapture( // behavior pass advancedMode: false explicitly. ValueNotifier? appSettings, AnalyticsService? analytics, + ImageStore? imageStore, + ImagePicking? imagePicking, }) async { SharedPreferences.setMockInitialValues(prefs); installFakeSecureStorage(); @@ -67,23 +72,25 @@ Future pumpCapture( writer: (_) async {}, debounce: Duration.zero, ); - await tester.pumpWidget( - MaterialApp( - home: CaptureScreen( - repository: repo, - appSettings: - appSettings ?? ValueNotifier(const AppSettings(advancedMode: true)), - analytics: analytics, - httpClient: httpClient, - // Both injected so the widget never reaches for the platform: the - // real factories want the OS keystore and an application-support - // directory, neither of which exists under `flutter test`. - firebaseFactory: firebaseFactory ?? () async => null, - stateStore: InMemorySyncStateStore(), - localBackup: backup, - ), + final app = MaterialApp( + home: CaptureScreen( + repository: repo, + appSettings: + appSettings ?? ValueNotifier(const AppSettings(advancedMode: true)), + analytics: analytics, + httpClient: httpClient, + // Both injected so the widget never reaches for the platform: the + // real factories want the OS keystore and an application-support + // directory, neither of which exists under `flutter test`. + 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; } diff --git a/test/desktop/dufs_login_env_test.dart b/test/desktop/dufs_login_env_test.dart new file mode 100644 index 0000000..97e68cd --- /dev/null +++ b/test/desktop/dufs_login_env_test.dart @@ -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); + }); +} diff --git a/test/desktop/wrapper_image_routes_test.dart b/test/desktop/wrapper_image_routes_test.dart new file mode 100644 index 0000000..7832a0e --- /dev/null +++ b/test/desktop/wrapper_image_routes_test.dart @@ -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 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> status() async => + jsonDecode((await http.get(Uri.parse('$base/images/status'))).body) + as Map; + 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': []}); + 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'); + }); +} diff --git a/test/fake_image_store.dart b/test/fake_image_store.dart new file mode 100644 index 0000000..da4093c --- /dev/null +++ b/test/fake_image_store.dart @@ -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 attached = {}; + + /// Note texts passed to each reconcile. + final List> reconciles = []; + + /// Refs reported as not on this device. + final Set missing = {}; + + /// Refs reported as waiting to upload. + final Set 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? gate; + + @override + Future 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 reconcile(Iterable 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 status() async => + ImageStoreStatus(configured: true, pending: pending.length); + + /// Lets a test fire a change notification. + void changed() => notifyListeners(); +} diff --git a/test/images/dufs_image_client_test.dart b/test/images/dufs_image_client_test.dart new file mode 100644 index 0000000..7a1e7b3 --- /dev/null +++ b/test/images/dufs_image_client_test.dart @@ -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 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 send(http.BaseRequest request) => + throw UnimplementedError(); + + @override + void close() => onClose(); +} diff --git a/test/images/fake_dufs.dart b/test/images/fake_dufs.dart new file mode 100644 index 0000000..56862a5 --- /dev/null +++ b/test/images/fake_dufs.dart @@ -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> files = {}; + + /// When false every request fails like a dropped connection. + bool online = true; + + /// Every request's `METHOD path`, in order. + final List 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(), + ); +} diff --git a/test/images/image_cache_store_test.dart b/test/images/image_cache_store_test.dart new file mode 100644 index 0000000..0b5e542 --- /dev/null +++ b/test/images/image_cache_store_test.dart @@ -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.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()), + ); + File(boom.root.path).deleteSync(); + final report = await boom.reconcile({}, null); + expect(report.pending, 0); + }); +} diff --git a/test/images/image_processing_test.dart b/test/images/image_processing_test.dart new file mode 100644 index 0000000..c11b8ad --- /dev/null +++ b/test/images/image_processing_test.dart @@ -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); + }); +} diff --git a/test/images/image_ref_test.dart b/test/images/image_ref_test.dart new file mode 100644 index 0000000..0abad2c --- /dev/null +++ b/test/images/image_ref_test.dart @@ -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); + }); +} diff --git a/test/ui_images/capture_images_test.dart b/test/ui_images/capture_images_test.dart new file mode 100644 index 0000000..7cfc480 --- /dev/null +++ b/test/ui_images/capture_images_test.dart @@ -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(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); + }); +} diff --git a/test/ui_images/cloud_images_settings_test.dart b/test/ui_images/cloud_images_settings_test.dart new file mode 100644 index 0000000..c98d302 --- /dev/null +++ b/test/ui_images/cloud_images_settings_test.dart @@ -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 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 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(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(); + 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); + }); +} diff --git a/test/ui_images/detail_and_misc_images_test.dart b/test/ui_images/detail_and_misc_images_test.dart new file mode 100644 index 0000000..2e72d6b --- /dev/null +++ b/test/ui_images/detail_and_misc_images_test.dart @@ -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(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(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)); + }); + }); +} diff --git a/test/ui_images/image_attach_test.dart b/test/ui_images/image_attach_test.dart new file mode 100644 index 0000000..7a003b4 --- /dev/null +++ b/test/ui_images/image_attach_test.dart @@ -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 = >[]; + 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 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 = >[]; + 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(); + 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); + }); +} diff --git a/test/ui_images/image_embed_test.dart b/test/ui_images/image_embed_test.dart new file mode 100644 index 0000000..3137767 --- /dev/null +++ b/test/ui_images/image_embed_test.dart @@ -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 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.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.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); + }); +} diff --git a/test/ui_images/image_picking_test.dart b/test/ui_images/image_picking_test.dart new file mode 100644 index 0000000..b0c1dcf --- /dev/null +++ b/test/ui_images/image_picking_test.dart @@ -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 = >[]; + var result = []; + 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?> openSheet( + WidgetTester tester, + List asked, + ) async { + List? 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 = []; + 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 = []; + await openSheet(tester, asked); + await tester.tapAt(const Offset(10, 10)); + await tester.pumpAndSettle(); + expect(asked, isEmpty); + }); +} diff --git a/test/ui_images/note_editor_images_test.dart b/test/ui_images/note_editor_images_test.dart new file mode 100644 index 0000000..6b48105 --- /dev/null +++ b/test/ui_images/note_editor_images_test.dart @@ -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 emitted; + + Future pump( + WidgetTester tester, { + String text = '', + NoteTemplate? template, + NoteEditorMode mode = NoteEditorMode.raw, + ValueChanged? 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(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 = []; + 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); + }); +} diff --git a/test/wrapper_server_harness.dart b/test/wrapper_server_harness.dart index 3a06e2d..1bb6cfc 100644 --- a/test/wrapper_server_harness.dart +++ b/test/wrapper_server_harness.dart @@ -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 enabledOrigin( Map 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: diff --git a/tool/image_e2e_wrapper.dart b/tool/image_e2e_wrapper.dart new file mode 100644 index 0000000..c732aa4 --- /dev/null +++ b/tool/image_e2e_wrapper.dart @@ -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 main(List 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)'); +}