mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-11-10 18:07:03 +01:00
Attach images to notes, stored on dufs and kept offline
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9edcc76569
commit
0798f7b1d0
25
AGENTS.md
25
AGENTS.md
@ -68,6 +68,7 @@ Three layers under `lib/`, each single-purpose:
|
|||||||
token. `sync_settings.dart`: persisted owner/repo/token/clientId.
|
token. `sync_settings.dart`: persisted owner/repo/token/clientId.
|
||||||
- `notes_markdown.dart`: round-trippable single-file export/import format
|
- `notes_markdown.dart`: round-trippable single-file export/import format
|
||||||
(HTML-comment `<!-- @note id=… priority=… status=… … -->` markers).
|
(HTML-comment `<!-- @note id=… priority=… status=… … -->` markers).
|
||||||
|
- `images/` + `attachments/` — attached images (see "Images" below).
|
||||||
- `ui/` — screens, all take an injected `NoteRepository`.
|
- `ui/` — screens, all take an injected `NoteRepository`.
|
||||||
- `capture_screen.dart`: landing screen; always-focused text box pre-filled
|
- `capture_screen.dart`: landing screen; always-focused text box pre-filled
|
||||||
with the structured template; lazy note creation on first keystroke.
|
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 →
|
save. `tool/migrate_backlog.dart` converts an old export (Settings → Export →
|
||||||
migrate → Settings → Import).
|
migrate → Settings → Import).
|
||||||
|
|
||||||
|
### Images
|
||||||
|
|
||||||
|
A note never holds image bytes, only a line
|
||||||
|
``
|
||||||
|
(`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
|
## Testing
|
||||||
|
|
||||||
- **The suite must stay quick and fully green.** It is currently **313 tests at
|
- **The suite must stay quick and fully green.** It is currently **418 tests at
|
||||||
100% line coverage** (2005 lines), running well under a minute on this
|
100% line coverage** (2494 lines), ~30s on this machine with
|
||||||
machine. Don't regress either.
|
`--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
|
(The "~5s" figure this file used to quote had drifted well before the port
|
||||||
guard was added; measure before quoting a new one.)
|
guard was added; measure before quoting a new one.)
|
||||||
- Widget tests use `test/fake_note_repository.dart` — a `FakeNoteRepository`
|
- Widget tests use `test/fake_note_repository.dart` — a `FakeNoteRepository`
|
||||||
|
|||||||
56
lib/attachments/image_password_store.dart
Normal file
56
lib/attachments/image_password_store.dart
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
/// The phone's copy of the `todo` dufs login password, in the OS keystore.
|
||||||
|
///
|
||||||
|
/// Created on the PC by `add_dufs_login.sh todo /todo-images rw` and pasted
|
||||||
|
/// into Settings → Cloud images. The desktop never uses this: its wrapper
|
||||||
|
/// reads `~/.config/dufs/logins/todo.env` instead.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
import 'package:todo/images/dufs_image_client.dart';
|
||||||
|
|
||||||
|
/// The login name `add_dufs_login.sh` was run with.
|
||||||
|
const kDufsImagesUser = 'todo';
|
||||||
|
|
||||||
|
/// Reads and writes the password; failures read as "no password".
|
||||||
|
class ImagePasswordStore {
|
||||||
|
/// Creates a store over [storage] (injectable for tests).
|
||||||
|
const ImagePasswordStore({FlutterSecureStorage? storage})
|
||||||
|
: _storage = storage ?? const FlutterSecureStorage();
|
||||||
|
|
||||||
|
final FlutterSecureStorage _storage;
|
||||||
|
|
||||||
|
static const _key = 'dufsImagesPassword';
|
||||||
|
|
||||||
|
/// The saved password, or '' when none is set or the keystore is
|
||||||
|
/// unavailable.
|
||||||
|
Future<String> read() async {
|
||||||
|
try {
|
||||||
|
return await _storage.read(key: _key) ?? '';
|
||||||
|
} on Exception {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves [password] (an empty one deletes it); false if the keystore is
|
||||||
|
/// unavailable.
|
||||||
|
Future<bool> write(String password) async {
|
||||||
|
try {
|
||||||
|
if (password.isEmpty) {
|
||||||
|
await _storage.delete(key: _key);
|
||||||
|
} else {
|
||||||
|
await _storage.write(key: _key, value: password);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} on Exception {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full login, or null when no password is saved.
|
||||||
|
Future<DufsLogin?> login() async {
|
||||||
|
final password = await read();
|
||||||
|
if (password.isEmpty) return null;
|
||||||
|
return DufsLogin(user: kDufsImagesUser, password: password);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
lib/attachments/image_store.dart
Normal file
7
lib/attachments/image_store.dart
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
/// `openImageStore()` for the current platform: app storage + direct dufs on
|
||||||
|
/// Android, the local wrapper's `/images/*` routes on the desktop web build.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'image_store_api.dart';
|
||||||
|
export 'image_store_io.dart'
|
||||||
|
if (dart.library.js_interop) 'image_store_web.dart';
|
||||||
113
lib/attachments/image_store_api.dart
Normal file
113
lib/attachments/image_store_api.dart
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
/// What the UI asks of attached images, independent of platform.
|
||||||
|
///
|
||||||
|
/// Android keeps images itself (an `ImageCacheStore` in app storage, talking
|
||||||
|
/// to dufs directly); the desktop web page delegates everything to the local
|
||||||
|
/// wrapper's `/images/*` routes. Both implement [ImageStore], picked by the
|
||||||
|
/// conditional export in `image_store.dart`.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'package:todo/images/image_ref.dart';
|
||||||
|
|
||||||
|
/// Where to draw an image from right now.
|
||||||
|
sealed class ImageSource {
|
||||||
|
const ImageSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file on this device.
|
||||||
|
final class LocalImageSource extends ImageSource {
|
||||||
|
/// Creates a source for the file at [path].
|
||||||
|
const LocalImageSource(this.path);
|
||||||
|
|
||||||
|
/// Absolute path of the cached JPEG.
|
||||||
|
final String path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A same-origin URL served by the desktop wrapper.
|
||||||
|
final class NetworkImageSource extends ImageSource {
|
||||||
|
/// Creates a source for [url].
|
||||||
|
const NetworkImageSource(this.url);
|
||||||
|
|
||||||
|
/// The wrapper URL, e.g. `http://localhost:8730/images/<noteId>/<uuid>.jpg`.
|
||||||
|
final String url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Not on this device yet (it downloads on the next reconcile).
|
||||||
|
final class MissingImageSource extends ImageSource {
|
||||||
|
/// The one instance.
|
||||||
|
const MissingImageSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summary for the settings screen.
|
||||||
|
class ImageStoreStatus {
|
||||||
|
/// Creates a status.
|
||||||
|
const ImageStoreStatus({required this.configured, required this.pending});
|
||||||
|
|
||||||
|
/// Whether a dufs login is available to this device.
|
||||||
|
final bool configured;
|
||||||
|
|
||||||
|
/// Images waiting to upload.
|
||||||
|
final int pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thrown by [ImageStore.attach] when the image could not be stored at all.
|
||||||
|
class ImageStoreException implements Exception {
|
||||||
|
/// Creates an exception carrying [message].
|
||||||
|
const ImageStoreException(this.message);
|
||||||
|
|
||||||
|
/// What went wrong, shown to the user.
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores, shows and syncs the images notes link to. Notifies when an image
|
||||||
|
/// becomes available or changes state, so embeds can redraw.
|
||||||
|
abstract class ImageStore extends ChangeNotifier {
|
||||||
|
/// Stores [bytes] (any image format) for [noteId] and queues the upload.
|
||||||
|
///
|
||||||
|
/// Returns the new ref, or null when [bytes] is not an image. Throws
|
||||||
|
/// [ImageStoreException] when it cannot be stored.
|
||||||
|
Future<ImageRef?> attach(String noteId, Uint8List bytes);
|
||||||
|
|
||||||
|
/// Uploads the queue, fetches every image [noteTexts] link to that this
|
||||||
|
/// device lacks, and evicts cached images no note links to.
|
||||||
|
Future<void> reconcile(Iterable<String> noteTexts);
|
||||||
|
|
||||||
|
/// Where to draw [ref] from.
|
||||||
|
ImageSource sourceFor(ImageRef ref);
|
||||||
|
|
||||||
|
/// Whether [ref] is still waiting to upload (always false when unknown).
|
||||||
|
bool isPending(ImageRef ref);
|
||||||
|
|
||||||
|
/// Login and queue summary.
|
||||||
|
Future<ImageStoreStatus> status();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every image ref across [noteTexts].
|
||||||
|
Set<ImageRef> imageRefsAcross(Iterable<String> noteTexts) => {
|
||||||
|
for (final text in noteTexts) ...imageRefsIn(text),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The store used where none was provided (tests, and any screen built
|
||||||
|
/// outside the app root): attaching is refused, nothing is ever available.
|
||||||
|
class NoImageStore extends ImageStore {
|
||||||
|
@override
|
||||||
|
Future<ImageRef?> attach(String noteId, Uint8List bytes) async =>
|
||||||
|
throw const ImageStoreException('Images are not available here');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reconcile(Iterable<String> noteTexts) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
ImageSource sourceFor(ImageRef ref) => const MissingImageSource();
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool isPending(ImageRef ref) => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ImageStoreStatus> status() async =>
|
||||||
|
const ImageStoreStatus(configured: false, pending: 0);
|
||||||
|
}
|
||||||
101
lib/attachments/image_store_io.dart
Normal file
101
lib/attachments/image_store_io.dart
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
/// Android: images live in app storage and sync with dufs directly.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import 'package:todo/attachments/image_password_store.dart';
|
||||||
|
import 'package:todo/attachments/image_store_api.dart';
|
||||||
|
import 'package:todo/images/dufs_image_client.dart';
|
||||||
|
import 'package:todo/images/image_cache_store.dart';
|
||||||
|
import 'package:todo/images/image_processing.dart';
|
||||||
|
import 'package:todo/images/image_ref.dart';
|
||||||
|
|
||||||
|
/// Opens the store under the app-support directory's `images/`.
|
||||||
|
// coverage:ignore-start
|
||||||
|
// Platform wiring only: path_provider has no binding in unit tests, which
|
||||||
|
// construct [IoImageStore] over a temp directory instead.
|
||||||
|
Future<ImageStore> openImageStore() async {
|
||||||
|
final support = await getApplicationSupportDirectory();
|
||||||
|
final root = Directory(p.join(support.path, 'images'));
|
||||||
|
return IoImageStore(ImageCacheStore(root));
|
||||||
|
}
|
||||||
|
// coverage:ignore-end
|
||||||
|
|
||||||
|
/// [ImageStore] over a local [ImageCacheStore], uploading with the login
|
||||||
|
/// from [ImagePasswordStore].
|
||||||
|
class IoImageStore extends ImageStore {
|
||||||
|
/// Creates a store over [cache]; the rest is injectable for tests.
|
||||||
|
IoImageStore(
|
||||||
|
this.cache, {
|
||||||
|
this._passwords = const ImagePasswordStore(),
|
||||||
|
http.Client Function()? httpClient,
|
||||||
|
Future<Uint8List?> Function(Uint8List)? prepare,
|
||||||
|
}) : _httpClient = httpClient ?? http.Client.new,
|
||||||
|
_prepare = prepare ?? prepareImageInBackground;
|
||||||
|
|
||||||
|
/// The on-device images and upload queue.
|
||||||
|
final ImageCacheStore cache;
|
||||||
|
|
||||||
|
final ImagePasswordStore _passwords;
|
||||||
|
final http.Client Function() _httpClient;
|
||||||
|
final Future<Uint8List?> Function(Uint8List) _prepare;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ImageRef?> attach(String noteId, Uint8List bytes) async {
|
||||||
|
final jpeg = await _prepare(bytes);
|
||||||
|
if (jpeg == null) return null;
|
||||||
|
final ref = ImageRef.create(noteId);
|
||||||
|
try {
|
||||||
|
await cache.put(ref, jpeg);
|
||||||
|
} on FileSystemException catch (e) {
|
||||||
|
throw ImageStoreException('Could not save the image: ${e.message}');
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
// Upload right away when online. An empty ref set never evicts, so this
|
||||||
|
// cannot race the link reaching the note.
|
||||||
|
unawaited(_sync(const {}));
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reconcile(Iterable<String> noteTexts) =>
|
||||||
|
_sync(imageRefsAcross(noteTexts));
|
||||||
|
|
||||||
|
Future<void> _sync(Set<ImageRef> refs) async {
|
||||||
|
final login = await _passwords.login();
|
||||||
|
final client = login == null
|
||||||
|
? null
|
||||||
|
: DufsImageClient(login, httpClient: _httpClient());
|
||||||
|
try {
|
||||||
|
final report = await cache.reconcile(refs, client);
|
||||||
|
if (report.uploaded + report.downloaded + report.evicted > 0) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
client?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
ImageSource sourceFor(ImageRef ref) {
|
||||||
|
final file = cache.fileFor(ref);
|
||||||
|
return file.existsSync()
|
||||||
|
? LocalImageSource(file.path)
|
||||||
|
: const MissingImageSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool isPending(ImageRef ref) => cache.isPending(ref);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ImageStoreStatus> status() async => ImageStoreStatus(
|
||||||
|
configured: await _passwords.login() != null,
|
||||||
|
pending: cache.pending().length,
|
||||||
|
);
|
||||||
|
}
|
||||||
29
lib/attachments/image_store_scope.dart
Normal file
29
lib/attachments/image_store_scope.dart
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import 'package:todo/attachments/image_store_api.dart';
|
||||||
|
|
||||||
|
/// Makes the app's one [ImageStore] reachable from any widget below it,
|
||||||
|
/// without threading it through every screen constructor.
|
||||||
|
///
|
||||||
|
/// Screens built without a scope (most widget tests) get a shared
|
||||||
|
/// [NoImageStore], so they render and simply cannot attach.
|
||||||
|
class ImageStoreScope extends InheritedNotifier<ImageStore> {
|
||||||
|
/// Provides [store] to [child].
|
||||||
|
const ImageStoreScope({
|
||||||
|
required ImageStore store,
|
||||||
|
required super.child,
|
||||||
|
super.key,
|
||||||
|
}) : super(notifier: store);
|
||||||
|
|
||||||
|
static final ImageStore _none = NoImageStore();
|
||||||
|
|
||||||
|
/// The nearest store, subscribing [context] to its changes.
|
||||||
|
static ImageStore of(BuildContext context) =>
|
||||||
|
context.dependOnInheritedWidgetOfExactType<ImageStoreScope>()?.notifier ??
|
||||||
|
_none;
|
||||||
|
|
||||||
|
/// The nearest store, without subscribing (for callbacks).
|
||||||
|
static ImageStore read(BuildContext context) =>
|
||||||
|
context.getInheritedWidgetOfExactType<ImageStoreScope>()?.notifier ??
|
||||||
|
_none;
|
||||||
|
}
|
||||||
103
lib/attachments/image_store_web.dart
Normal file
103
lib/attachments/image_store_web.dart
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
/// Desktop (web build): every image operation goes through the local
|
||||||
|
/// wrapper's `/images/*` routes; the page never sees the dufs password.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import 'package:todo/attachments/image_store_api.dart';
|
||||||
|
import 'package:todo/images/image_ref.dart';
|
||||||
|
import 'package:todo/sync/desktop_wrapper.dart';
|
||||||
|
|
||||||
|
/// Opens the store against the wrapper that served this page.
|
||||||
|
Future<ImageStore> openImageStore() async =>
|
||||||
|
WebImageStore(origin: servingWrapperOrigin());
|
||||||
|
|
||||||
|
/// [ImageStore] backed by the desktop wrapper at [origin].
|
||||||
|
class WebImageStore extends ImageStore {
|
||||||
|
/// Creates a store talking to [origin]; [httpClient] is injectable.
|
||||||
|
WebImageStore({required this.origin, http.Client? httpClient})
|
||||||
|
: _http = httpClient ?? http.Client();
|
||||||
|
|
||||||
|
/// The wrapper's origin, e.g. `http://localhost:8730`.
|
||||||
|
final String origin;
|
||||||
|
|
||||||
|
final http.Client _http;
|
||||||
|
|
||||||
|
// Bumped whenever the wrapper may hold new bytes, so an image that failed
|
||||||
|
// to load (not downloaded yet) is requested again under a fresh URL.
|
||||||
|
int _revision = 0;
|
||||||
|
|
||||||
|
Uri _images(String rest) => Uri.parse('$origin/images/$rest');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ImageRef?> attach(String noteId, Uint8List bytes) async {
|
||||||
|
final ref = ImageRef.create(noteId);
|
||||||
|
final http.Response response;
|
||||||
|
try {
|
||||||
|
response = await _http.post(_images(ref.relativePath), body: bytes);
|
||||||
|
} on Exception {
|
||||||
|
throw const ImageStoreException(
|
||||||
|
'The desktop helper is not running; the image was not saved',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (response.statusCode == 415) return null;
|
||||||
|
if (response.statusCode != 204) {
|
||||||
|
throw ImageStoreException(
|
||||||
|
'The desktop helper refused the image (${response.statusCode})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_revision++;
|
||||||
|
notifyListeners();
|
||||||
|
unawaited(_reconcile(const {}));
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reconcile(Iterable<String> noteTexts) =>
|
||||||
|
_reconcile(imageRefsAcross(noteTexts));
|
||||||
|
|
||||||
|
Future<void> _reconcile(Set<ImageRef> refs) async {
|
||||||
|
try {
|
||||||
|
final response = await _http.post(
|
||||||
|
_images('reconcile'),
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'refs': [for (final ref in refs) ref.relativePath],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) return;
|
||||||
|
final report = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
if ((report['downloaded'] as int? ?? 0) > 0) {
|
||||||
|
_revision++;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
} on Exception {
|
||||||
|
// Best-effort: the next sync, resume or attach reconciles again.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
ImageSource sourceFor(ImageRef ref) =>
|
||||||
|
NetworkImageSource('$origin/images/${ref.relativePath}?r=$_revision');
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool isPending(ImageRef ref) => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ImageStoreStatus> status() async {
|
||||||
|
try {
|
||||||
|
final response = await _http.get(_images('status'));
|
||||||
|
final body = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return ImageStoreStatus(
|
||||||
|
configured: body['configured'] as bool? ?? false,
|
||||||
|
pending: body['pending'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
} on Exception {
|
||||||
|
return const ImageStoreStatus(configured: false, pending: 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,6 +17,7 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:todo/data/note_template.dart';
|
import 'package:todo/data/note_template.dart';
|
||||||
|
import 'package:todo/images/image_ref.dart';
|
||||||
|
|
||||||
/// Matches a single `http(s)://…` token.
|
/// Matches a single `http(s)://…` token.
|
||||||
final RegExp _linkPattern = RegExp(r'^https?://\S+$');
|
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 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) {
|
String noteTitle(String text) {
|
||||||
|
var images = 0;
|
||||||
for (final line in text.split('\n')) {
|
for (final line in text.split('\n')) {
|
||||||
final trimmed = line.trim();
|
final trimmed = line.trim();
|
||||||
if (trimmed.isEmpty) continue;
|
if (trimmed.isEmpty) continue;
|
||||||
|
if (imageRefOfLine(trimmed) != null) {
|
||||||
|
images++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
return trimmed.replaceFirst(RegExp(r'^#+\s*'), '');
|
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
|
/// Returns the section whose `## label` heading is on [line], or null. Only
|
||||||
|
|||||||
@ -14,7 +14,7 @@ Future<NoteRepository> openRepository() async {
|
|||||||
final database = await WebLogPersistence.openDatabase(idbFactoryBrowser);
|
final database = await WebLogPersistence.openDatabase(idbFactoryBrowser);
|
||||||
final persistence = WebLogPersistence(
|
final persistence = WebLogPersistence(
|
||||||
database: database,
|
database: database,
|
||||||
backup: DesktopBackupClient(baseUrl: desktopWrapperOrigin),
|
backup: DesktopBackupClient(baseUrl: servingWrapperOrigin()),
|
||||||
);
|
);
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|||||||
37
lib/desktop/dufs_login_env.dart
Normal file
37
lib/desktop/dufs_login_env.dart
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
/// Reads the dufs login `add_dufs_login.sh` wrote for the desktop wrapper.
|
||||||
|
///
|
||||||
|
/// `~/src/dufs-cloud/scripts/add_dufs_login.sh todo /todo-images rw` leaves a
|
||||||
|
/// 0600 `~/.config/dufs/logins/todo.env` of `KEY=value` lines. The wrapper
|
||||||
|
/// re-reads it on every reconcile, so creating or rotating the login takes
|
||||||
|
/// effect without restarting the desktop app.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
import 'package:todo/images/dufs_image_client.dart';
|
||||||
|
|
||||||
|
/// `~/.config/dufs/logins/todo.env` under [home].
|
||||||
|
String defaultDufsLoginEnvPath(String home) =>
|
||||||
|
p.join(home, '.config', 'dufs', 'logins', 'todo.env');
|
||||||
|
|
||||||
|
/// The login in [envFile], or null when it is absent or incomplete.
|
||||||
|
DufsLogin? readDufsLoginEnv(File envFile) {
|
||||||
|
if (!envFile.existsSync()) return null;
|
||||||
|
final values = <String, String>{};
|
||||||
|
for (final line in envFile.readAsLinesSync()) {
|
||||||
|
final eq = line.indexOf('=');
|
||||||
|
if (eq <= 0 || line.startsWith('#')) continue;
|
||||||
|
values[line.substring(0, eq).trim()] = line.substring(eq + 1).trim();
|
||||||
|
}
|
||||||
|
final user = values['DUFS_USER'] ?? '';
|
||||||
|
final password = values['DUFS_PASSWORD'] ?? '';
|
||||||
|
final url = values['DUFS_URL'] ?? '';
|
||||||
|
if (user.isEmpty || password.isEmpty || url.isEmpty) return null;
|
||||||
|
return DufsLogin(
|
||||||
|
user: user,
|
||||||
|
password: password,
|
||||||
|
baseUrl: url.endsWith('/') ? url.substring(0, url.length - 1) : url,
|
||||||
|
);
|
||||||
|
}
|
||||||
159
lib/desktop/wrapper_image_routes.dart
Normal file
159
lib/desktop/wrapper_image_routes.dart
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
/// The desktop wrapper's `/images/*` routes: the web page's only way to
|
||||||
|
/// store, show and sync attached images.
|
||||||
|
///
|
||||||
|
/// The page never talks to dufs itself. That keeps the dufs password out of
|
||||||
|
/// the browser, needs no CORS on dufs, and gives the desktop the same offline
|
||||||
|
/// store the phone has (an [ImageCacheStore] on disk). Same-origin with the
|
||||||
|
/// page, so an `<img src="/images/…">` just works.
|
||||||
|
///
|
||||||
|
/// * `POST /images/<noteId>/<uuid>.jpg` — raw bytes of any image; re-encoded
|
||||||
|
/// here (the page must not decode on its UI thread), stored, queued.
|
||||||
|
/// 415 when the bytes are not an image.
|
||||||
|
/// * `GET /images/<noteId>/<uuid>.jpg` — the cached JPEG, 404 when absent.
|
||||||
|
/// * `POST /images/reconcile` — `{"refs": ["<noteId>/<uuid>.jpg", …]}`: every
|
||||||
|
/// image the notes link to. Uploads the queue, fetches what is missing,
|
||||||
|
/// evicts the rest; answers with the [ReconcileReport].
|
||||||
|
/// * `GET /images/status` — `{"configured": bool, "pending": n}`.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import 'package:todo/desktop/dufs_login_env.dart';
|
||||||
|
import 'package:todo/desktop/wrapper_paths.dart';
|
||||||
|
import 'package:todo/images/dufs_image_client.dart';
|
||||||
|
import 'package:todo/images/image_cache_store.dart';
|
||||||
|
import 'package:todo/images/image_processing.dart';
|
||||||
|
import 'package:todo/images/image_ref.dart';
|
||||||
|
|
||||||
|
/// Largest upload accepted from the page, before re-encoding (a raw 50 MP
|
||||||
|
/// phone photo is ~25 MB).
|
||||||
|
const int kMaxImageUploadBytes = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// The routes the real wrapper uses: cache under
|
||||||
|
/// `~/.local/share/todo-desktop/images`, login from `todo.env`.
|
||||||
|
WrapperImageRoutes defaultWrapperImageRoutes(String home) => WrapperImageRoutes(
|
||||||
|
store: ImageCacheStore(Directory(defaultImageCachePath(home))),
|
||||||
|
loginEnvPath: defaultDufsLoginEnvPath(home),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Handles `/images/*` for the wrapper.
|
||||||
|
class WrapperImageRoutes {
|
||||||
|
/// Creates the routes over [store], reading the dufs login from
|
||||||
|
/// [loginEnvPath]. [httpClient] and [prepare] are injectable for tests.
|
||||||
|
WrapperImageRoutes({
|
||||||
|
required this.store,
|
||||||
|
required this.loginEnvPath,
|
||||||
|
http.Client Function()? httpClient,
|
||||||
|
Future<Uint8List?> Function(Uint8List)? prepare,
|
||||||
|
}) : _httpClient = httpClient ?? http.Client.new,
|
||||||
|
_prepare = prepare ?? prepareImageInBackground;
|
||||||
|
|
||||||
|
/// The on-disk image cache and upload queue.
|
||||||
|
final ImageCacheStore store;
|
||||||
|
|
||||||
|
/// `todo.env` written by `add_dufs_login.sh`.
|
||||||
|
final String loginEnvPath;
|
||||||
|
|
||||||
|
final http.Client Function() _httpClient;
|
||||||
|
final Future<Uint8List?> Function(Uint8List) _prepare;
|
||||||
|
|
||||||
|
static const _prefix = '/images/';
|
||||||
|
|
||||||
|
/// Answers [request] and returns true when it is an `/images/` route.
|
||||||
|
Future<bool> handle(HttpRequest request) async {
|
||||||
|
final path = request.uri.path;
|
||||||
|
if (!path.startsWith(_prefix)) return false;
|
||||||
|
final rest = path.substring(_prefix.length);
|
||||||
|
final response = request.response;
|
||||||
|
final method = request.method;
|
||||||
|
if (rest == 'reconcile' && method == 'POST') {
|
||||||
|
await _reconcile(request);
|
||||||
|
} else if (rest == 'status' && method == 'GET') {
|
||||||
|
_json(response, {
|
||||||
|
'configured': _login() != null,
|
||||||
|
'pending': store.pending().length,
|
||||||
|
});
|
||||||
|
} else if (ImageRef.tryParse(rest) case final ref?) {
|
||||||
|
if (method == 'GET') {
|
||||||
|
await _serve(response, ref);
|
||||||
|
} else if (method == 'POST') {
|
||||||
|
await _put(request, ref);
|
||||||
|
} else {
|
||||||
|
response.statusCode = HttpStatus.methodNotAllowed;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
response.statusCode = HttpStatus.notFound;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
DufsLogin? _login() => readDufsLoginEnv(File(loginEnvPath));
|
||||||
|
|
||||||
|
Future<void> _serve(HttpResponse response, ImageRef ref) async {
|
||||||
|
final file = store.fileFor(ref);
|
||||||
|
if (!file.existsSync()) {
|
||||||
|
response.statusCode = HttpStatus.notFound;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.headers
|
||||||
|
..contentType = ContentType('image', 'jpeg')
|
||||||
|
// The name is a fresh uuid per image, so the bytes never change.
|
||||||
|
..set(HttpHeaders.cacheControlHeader, 'private, max-age=31536000');
|
||||||
|
await response.addStream(file.openRead());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _put(HttpRequest request, ImageRef ref) async {
|
||||||
|
final builder = BytesBuilder(copy: false);
|
||||||
|
var tooLarge = false;
|
||||||
|
// Read to the end even once over the cap: answering while the browser
|
||||||
|
// is still sending makes it see a dropped connection, not the 413.
|
||||||
|
await for (final chunk in request) {
|
||||||
|
tooLarge =
|
||||||
|
tooLarge || builder.length + chunk.length > kMaxImageUploadBytes;
|
||||||
|
if (!tooLarge) builder.add(chunk);
|
||||||
|
}
|
||||||
|
if (tooLarge) {
|
||||||
|
request.response.statusCode = HttpStatus.requestEntityTooLarge;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final jpeg = await _prepare(builder.takeBytes());
|
||||||
|
if (jpeg == null) {
|
||||||
|
request.response.statusCode = HttpStatus.unsupportedMediaType;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await store.put(ref, jpeg);
|
||||||
|
request.response.statusCode = HttpStatus.noContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reconcile(HttpRequest request) async {
|
||||||
|
final Set<ImageRef> refs;
|
||||||
|
try {
|
||||||
|
final body = jsonDecode(await utf8.decodeStream(request));
|
||||||
|
refs = {
|
||||||
|
for (final raw in (body as Map<String, dynamic>)['refs'] as List)
|
||||||
|
?ImageRef.tryParse(raw as String),
|
||||||
|
};
|
||||||
|
} on Object {
|
||||||
|
request.response.statusCode = HttpStatus.badRequest;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final login = _login();
|
||||||
|
final client = login == null
|
||||||
|
? null
|
||||||
|
: DufsImageClient(login, httpClient: _httpClient());
|
||||||
|
try {
|
||||||
|
_json(request.response, (await store.reconcile(refs, client)).toJson());
|
||||||
|
} finally {
|
||||||
|
client?.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _json(HttpResponse response, Object body) {
|
||||||
|
response.headers.contentType = ContentType.json;
|
||||||
|
response.write(jsonEncode(body));
|
||||||
|
}
|
||||||
|
}
|
||||||
25
lib/desktop/wrapper_paths.dart
Normal file
25
lib/desktop/wrapper_paths.dart
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
/// Default on-disk locations the desktop wrapper reads and writes.
|
||||||
|
///
|
||||||
|
/// Split out of `wrapper_server.dart` (file-length cap); re-exported from
|
||||||
|
/// there, so callers keep importing the server.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
/// The canonical backlog file for [home], i.e. `~/src/todo/BACKLOG.md`.
|
||||||
|
///
|
||||||
|
/// Lives here rather than in `bin/todo_desktop.dart` so it is covered: the
|
||||||
|
/// entry point is `coverage:ignore-file` thin wiring, and this path silently
|
||||||
|
/// broke once already. The 2026-09-11 `~` reorganisation moved every repo
|
||||||
|
/// under `~/src`, but the migration's rewriter only matched *literal*
|
||||||
|
/// `/home/kuhy/todo` strings — a path assembled from segments was invisible
|
||||||
|
/// to it, so the wrapper went on exporting to the dead `~/todo` while the
|
||||||
|
/// `todo` MCP read `~/src/todo`. Every backlog read between 2026-09-11 and
|
||||||
|
/// 2026-09-12 got a stale file.
|
||||||
|
String defaultBacklogPath(String home) =>
|
||||||
|
p.join(home, 'src', 'todo', 'BACKLOG.md');
|
||||||
|
|
||||||
|
/// The wrapper's image cache and upload queue,
|
||||||
|
/// `~/.local/share/todo-desktop/images` — beside the note-log copy.
|
||||||
|
String defaultImageCachePath(String home) =>
|
||||||
|
p.join(home, '.local', 'share', 'todo-desktop', 'images');
|
||||||
@ -5,6 +5,10 @@ import 'dart:io';
|
|||||||
import 'package:crdt_sync/crdt_sync.dart';
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
import 'package:todo/desktop/wrapper_image_routes.dart';
|
||||||
|
|
||||||
|
export 'wrapper_paths.dart';
|
||||||
|
|
||||||
part 'wrapper_sync_routes.dart';
|
part 'wrapper_sync_routes.dart';
|
||||||
|
|
||||||
/// Route serving `todo`'s own seeded Firebase session (a `FirebaseCredentials`
|
/// Route serving `todo`'s own seeded Firebase session (a `FirebaseCredentials`
|
||||||
@ -16,19 +20,6 @@ part 'wrapper_sync_routes.dart';
|
|||||||
/// [kSyncAccountEnvVar] as [kSyncAccountPath].
|
/// [kSyncAccountEnvVar] as [kSyncAccountPath].
|
||||||
const kSyncCredentialsPath = '/sync-credentials';
|
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.
|
/// Local HTTP server backing the desktop app.
|
||||||
///
|
///
|
||||||
/// The desktop app is a Flutter **web** build (Flutter's Linux embedder manages
|
/// The desktop app is a Flutter **web** build (Flutter's Linux embedder manages
|
||||||
@ -55,7 +46,11 @@ class WrapperServer {
|
|||||||
bool? serveSyncAccount,
|
bool? serveSyncAccount,
|
||||||
String? syncConfigDir,
|
String? syncConfigDir,
|
||||||
String? todoCredentialsPath,
|
String? todoCredentialsPath,
|
||||||
}) : serveSyncAccount =
|
WrapperImageRoutes? images,
|
||||||
|
}) : images =
|
||||||
|
images ??
|
||||||
|
defaultWrapperImageRoutes(Platform.environment['HOME'] ?? ''),
|
||||||
|
serveSyncAccount =
|
||||||
serveSyncAccount ??
|
serveSyncAccount ??
|
||||||
(Platform.environment[kSyncAccountEnvVar] ?? '').isNotEmpty,
|
(Platform.environment[kSyncAccountEnvVar] ?? '').isNotEmpty,
|
||||||
syncConfigDir =
|
syncConfigDir =
|
||||||
@ -70,6 +65,9 @@ class WrapperServer {
|
|||||||
'firebase_auth.json',
|
'firebase_auth.json',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// The `/images/*` routes (attached images; see [WrapperImageRoutes]).
|
||||||
|
final WrapperImageRoutes images;
|
||||||
|
|
||||||
/// Whether the sync-account route answers; off unless explicitly enabled.
|
/// Whether the sync-account route answers; off unless explicitly enabled.
|
||||||
final bool serveSyncAccount;
|
final bool serveSyncAccount;
|
||||||
|
|
||||||
@ -152,6 +150,7 @@ class WrapperServer {
|
|||||||
if (path == kSyncCredentialsPath) {
|
if (path == kSyncCredentialsPath) {
|
||||||
return await _syncCredentials(request);
|
return await _syncCredentials(request);
|
||||||
}
|
}
|
||||||
|
if (await images.handle(request)) return;
|
||||||
return await _static(request, path);
|
return await _static(request, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -219,6 +218,8 @@ class WrapperServer {
|
|||||||
return ContentType('text', 'css', charset: 'utf-8');
|
return ContentType('text', 'css', charset: 'utf-8');
|
||||||
case '.png':
|
case '.png':
|
||||||
return ContentType('image', 'png');
|
return ContentType('image', 'png');
|
||||||
|
case '.jpg' || '.jpeg':
|
||||||
|
return ContentType('image', 'jpeg');
|
||||||
case '.svg':
|
case '.svg':
|
||||||
return ContentType('image', 'svg+xml');
|
return ContentType('image', 'svg+xml');
|
||||||
case '.ttf':
|
case '.ttf':
|
||||||
|
|||||||
114
lib/images/dufs_image_client.dart
Normal file
114
lib/images/dufs_image_client.dart
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
/// The three WebDAV calls image sync needs, against kuhy's dufs.
|
||||||
|
///
|
||||||
|
/// Deliberately not dufs-cloud's full `DufsClient` (~700 lines with listing,
|
||||||
|
/// moves, streaming progress and an xml parser): todo only ever puts, gets and
|
||||||
|
/// checks one folder. dufs creates missing parent folders on PUT (verified
|
||||||
|
/// against dufs 0.46.0), so there is no MKCOL step.
|
||||||
|
///
|
||||||
|
/// Pure Dart: the desktop wrapper imports it.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import 'package:todo/images/image_ref.dart';
|
||||||
|
|
||||||
|
/// A scoped dufs login, e.g. `todo` → `/todo-images`.
|
||||||
|
class DufsLogin {
|
||||||
|
/// Creates a login for [user] with [password] against [baseUrl].
|
||||||
|
const DufsLogin({
|
||||||
|
required this.user,
|
||||||
|
required this.password,
|
||||||
|
this.baseUrl = kDufsBaseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The login name (`add_dufs_login.sh`'s first argument).
|
||||||
|
final String user;
|
||||||
|
|
||||||
|
/// Its generated password.
|
||||||
|
final String password;
|
||||||
|
|
||||||
|
/// The dufs origin, without a trailing slash.
|
||||||
|
final String baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of [DufsImageClient.check], for the settings screen.
|
||||||
|
enum DufsCheck {
|
||||||
|
/// The login can see its folder.
|
||||||
|
ok,
|
||||||
|
|
||||||
|
/// dufs refused the login (401/403).
|
||||||
|
rejected,
|
||||||
|
|
||||||
|
/// dufs answered something else, or could not be reached.
|
||||||
|
unreachable,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload / download / check for images under [kImagesDir].
|
||||||
|
class DufsImageClient {
|
||||||
|
/// Creates a client for [login]; [httpClient] is injectable for tests.
|
||||||
|
DufsImageClient(this.login, {http.Client? httpClient})
|
||||||
|
: _http = httpClient ?? http.Client();
|
||||||
|
|
||||||
|
/// The credentials every request carries.
|
||||||
|
final DufsLogin login;
|
||||||
|
|
||||||
|
final http.Client _http;
|
||||||
|
|
||||||
|
Map<String, String> get _auth {
|
||||||
|
final pair = utf8.encode('${login.user}:${login.password}');
|
||||||
|
return {'authorization': 'Basic ${base64.encode(pair)}'};
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri _uri(String relative) =>
|
||||||
|
Uri.parse('${login.baseUrl}/$kImagesDir/$relative');
|
||||||
|
|
||||||
|
/// PUTs [bytes] as [ref]; true on a 2xx.
|
||||||
|
Future<bool> upload(ImageRef ref, Uint8List bytes) async {
|
||||||
|
try {
|
||||||
|
final response = await _http.put(
|
||||||
|
_uri(ref.relativePath),
|
||||||
|
headers: {..._auth, 'content-type': 'image/jpeg'},
|
||||||
|
body: bytes,
|
||||||
|
);
|
||||||
|
return response.statusCode >= 200 && response.statusCode < 300;
|
||||||
|
} on Exception {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GETs [ref]'s bytes, or null when it is missing or unreachable.
|
||||||
|
Future<Uint8List?> download(ImageRef ref) async {
|
||||||
|
try {
|
||||||
|
final response = await _http.get(
|
||||||
|
_uri(ref.relativePath),
|
||||||
|
headers: _auth,
|
||||||
|
);
|
||||||
|
return response.statusCode == 200 ? response.bodyBytes : null;
|
||||||
|
} on Exception {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the login can see its folder (PROPFIND, depth 0).
|
||||||
|
Future<DufsCheck> check() async {
|
||||||
|
try {
|
||||||
|
final request = http.Request('PROPFIND', _uri(''))
|
||||||
|
..headers.addAll({..._auth, 'depth': '0'});
|
||||||
|
final response = await _http.send(request);
|
||||||
|
await response.stream.drain<void>();
|
||||||
|
return switch (response.statusCode) {
|
||||||
|
207 => DufsCheck.ok,
|
||||||
|
401 || 403 => DufsCheck.rejected,
|
||||||
|
_ => DufsCheck.unreachable,
|
||||||
|
};
|
||||||
|
} on Exception {
|
||||||
|
return DufsCheck.unreachable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases the underlying HTTP client.
|
||||||
|
void close() => _http.close();
|
||||||
|
}
|
||||||
207
lib/images/image_cache_store.dart
Normal file
207
lib/images/image_cache_store.dart
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
/// The on-device copy of every image a note links to, plus the upload queue.
|
||||||
|
///
|
||||||
|
/// One directory, `<root>/<noteId>/<uuid>.jpg`, mirrors dufs' `todo-images/`.
|
||||||
|
/// An image attached while offline gets a `<uuid>.jpg.pending` marker next to
|
||||||
|
/// it; the marker is the queue, so it survives restarts with no separate
|
||||||
|
/// bookkeeping, and it is only removed after dufs has accepted the upload.
|
||||||
|
///
|
||||||
|
/// Used as-is by the Android app and by the desktop wrapper (the web page
|
||||||
|
/// never talks to dufs). Pure Dart + `dart:io`.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
|
import 'package:todo/images/dufs_image_client.dart';
|
||||||
|
import 'package:todo/images/image_ref.dart';
|
||||||
|
|
||||||
|
/// How long a cached, unreferenced image is kept before eviction.
|
||||||
|
///
|
||||||
|
/// Covers the gap between writing an image and its link reaching the note
|
||||||
|
/// store (and, on desktop, the page's next reconcile): without it a
|
||||||
|
/// reconcile landing in that gap would delete a fresh image.
|
||||||
|
const kEvictionGrace = Duration(minutes: 10);
|
||||||
|
|
||||||
|
/// Counts from one [ImageCacheStore.reconcile] pass.
|
||||||
|
class ReconcileReport {
|
||||||
|
/// Creates a report.
|
||||||
|
const ReconcileReport({
|
||||||
|
this.uploaded = 0,
|
||||||
|
this.downloaded = 0,
|
||||||
|
this.evicted = 0,
|
||||||
|
this.pending = 0,
|
||||||
|
this.missing = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Queued images dufs accepted this pass.
|
||||||
|
final int uploaded;
|
||||||
|
|
||||||
|
/// Linked images fetched from dufs this pass.
|
||||||
|
final int downloaded;
|
||||||
|
|
||||||
|
/// Unlinked cached images deleted.
|
||||||
|
final int evicted;
|
||||||
|
|
||||||
|
/// Images still waiting to upload after the pass.
|
||||||
|
final int pending;
|
||||||
|
|
||||||
|
/// Linked images still not on this device after the pass.
|
||||||
|
final int missing;
|
||||||
|
|
||||||
|
/// JSON form, for the wrapper's `/images/reconcile` response.
|
||||||
|
Map<String, int> toJson() => {
|
||||||
|
'uploaded': uploaded,
|
||||||
|
'downloaded': downloaded,
|
||||||
|
'evicted': evicted,
|
||||||
|
'pending': pending,
|
||||||
|
'missing': missing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local image files and their upload queue under [root].
|
||||||
|
class ImageCacheStore {
|
||||||
|
/// Creates a store rooted at [root]; [now] is injectable for tests.
|
||||||
|
ImageCacheStore(this.root, {DateTime Function()? now})
|
||||||
|
: _now = now ?? DateTime.now;
|
||||||
|
|
||||||
|
/// Directory holding `<noteId>/<uuid>.jpg`.
|
||||||
|
final Directory root;
|
||||||
|
|
||||||
|
final DateTime Function() _now;
|
||||||
|
|
||||||
|
// Passes run one at a time. Calls arriving before the next pass starts
|
||||||
|
// merge into it (see [reconcile]).
|
||||||
|
Future<void> _tail = Future.value();
|
||||||
|
Future<ReconcileReport>? _scheduled;
|
||||||
|
_Pass? _scheduledPass;
|
||||||
|
|
||||||
|
/// The cached file for [ref] (it may not exist).
|
||||||
|
File fileFor(ImageRef ref) => File(p.join(root.path, ref.noteId, ref.file));
|
||||||
|
|
||||||
|
File _marker(ImageRef ref) => File('${fileFor(ref).path}.pending');
|
||||||
|
|
||||||
|
/// Whether [ref] is waiting to upload.
|
||||||
|
bool isPending(ImageRef ref) => _marker(ref).existsSync();
|
||||||
|
|
||||||
|
/// Stores [bytes] as [ref] and queues it for upload.
|
||||||
|
Future<void> put(ImageRef ref, Uint8List bytes) async {
|
||||||
|
final file = fileFor(ref);
|
||||||
|
await file.parent.create(recursive: true);
|
||||||
|
// Marker first: a crash between the two writes then leaves a marker with
|
||||||
|
// no file (skipped by reconcile), never a file that silently never uploads.
|
||||||
|
await _marker(ref).writeAsString('');
|
||||||
|
final tmp = File('${file.path}.tmp');
|
||||||
|
await tmp.writeAsBytes(bytes, flush: true);
|
||||||
|
await tmp.rename(file.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every image waiting to upload.
|
||||||
|
List<ImageRef> pending() => [
|
||||||
|
for (final marker in _files('.pending'))
|
||||||
|
?ImageRef.tryParse(_relative(marker).replaceFirst('.pending', '')),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Uploads the queue, fetches linked images this device lacks, and evicts
|
||||||
|
/// cached images nothing links to any more.
|
||||||
|
///
|
||||||
|
/// Without a [client] (no password set yet) only eviction runs. Eviction is
|
||||||
|
/// skipped entirely when [referenced] is empty — an empty set far more
|
||||||
|
/// likely means "notes not loaded yet" than "every image unlinked" — and it
|
||||||
|
/// never touches a queued image or one younger than [kEvictionGrace].
|
||||||
|
///
|
||||||
|
/// Overlapping calls do not run in parallel: calls made before the next
|
||||||
|
/// pass starts share it, with their refs unioned and the newest client.
|
||||||
|
Future<ReconcileReport> reconcile(
|
||||||
|
Set<ImageRef> referenced,
|
||||||
|
DufsImageClient? client,
|
||||||
|
) {
|
||||||
|
// Merge into a pass that has not started yet: union the refs (an
|
||||||
|
// upload-only call right after an attach must not wipe a full pass's
|
||||||
|
// refs) and keep the newest client.
|
||||||
|
final waiting = _scheduledPass;
|
||||||
|
_scheduledPass = waiting == null
|
||||||
|
? _Pass(referenced, client)
|
||||||
|
: _Pass({
|
||||||
|
...waiting.referenced,
|
||||||
|
...referenced,
|
||||||
|
}, client ?? waiting.client);
|
||||||
|
final scheduled = _scheduled;
|
||||||
|
if (scheduled != null) return scheduled;
|
||||||
|
final future = _tail.then((_) {
|
||||||
|
final pass = _scheduledPass!;
|
||||||
|
_scheduledPass = null;
|
||||||
|
_scheduled = null;
|
||||||
|
return _run(pass);
|
||||||
|
});
|
||||||
|
_scheduled = future;
|
||||||
|
_tail = future.then<void>((_) {}, onError: (Object _) {});
|
||||||
|
return future;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ReconcileReport> _run(_Pass pass) async {
|
||||||
|
var uploaded = 0;
|
||||||
|
var downloaded = 0;
|
||||||
|
final client = pass.client;
|
||||||
|
if (client != null) {
|
||||||
|
for (final ref in pending()) {
|
||||||
|
final file = fileFor(ref);
|
||||||
|
if (!file.existsSync()) continue;
|
||||||
|
if (await client.upload(ref, await file.readAsBytes())) {
|
||||||
|
await _marker(ref).delete();
|
||||||
|
uploaded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final ref in pass.referenced) {
|
||||||
|
if (fileFor(ref).existsSync()) continue;
|
||||||
|
final bytes = await client.download(ref);
|
||||||
|
if (bytes == null) continue;
|
||||||
|
final file = fileFor(ref);
|
||||||
|
await file.parent.create(recursive: true);
|
||||||
|
await file.writeAsBytes(bytes, flush: true);
|
||||||
|
downloaded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final evicted = pass.referenced.isEmpty ? 0 : _evict(pass.referenced);
|
||||||
|
return ReconcileReport(
|
||||||
|
uploaded: uploaded,
|
||||||
|
downloaded: downloaded,
|
||||||
|
evicted: evicted,
|
||||||
|
pending: pending().length,
|
||||||
|
missing: pass.referenced.where((r) => !fileFor(r).existsSync()).length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
int _evict(Set<ImageRef> referenced) {
|
||||||
|
final cutoff = _now().subtract(kEvictionGrace);
|
||||||
|
var evicted = 0;
|
||||||
|
for (final file in _files('.jpg')) {
|
||||||
|
final ref = ImageRef.tryParse(_relative(file));
|
||||||
|
if (ref == null || referenced.contains(ref) || isPending(ref)) continue;
|
||||||
|
if (file.lastModifiedSync().isAfter(cutoff)) continue;
|
||||||
|
file.deleteSync();
|
||||||
|
evicted++;
|
||||||
|
}
|
||||||
|
return evicted;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<File> _files(String suffix) {
|
||||||
|
if (!root.existsSync()) return const [];
|
||||||
|
return [
|
||||||
|
for (final entity in root.listSync(recursive: true))
|
||||||
|
if (entity is File && entity.path.endsWith(suffix)) entity,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
String _relative(File file) =>
|
||||||
|
p.split(p.relative(file.path, from: root.path)).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Pass {
|
||||||
|
const _Pass(this.referenced, this.client);
|
||||||
|
|
||||||
|
final Set<ImageRef> referenced;
|
||||||
|
final DufsImageClient? client;
|
||||||
|
}
|
||||||
66
lib/images/image_processing.dart
Normal file
66
lib/images/image_processing.dart
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
/// Shrinks a picked or pasted image to what a note needs, before it is stored
|
||||||
|
/// or uploaded.
|
||||||
|
///
|
||||||
|
/// Phone photos are 3–8 MB with GPS coordinates in their EXIF. Every image is
|
||||||
|
/// re-encoded here: the recorded rotation is applied to the pixels first (so
|
||||||
|
/// dropping EXIF does not leave a portrait photo lying on its side), the long
|
||||||
|
/// side is capped at [kMaxImageSide], and the result is a JPEG with no
|
||||||
|
/// metadata at all.
|
||||||
|
///
|
||||||
|
/// Pure Dart (`package:image`), so the Android app and the desktop wrapper
|
||||||
|
/// run the same code; the web page never decodes images, it hands the raw
|
||||||
|
/// bytes to the wrapper.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:isolate';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
|
||||||
|
/// Longest side, in pixels, of a stored image.
|
||||||
|
const kMaxImageSide = 2560;
|
||||||
|
|
||||||
|
/// JPEG quality of a stored image.
|
||||||
|
const kJpegQuality = 85;
|
||||||
|
|
||||||
|
/// Re-encodes [bytes] as a metadata-free JPEG no larger than [kMaxImageSide]
|
||||||
|
/// on its long side, or returns null when [bytes] is not a decodable image.
|
||||||
|
Uint8List? prepareImage(Uint8List bytes) {
|
||||||
|
final decoded = _decode(bytes);
|
||||||
|
if (decoded == null) return null;
|
||||||
|
var image = img.bakeOrientation(decoded);
|
||||||
|
final longSide = math.max(image.width, image.height);
|
||||||
|
if (longSide > kMaxImageSide) {
|
||||||
|
image = image.width >= image.height
|
||||||
|
? img.copyResize(image, width: kMaxImageSide)
|
||||||
|
: img.copyResize(image, height: kMaxImageSide);
|
||||||
|
}
|
||||||
|
if (image.hasAlpha) image = _flattenOntoWhite(image);
|
||||||
|
image.exif = img.ExifData();
|
||||||
|
return img.encodeJpg(image, quality: kJpegQuality);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [prepareImage] off the calling isolate: decoding a 12 MP photo takes long
|
||||||
|
/// enough to drop frames if it runs on the UI isolate.
|
||||||
|
Future<Uint8List?> prepareImageInBackground(Uint8List bytes) =>
|
||||||
|
Isolate.run(() => prepareImage(bytes));
|
||||||
|
|
||||||
|
/// `decodeImage` returns null for most non-images but *throws* (a
|
||||||
|
/// RangeError from its PSD sniffer, seen on a 3-byte input) for others; both
|
||||||
|
/// mean "not an image" here.
|
||||||
|
img.Image? _decode(Uint8List bytes) {
|
||||||
|
try {
|
||||||
|
return img.decodeImage(bytes);
|
||||||
|
} on Object {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JPEG has no alpha: without this a transparent screenshot's clear pixels
|
||||||
|
/// come out as whatever colour their RGB happened to hold, usually black.
|
||||||
|
img.Image _flattenOntoWhite(img.Image image) {
|
||||||
|
final background = img.Image(width: image.width, height: image.height)
|
||||||
|
..clear(img.ColorRgb8(255, 255, 255));
|
||||||
|
return img.compositeImage(background, image);
|
||||||
|
}
|
||||||
91
lib/images/image_ref.dart
Normal file
91
lib/images/image_ref.dart
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
/// Where a note's image lives, and how a note's text points at it.
|
||||||
|
///
|
||||||
|
/// A note never stores image bytes: it carries a plain markdown link
|
||||||
|
/// ``, so the
|
||||||
|
/// CRDT log, the sync format and every older build stay untouched (an old
|
||||||
|
/// build just shows the link as text). This file is the single source of truth
|
||||||
|
/// for that link's shape; everything else goes through it.
|
||||||
|
///
|
||||||
|
/// Pure Dart: the desktop wrapper (`bin/todo_desktop.dart`) imports it.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
/// Public origin of kuhy's dufs server (Caddy → dufs on 127.0.0.1:5000).
|
||||||
|
const kDufsBaseUrl = 'https://kuhy-cloud.duckdns.org';
|
||||||
|
|
||||||
|
/// The dufs folder the `todo` login is scoped to.
|
||||||
|
const kImagesDir = 'todo-images';
|
||||||
|
|
||||||
|
/// Where the same folder sits on kuhy's PC, as written into `BACKLOG.md` so an
|
||||||
|
/// agent reading the backlog can open the image straight from disk.
|
||||||
|
const kBacklogImagesRoot = '~/data/cloud/$kImagesDir';
|
||||||
|
|
||||||
|
// Segments are restricted to URL- and filesystem-safe characters, which is
|
||||||
|
// what makes a ref safe to join onto a cache directory without a traversal
|
||||||
|
// check of its own.
|
||||||
|
const _segment = '[A-Za-z0-9_-]+';
|
||||||
|
const _path = '$_segment/$_segment\\.jpg';
|
||||||
|
const _label = r'!\[[^\]\n]*\]\(';
|
||||||
|
final _refPath = RegExp('^($_segment)/($_segment\\.jpg)\$');
|
||||||
|
final _urlLink = RegExp(
|
||||||
|
'$_label${RegExp.escape('$kDufsBaseUrl/$kImagesDir/')}($_path)\\)',
|
||||||
|
);
|
||||||
|
final _backlogLink = RegExp(
|
||||||
|
'($_label)${RegExp.escape('$kBacklogImagesRoot/')}($_path\\))',
|
||||||
|
);
|
||||||
|
final _wholeLine = RegExp('^\\s*${_urlLink.pattern}\\s*\$');
|
||||||
|
|
||||||
|
/// One image, addressed as `<noteId>/<file>` under [kImagesDir].
|
||||||
|
///
|
||||||
|
/// An extension type over that relative path: equality and hashing are the
|
||||||
|
/// string's, so refs drop straight into sets.
|
||||||
|
extension type const ImageRef._(String relativePath) {
|
||||||
|
/// A new, unique ref for an image being attached to [noteId].
|
||||||
|
factory ImageRef.create(String noteId) {
|
||||||
|
final ref = ImageRef.tryParse('$noteId/${const Uuid().v4()}.jpg');
|
||||||
|
if (ref == null) throw ArgumentError.value(noteId, 'noteId');
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses `<noteId>/<file>.jpg`, or null when it is not a safe image path.
|
||||||
|
static ImageRef? tryParse(String relativePath) =>
|
||||||
|
_refPath.hasMatch(relativePath) ? ImageRef._(relativePath) : null;
|
||||||
|
|
||||||
|
/// The note the image was attached to (it names the folder only).
|
||||||
|
String get noteId => relativePath.substring(0, relativePath.indexOf('/'));
|
||||||
|
|
||||||
|
/// `<uuid>.jpg`.
|
||||||
|
String get file => relativePath.substring(relativePath.indexOf('/') + 1);
|
||||||
|
|
||||||
|
/// The public dufs URL stored in the note.
|
||||||
|
String get url => '$kDufsBaseUrl/$kImagesDir/$relativePath';
|
||||||
|
|
||||||
|
/// The markdown line inserted into a note.
|
||||||
|
String get markdown => '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every image [text] links to, in order, without duplicates.
|
||||||
|
Set<ImageRef> imageRefsIn(String text) => {
|
||||||
|
for (final match in _urlLink.allMatches(text))
|
||||||
|
ImageRef.tryParse(match.group(1)!)!,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The image when [line] is nothing but one image link, else null.
|
||||||
|
ImageRef? imageRefOfLine(String line) {
|
||||||
|
final match = _wholeLine.firstMatch(line);
|
||||||
|
return match == null ? null : ImageRef.tryParse(match.group(1)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrites image links to their on-disk path under [kBacklogImagesRoot].
|
||||||
|
String toBacklogImagePaths(String text) => text.replaceAllMapped(
|
||||||
|
_urlLink,
|
||||||
|
(m) => m[0]!.replaceFirst('$kDufsBaseUrl/$kImagesDir', kBacklogImagesRoot),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Exact inverse of [toBacklogImagePaths], so an exported backlog imports
|
||||||
|
/// back with its links intact.
|
||||||
|
String fromBacklogImagePaths(String text) => text.replaceAllMapped(
|
||||||
|
_backlogLink,
|
||||||
|
(m) => '${m[1]}$kDufsBaseUrl/$kImagesDir/${m[2]}',
|
||||||
|
);
|
||||||
@ -4,6 +4,8 @@
|
|||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:todo/analytics/analytics_service.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/app_settings.dart';
|
||||||
import 'package:todo/data/note_repository.dart';
|
import 'package:todo/data/note_repository.dart';
|
||||||
import 'package:todo/data/repository_factory.dart';
|
import 'package:todo/data/repository_factory.dart';
|
||||||
@ -20,12 +22,15 @@ Future<void> main() async {
|
|||||||
final repository = await openRepository();
|
final repository = await openRepository();
|
||||||
final appSettings = ValueNotifier(await AppSettings.load());
|
final appSettings = ValueNotifier(await AppSettings.load());
|
||||||
final analytics = AnalyticsService(nodeId: repository.nodeId);
|
final analytics = AnalyticsService(nodeId: repository.nodeId);
|
||||||
|
// App storage + direct dufs on Android; the wrapper's /images on desktop.
|
||||||
|
final imageStore = await openImageStore();
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
TodoApp(
|
TodoApp(
|
||||||
repository: repository,
|
repository: repository,
|
||||||
appSettings: appSettings,
|
appSettings: appSettings,
|
||||||
analytics: analytics,
|
analytics: analytics,
|
||||||
|
imageStore: imageStore,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -39,6 +44,7 @@ class TodoApp extends StatelessWidget {
|
|||||||
required this.repository,
|
required this.repository,
|
||||||
required this.appSettings,
|
required this.appSettings,
|
||||||
required this.analytics,
|
required this.analytics,
|
||||||
|
required this.imageStore,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -53,16 +59,23 @@ class TodoApp extends StatelessWidget {
|
|||||||
/// reports interaction events.
|
/// reports interaction events.
|
||||||
final AnalyticsService analytics;
|
final AnalyticsService analytics;
|
||||||
|
|
||||||
|
/// The single, app-wide store for attached images.
|
||||||
|
final ImageStore imageStore;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
// Above MaterialApp so pushed routes (note detail) see it too.
|
||||||
title: 'todo',
|
return ImageStoreScope(
|
||||||
theme: buildLightTheme(),
|
store: imageStore,
|
||||||
darkTheme: buildDarkTheme(),
|
child: MaterialApp(
|
||||||
home: CaptureScreen(
|
title: 'todo',
|
||||||
repository: repository,
|
theme: buildLightTheme(),
|
||||||
appSettings: appSettings,
|
darkTheme: buildDarkTheme(),
|
||||||
analytics: analytics,
|
home: CaptureScreen(
|
||||||
|
repository: repository,
|
||||||
|
appSettings: appSettings,
|
||||||
|
analytics: analytics,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
/// Export still lands at the canonical `~/src/todo/BACKLOG.md` path the user's
|
||||||
/// tooling reads — the browser itself cannot write there.
|
/// tooling reads — the browser itself cannot write there.
|
||||||
Future<String> exportBacklog(String markdown, int noteCount) async {
|
Future<String> exportBacklog(String markdown, int noteCount) async {
|
||||||
final client = DesktopBackupClient(baseUrl: desktopWrapperOrigin);
|
final client = DesktopBackupClient(baseUrl: servingWrapperOrigin());
|
||||||
await client.writeBacklog(markdown);
|
await client.writeBacklog(markdown);
|
||||||
return 'Exported $noteCount notes to ~/src/todo/BACKLOG.md';
|
return 'Exported $noteCount notes to ~/src/todo/BACKLOG.md';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,3 +9,19 @@ const desktopWrapperPort = 8730;
|
|||||||
|
|
||||||
/// Origin of the desktop wrapper, e.g. `http://localhost:8730`.
|
/// Origin of the desktop wrapper, e.g. `http://localhost:8730`.
|
||||||
const desktopWrapperOrigin = 'http://localhost:$desktopWrapperPort';
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
/// tooling and the `todo` MCP server read it, and without this the move to a
|
||||||
/// web build would let it silently go stale.
|
/// web build would let it silently go stale.
|
||||||
LocalBackup createLocalBackup(NoteRepository repository) {
|
LocalBackup createLocalBackup(NoteRepository repository) {
|
||||||
final client = DesktopBackupClient(baseUrl: desktopWrapperOrigin);
|
final client = DesktopBackupClient(baseUrl: servingWrapperOrigin());
|
||||||
return LocalBackup(
|
return LocalBackup(
|
||||||
fetch: repository.listNotes,
|
fetch: repository.listNotes,
|
||||||
// Recovery reads the same file back through the wrapper, so a cleared
|
// Recovery reads the same file back through the wrapper, so a cleared
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import 'package:todo/data/note.dart';
|
import 'package:todo/data/note.dart';
|
||||||
|
import 'package:todo/images/image_ref.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
/// Serialises notes to (and parses them back from) a single Markdown file.
|
/// 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)
|
/// lets [NoteRepository.importNotes] re-import a file as a *merge* (by id)
|
||||||
/// rather than creating duplicates — the basis for "never lose ideas"
|
/// rather than creating duplicates — the basis for "never lose ideas"
|
||||||
/// recovery and round-tripping a backup.
|
/// 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 {
|
class NotesMarkdown {
|
||||||
// Private ctor: this is a static-only utility class, never instantiated.
|
// Private ctor: this is a static-only utility class, never instantiated.
|
||||||
const NotesMarkdown._(); // coverage:ignore-line
|
const NotesMarkdown._(); // coverage:ignore-line
|
||||||
@ -41,7 +47,7 @@ class NotesMarkdown {
|
|||||||
'created="${note.createdAt.toIso8601String()}" '
|
'created="${note.createdAt.toIso8601String()}" '
|
||||||
'updated="${note.updatedAt.toIso8601String()}" -->',
|
'updated="${note.updatedAt.toIso8601String()}" -->',
|
||||||
)
|
)
|
||||||
..writeln(note.text)
|
..writeln(toBacklogImagePaths(note.text))
|
||||||
..writeln();
|
..writeln();
|
||||||
}
|
}
|
||||||
return buffer.toString();
|
return buffer.toString();
|
||||||
@ -62,7 +68,9 @@ class NotesMarkdown {
|
|||||||
final bodyEnd = i + 1 < markers.length
|
final bodyEnd = i + 1 < markers.length
|
||||||
? markers[i + 1].start
|
? markers[i + 1].start
|
||||||
: content.length;
|
: content.length;
|
||||||
final body = content.substring(bodyStart, bodyEnd).trim();
|
final body = fromBacklogImagePaths(
|
||||||
|
content.substring(bodyStart, bodyEnd).trim(),
|
||||||
|
);
|
||||||
|
|
||||||
final id = attrs['id'];
|
final id = attrs['id'];
|
||||||
notes.add(
|
notes.add(
|
||||||
|
|||||||
@ -4,10 +4,13 @@
|
|||||||
/// screen still owns every action; this just lays the row out.
|
/// screen still owns every action; this just lays the row out.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import 'package:todo/data/note_repository.dart';
|
import 'package:todo/data/note_repository.dart';
|
||||||
import 'package:todo/frame_stats.dart';
|
import 'package:todo/frame_stats.dart';
|
||||||
|
import 'package:todo/ui/image_picking.dart';
|
||||||
|
|
||||||
/// App bar for the capture screen.
|
/// App bar for the capture screen.
|
||||||
class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
|
class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||||
@ -20,6 +23,8 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
required this.onSync,
|
required this.onSync,
|
||||||
required this.onOpenList,
|
required this.onOpenList,
|
||||||
required this.onOpenSettings,
|
required this.onOpenSettings,
|
||||||
|
required this.onAttachImages,
|
||||||
|
this.imagePicking,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -44,6 +49,12 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
/// Opens the settings screen.
|
/// Opens the settings screen.
|
||||||
final VoidCallback onOpenSettings;
|
final VoidCallback onOpenSettings;
|
||||||
|
|
||||||
|
/// Receives images picked with the 📎 action.
|
||||||
|
final ValueChanged<List<Uint8List>> onAttachImages;
|
||||||
|
|
||||||
|
/// The picker behind 📎; null uses the platform one.
|
||||||
|
final ImagePicking? imagePicking;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||||
|
|
||||||
@ -68,6 +79,8 @@ class CaptureAppBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
// coverage:ignore-end
|
// coverage:ignore-end
|
||||||
|
// Not gated on advanced mode: attaching is basic capture.
|
||||||
|
AttachImageButton(onImages: onAttachImages, picking: imagePicking),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'New note',
|
tooltip: 'New note',
|
||||||
onPressed: onNewNote,
|
onPressed: onNewNote,
|
||||||
|
|||||||
@ -34,6 +34,10 @@ class CaptureDraft {
|
|||||||
String? _id;
|
String? _id;
|
||||||
DateTime? _createdAt;
|
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.
|
/// The draft's priority, applied to every write.
|
||||||
Priority priority = Priority.defaultValue;
|
Priority priority = Priority.defaultValue;
|
||||||
|
|
||||||
@ -54,17 +58,33 @@ class CaptureDraft {
|
|||||||
_id = _uuid.v4();
|
_id = _uuid.v4();
|
||||||
_createdAt = DateTime.now();
|
_createdAt = DateTime.now();
|
||||||
created = true;
|
created = true;
|
||||||
|
} else if (_reserved) {
|
||||||
|
if (text.trim().isEmpty) return false;
|
||||||
|
_reserved = false;
|
||||||
|
created = true;
|
||||||
}
|
}
|
||||||
await _save(live: live);
|
await _save(live: live);
|
||||||
return created;
|
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.
|
/// Re-saves when only priority/status changed.
|
||||||
///
|
///
|
||||||
/// Does nothing before the row exists: the new value is already held here
|
/// Does nothing before the row exists: the new value is already held here
|
||||||
/// and will be applied by the first keystroke that creates it.
|
/// and will be applied by the first keystroke that creates it.
|
||||||
Future<void> persistMetadata({required bool Function() live}) async {
|
Future<void> persistMetadata({required bool Function() live}) async {
|
||||||
if (_id == null) return;
|
if (_id == null || _reserved) return;
|
||||||
await _save(live: live);
|
await _save(live: live);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,6 +111,7 @@ class CaptureDraft {
|
|||||||
_text = '';
|
_text = '';
|
||||||
_id = null;
|
_id = null;
|
||||||
_createdAt = null;
|
_createdAt = null;
|
||||||
|
_reserved = false;
|
||||||
priority = Priority.defaultValue;
|
priority = Priority.defaultValue;
|
||||||
status = Status.todo;
|
status = Status.todo;
|
||||||
lastSavedAt.value = null;
|
lastSavedAt.value = null;
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:crdt_sync/crdt_sync.dart';
|
import 'package:crdt_sync/crdt_sync.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:todo/analytics/analytics_event.dart';
|
import 'package:todo/analytics/analytics_event.dart';
|
||||||
import 'package:todo/analytics/analytics_service.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/app_settings.dart';
|
||||||
import 'package:todo/data/note.dart';
|
import 'package:todo/data/note.dart';
|
||||||
import 'package:todo/data/note_repository.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.dart';
|
||||||
import 'package:todo/ui/capture_status_footer.dart';
|
import 'package:todo/ui/capture_status_footer.dart';
|
||||||
import 'package:todo/ui/capture_sync_runner.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/note_form.dart';
|
||||||
import 'package:todo/ui/notes_list_screen.dart';
|
import 'package:todo/ui/notes_list_screen.dart';
|
||||||
import 'package:todo/ui/settings_screen.dart';
|
import 'package:todo/ui/settings_screen.dart';
|
||||||
|
|
||||||
|
part 'capture_screen_images.dart';
|
||||||
part 'capture_screen_sync.dart';
|
part 'capture_screen_sync.dart';
|
||||||
part 'capture_screen_widget.dart';
|
part 'capture_screen_widget.dart';
|
||||||
|
|
||||||
@ -59,6 +65,9 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
/// The note being captured, and its write-through to storage.
|
/// The note being captured, and its write-through to storage.
|
||||||
late final CaptureDraft _draft = CaptureDraft(widget.repository);
|
late final CaptureDraft _draft = CaptureDraft(widget.repository);
|
||||||
|
|
||||||
|
/// Inserts attached-image links into the mounted editor.
|
||||||
|
final ImageAttachController _attach = ImageAttachController();
|
||||||
|
|
||||||
final SyncStatusStore _syncStatus = SyncStatusStore();
|
final SyncStatusStore _syncStatus = SyncStatusStore();
|
||||||
|
|
||||||
/// Runs sync passes and records their outcome; see [CaptureSyncRunner].
|
/// Runs sync passes and records their outcome; see [CaptureSyncRunner].
|
||||||
@ -138,6 +147,8 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
onSync: _sync,
|
onSync: _sync,
|
||||||
onOpenList: _openList,
|
onOpenList: _openList,
|
||||||
onOpenSettings: _openSettings,
|
onOpenSettings: _openSettings,
|
||||||
|
onAttachImages: _attachImages,
|
||||||
|
imagePicking: widget.imagePicking,
|
||||||
),
|
),
|
||||||
body: _captureBody(advanced: advanced),
|
body: _captureBody(advanced: advanced),
|
||||||
);
|
);
|
||||||
@ -167,33 +178,11 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
// flicker doesn't fire a request per focus change.
|
// flicker doesn't fire a request per focus change.
|
||||||
_autoSyncDebounce?.cancel();
|
_autoSyncDebounce?.cancel();
|
||||||
_autoSyncDebounce = Timer(CaptureScreen.autoSyncDebounce, _autoSync);
|
_autoSyncDebounce = Timer(CaptureScreen.autoSyncDebounce, _autoSync);
|
||||||
|
} else if (state == AppLifecycleState.resumed) {
|
||||||
|
unawaited(_reconcileImages());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens the settings screen and adopts any saved configuration.
|
|
||||||
Future<void> _openSettings() async {
|
|
||||||
_logEvent('screen_view', params: {'screen': 'settings'});
|
|
||||||
final current = _settings ?? await SyncSettings.load();
|
|
||||||
if (!mounted) return;
|
|
||||||
await Navigator.of(context).push<SyncSettings>(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => SettingsScreen(
|
|
||||||
initial: current,
|
|
||||||
repository: widget.repository,
|
|
||||||
appSettings: widget.appSettings,
|
|
||||||
analytics: widget.analytics,
|
|
||||||
httpClient: widget.httpClient,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
|
||||||
// Always reload from storage: a device-flow "Connect" saves the token
|
|
||||||
// without popping a result, so relying on the pop value would miss it and
|
|
||||||
// leave us syncing with stale (token-less) settings.
|
|
||||||
final fresh = await SyncSettings.load();
|
|
||||||
setState(() => _settings = fresh);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs a full sync, routing to settings first if not yet configured.
|
/// Runs a full sync, routing to settings first if not yet configured.
|
||||||
Future<void> _sync() async {
|
Future<void> _sync() async {
|
||||||
final settings = _settings ?? await SyncSettings.load();
|
final settings = _settings ?? await SyncSettings.load();
|
||||||
@ -210,6 +199,7 @@ class _CaptureScreenState extends State<CaptureScreen>
|
|||||||
live: () => mounted,
|
live: () => mounted,
|
||||||
);
|
);
|
||||||
if (outcome.ok) _adoptReconciledSettings(outcome.reconciled);
|
if (outcome.ok) _adoptReconciledSettings(outcome.reconciled);
|
||||||
|
unawaited(_reconcileImages());
|
||||||
_logEvent(
|
_logEvent(
|
||||||
'sync_result',
|
'sync_result',
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
29
lib/ui/capture_screen_images.dart
Normal file
29
lib/ui/capture_screen_images.dart
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
/// The capture screen's image behaviour: attaching to the draft, and
|
||||||
|
/// reconciling the device's image store with every note.
|
||||||
|
///
|
||||||
|
/// A `part` of `capture_screen.dart` because it reads the private draft and
|
||||||
|
/// attach controller.
|
||||||
|
part of 'capture_screen.dart';
|
||||||
|
|
||||||
|
extension _CaptureImages on _CaptureScreenState {
|
||||||
|
/// Stores [images] under the draft's note (reserving its id) and inserts
|
||||||
|
/// their links at the cursor. [rejected] counts dropped non-images.
|
||||||
|
Future<void> _attachImages(List<Uint8List> images, {int rejected = 0}) =>
|
||||||
|
attachImages(
|
||||||
|
context,
|
||||||
|
noteId: _draft.reserveId,
|
||||||
|
target: _attach,
|
||||||
|
images: images,
|
||||||
|
rejected: rejected,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Uploads queued images, prefetches every image any note links to, and
|
||||||
|
/// evicts what no note links to. Runs after every sync attempt, on resume
|
||||||
|
/// and at launch (via the launch sync).
|
||||||
|
Future<void> _reconcileImages() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
final store = ImageStoreScope.read(context);
|
||||||
|
final notes = await widget.repository.listNotes();
|
||||||
|
await store.reconcile([for (final note in notes) note.text]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,10 +9,18 @@
|
|||||||
part of 'capture_screen.dart';
|
part of 'capture_screen.dart';
|
||||||
|
|
||||||
extension _CaptureSyncBehaviour on _CaptureScreenState {
|
extension _CaptureSyncBehaviour on _CaptureScreenState {
|
||||||
|
/// Background sync, then an image reconcile over the notes it produced.
|
||||||
|
/// The reconcile runs even when sync is skipped (unconfigured, offline):
|
||||||
|
/// images have their own login and must still upload and prefetch.
|
||||||
|
Future<void> _autoSync() async {
|
||||||
|
await _autoSyncPass();
|
||||||
|
await _reconcileImages();
|
||||||
|
}
|
||||||
|
|
||||||
/// Best-effort background sync: no snackbar, skips when unconfigured, and
|
/// Best-effort background sync: no snackbar, skips when unconfigured, and
|
||||||
/// never overlaps itself. Failures are not swallowed silently — the outcome
|
/// never overlaps itself. Failures are not swallowed silently — the outcome
|
||||||
/// lands in the status line so drift can't go unnoticed for days.
|
/// lands in the status line so drift can't go unnoticed for days.
|
||||||
Future<void> _autoSync() async {
|
Future<void> _autoSyncPass() async {
|
||||||
final settings = _settings;
|
final settings = _settings;
|
||||||
if (_autoSyncing || settings == null) return;
|
if (_autoSyncing || settings == null) return;
|
||||||
// Either backend counts. `isConfigured` means "has a GitHub token", so
|
// 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);
|
widget.appSettings.value = widget.appSettings.value.adopt(reconciled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opens the settings screen and adopts any saved configuration.
|
||||||
|
Future<void> _openSettings() async {
|
||||||
|
_logEvent('screen_view', params: {'screen': 'settings'});
|
||||||
|
final current = _settings ?? await SyncSettings.load();
|
||||||
|
if (!mounted) return;
|
||||||
|
await Navigator.of(context).push<SyncSettings>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => SettingsScreen(
|
||||||
|
initial: current,
|
||||||
|
repository: widget.repository,
|
||||||
|
appSettings: widget.appSettings,
|
||||||
|
analytics: widget.analytics,
|
||||||
|
httpClient: widget.httpClient,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
// Always reload from storage: a device-flow "Connect" saves the token
|
||||||
|
// without popping a result, so relying on the pop value would miss it and
|
||||||
|
// leave us syncing with stale (token-less) settings.
|
||||||
|
final fresh = await SyncSettings.load();
|
||||||
|
_rebuild(() => _settings = fresh);
|
||||||
|
}
|
||||||
|
|
||||||
void _openList() {
|
void _openList() {
|
||||||
_logEvent('screen_view', params: {'screen': 'notes_list'});
|
_logEvent('screen_view', params: {'screen': 'notes_list'});
|
||||||
Navigator.of(context).push<void>(
|
Navigator.of(context).push<void>(
|
||||||
@ -95,8 +127,17 @@ extension _CaptureSyncBehaviour on _CaptureScreenState {
|
|||||||
if (recovered.isNotEmpty) await widget.repository.importNotes(recovered);
|
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}) {
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -120,6 +161,7 @@ extension _CaptureSyncBehaviour on _CaptureScreenState {
|
|||||||
onStatusChanged: _setStatus,
|
onStatusChanged: _setStatus,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
onChanged: _onChanged,
|
onChanged: _onChanged,
|
||||||
|
attachController: _attach,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
CaptureStatusFooter(
|
CaptureStatusFooter(
|
||||||
|
|||||||
@ -22,6 +22,7 @@ class CaptureScreen extends StatefulWidget {
|
|||||||
this.localBackup,
|
this.localBackup,
|
||||||
this.firebaseFactory,
|
this.firebaseFactory,
|
||||||
this.stateStore,
|
this.stateStore,
|
||||||
|
this.imagePicking,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -55,6 +56,9 @@ class CaptureScreen extends StatefulWidget {
|
|||||||
/// file-backed instance is created); tests pass a fake with in-memory IO.
|
/// file-backed instance is created); tests pass a fake with in-memory IO.
|
||||||
final LocalBackup? localBackup;
|
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
|
/// How long focus must stay lost before a background sync fires. Public so
|
||||||
/// tests can pump exactly past it.
|
/// tests can pump exactly past it.
|
||||||
static const autoSyncDebounce = Duration(seconds: 5);
|
static const autoSyncDebounce = Duration(seconds: 5);
|
||||||
|
|||||||
176
lib/ui/cloud_images_settings.dart
Normal file
176
lib/ui/cloud_images_settings.dart
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
/// Settings → Cloud images: the dufs login attached images upload with.
|
||||||
|
///
|
||||||
|
/// On the phone that is a password field (the `todo` login's password,
|
||||||
|
/// printed and put on the PC clipboard by `add_dufs_login.sh`) plus a Test
|
||||||
|
/// button. The desktop page has no field: its wrapper reads the login from
|
||||||
|
/// `~/.config/dufs/logins/todo.env`, so the screen only reports whether it
|
||||||
|
/// found one.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import 'package:todo/attachments/image_password_store.dart';
|
||||||
|
import 'package:todo/attachments/image_store_api.dart';
|
||||||
|
import 'package:todo/attachments/image_store_scope.dart';
|
||||||
|
import 'package:todo/images/dufs_image_client.dart';
|
||||||
|
|
||||||
|
/// The command that creates the login, shown wherever it is missing.
|
||||||
|
const kAddLoginCommand =
|
||||||
|
'~/src/dufs-cloud/scripts/add_dufs_login.sh todo /todo-images rw';
|
||||||
|
|
||||||
|
/// The settings-list entry that opens [CloudImagesScreen].
|
||||||
|
class CloudImagesTile extends StatelessWidget {
|
||||||
|
/// Creates the tile; [httpClient] reaches the Test button (tests).
|
||||||
|
const CloudImagesTile({this.httpClient, super.key});
|
||||||
|
|
||||||
|
/// Injected HTTP client for the Test button.
|
||||||
|
final http.Client? httpClient;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Cloud images'),
|
||||||
|
subtitle: const Text('Where attached images upload (dufs)'),
|
||||||
|
trailing: const Icon(Icons.chevron_right),
|
||||||
|
onTap: () => Navigator.of(context).push<void>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => CloudImagesScreen(httpClient: httpClient),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Password + status for image uploads.
|
||||||
|
class CloudImagesScreen extends StatefulWidget {
|
||||||
|
/// Creates the screen; everything injectable is for tests.
|
||||||
|
const CloudImagesScreen({
|
||||||
|
this.passwords = const ImagePasswordStore(),
|
||||||
|
this.httpClient,
|
||||||
|
this.showPasswordField = !kIsWeb,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Where the phone keeps the password.
|
||||||
|
final ImagePasswordStore passwords;
|
||||||
|
|
||||||
|
/// Injected HTTP client for the Test button.
|
||||||
|
final http.Client? httpClient;
|
||||||
|
|
||||||
|
/// Whether this device enters the password itself (not the desktop page).
|
||||||
|
final bool showPasswordField;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CloudImagesScreen> createState() => _CloudImagesScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CloudImagesScreenState extends State<CloudImagesScreen> {
|
||||||
|
final _password = TextEditingController();
|
||||||
|
ImageStoreStatus? _status;
|
||||||
|
String? _checkResult;
|
||||||
|
bool _checking = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
unawaited(_load());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_password.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final store = ImageStoreScope.read(context);
|
||||||
|
if (widget.showPasswordField) {
|
||||||
|
_password.text = await widget.passwords.read();
|
||||||
|
}
|
||||||
|
final status = await store.status();
|
||||||
|
if (mounted) setState(() => _status = status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the password, then checks it against dufs.
|
||||||
|
Future<void> _saveAndTest() async {
|
||||||
|
final password = _password.text.trim();
|
||||||
|
setState(() => _checking = true);
|
||||||
|
final saved = await widget.passwords.write(password);
|
||||||
|
var result = 'Could not save: the keystore is unavailable';
|
||||||
|
if (saved) {
|
||||||
|
final client = DufsImageClient(
|
||||||
|
DufsLogin(user: kDufsImagesUser, password: password),
|
||||||
|
httpClient: widget.httpClient,
|
||||||
|
);
|
||||||
|
final check = await client.check();
|
||||||
|
result = switch (check) {
|
||||||
|
DufsCheck.ok => 'Saved — dufs accepted the login',
|
||||||
|
DufsCheck.rejected => 'Saved, but dufs rejected the password',
|
||||||
|
DufsCheck.unreachable => 'Saved — dufs is unreachable right now',
|
||||||
|
};
|
||||||
|
// Upload what queued up while there was no password, now rather than
|
||||||
|
// on the next sync. No note texts: an empty ref set only uploads, it
|
||||||
|
// never evicts.
|
||||||
|
if (check == DufsCheck.ok && mounted) {
|
||||||
|
await ImageStoreScope.read(context).reconcile(const []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_checking = false;
|
||||||
|
_checkResult = result;
|
||||||
|
});
|
||||||
|
await _load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final status = _status;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Cloud images')),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
if (widget.showPasswordField) ...[
|
||||||
|
TextField(
|
||||||
|
controller: _password,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Password for the "todo" dufs login',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _checking ? null : _saveAndTest,
|
||||||
|
child: const Text('Save & test'),
|
||||||
|
),
|
||||||
|
if (_checkResult case final result?) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(result),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
if (status != null) ...[
|
||||||
|
Text(
|
||||||
|
status.configured
|
||||||
|
? 'Login: configured'
|
||||||
|
: 'Login: missing — create it on the PC with',
|
||||||
|
),
|
||||||
|
if (!status.configured) const SelectableText(kAddLoginCommand),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('Waiting to upload: ${status.pending}'),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Images are shrunk to 2560px JPEGs without location data before '
|
||||||
|
'they leave this device.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
lib/ui/image_attach.dart
Normal file
81
lib/ui/image_attach.dart
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
/// Turning picked, pasted or dropped image bytes into links in the open note.
|
||||||
|
///
|
||||||
|
/// Shared by the capture and detail screens: each owns one
|
||||||
|
/// [ImageAttachController], hands it to its [NoteEditor] (which binds the
|
||||||
|
/// insert-at-cursor), and routes every image source through [attachImages].
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import 'package:todo/attachments/image_store_api.dart';
|
||||||
|
import 'package:todo/attachments/image_store_scope.dart';
|
||||||
|
import 'package:todo/images/image_ref.dart';
|
||||||
|
|
||||||
|
/// Lets a screen insert lines into whichever editor is currently mounted.
|
||||||
|
class ImageAttachController {
|
||||||
|
void Function(List<String> lines)? _insert;
|
||||||
|
|
||||||
|
/// Called by the editor on mount with its insert-at-cursor function;
|
||||||
|
/// returns the call that undoes it (a no-op once another editor bound).
|
||||||
|
VoidCallback bind(void Function(List<String> lines) insert) {
|
||||||
|
_insert = insert;
|
||||||
|
return () {
|
||||||
|
if (identical(_insert, insert)) _insert = null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts [lines] at the editor's cursor; false when no editor is bound.
|
||||||
|
bool insert(List<String> lines) {
|
||||||
|
final insert = _insert;
|
||||||
|
if (insert == null || lines.isEmpty) return false;
|
||||||
|
insert(lines);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores each of [images] for the note [noteId] names and inserts their
|
||||||
|
/// links, in order, through [target].
|
||||||
|
///
|
||||||
|
/// [noteId] is a callback because the capture screen's draft only gets an id
|
||||||
|
/// once there is something to save; asking for it is what reserves one.
|
||||||
|
/// Reserving writes nothing, so a rejected paste still never creates an
|
||||||
|
/// empty note. [rejected] counts inputs already known
|
||||||
|
/// not to be images (e.g. a dropped PDF), so one message covers both.
|
||||||
|
Future<void> attachImages(
|
||||||
|
BuildContext context, {
|
||||||
|
required String Function() noteId,
|
||||||
|
required ImageAttachController target,
|
||||||
|
required List<Uint8List> images,
|
||||||
|
int rejected = 0,
|
||||||
|
}) async {
|
||||||
|
final store = ImageStoreScope.read(context);
|
||||||
|
final refs = <ImageRef>[];
|
||||||
|
var notImages = rejected;
|
||||||
|
String? failure;
|
||||||
|
for (final bytes in images) {
|
||||||
|
try {
|
||||||
|
final ref = await store.attach(noteId(), bytes);
|
||||||
|
if (ref == null) {
|
||||||
|
notImages++;
|
||||||
|
} else {
|
||||||
|
refs.add(ref);
|
||||||
|
}
|
||||||
|
} on ImageStoreException catch (e) {
|
||||||
|
failure = e.message;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
target.insert([for (final ref in refs) ref.markdown]);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (failure != null) {
|
||||||
|
showError(context, failure);
|
||||||
|
} else if (notImages > 0) {
|
||||||
|
showError(
|
||||||
|
context,
|
||||||
|
notImages == 1 ? 'Not an image' : '$notImages files were not images',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
6
lib/ui/image_input.dart
Normal file
6
lib/ui/image_input.dart
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/// [ImageInputListener] for the current platform: paste and drag-and-drop on
|
||||||
|
/// the desktop web build, a pass-through elsewhere.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'image_input_io.dart'
|
||||||
|
if (dart.library.js_interop) 'image_input_web.dart';
|
||||||
15
lib/ui/image_input_files.dart
Normal file
15
lib/ui/image_input_files.dart
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/// Which of a paste's or drop's files are images: split out of the web-only
|
||||||
|
/// listener so the decision is unit-testable on the VM.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Indices of the files whose MIME type is an image, and how many were not.
|
||||||
|
///
|
||||||
|
/// Order is preserved: files dropped together are attached in the order the
|
||||||
|
/// browser lists them.
|
||||||
|
({List<int> images, int rejected}) partitionImageFiles(List<String> mimeTypes) {
|
||||||
|
final images = <int>[
|
||||||
|
for (var i = 0; i < mimeTypes.length; i++)
|
||||||
|
if (mimeTypes[i].toLowerCase().startsWith('image/')) i,
|
||||||
|
];
|
||||||
|
return (images: images, rejected: mimeTypes.length - images.length);
|
||||||
|
}
|
||||||
27
lib/ui/image_input_io.dart
Normal file
27
lib/ui/image_input_io.dart
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// Receives pasted or dropped images and the count of non-image files.
|
||||||
|
typedef ImageInputCallback =
|
||||||
|
void Function(List<Uint8List> images, int rejected);
|
||||||
|
|
||||||
|
/// Off the web there is no document to paste or drop onto (Android attaches
|
||||||
|
/// through the 📎 button), so this is just [child].
|
||||||
|
class ImageInputListener extends StatelessWidget {
|
||||||
|
/// Wraps [child]; [onImages] is never called on this platform.
|
||||||
|
const ImageInputListener({
|
||||||
|
required this.onImages,
|
||||||
|
required this.child,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Unused off the web.
|
||||||
|
final ImageInputCallback onImages;
|
||||||
|
|
||||||
|
/// The screen body.
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => child;
|
||||||
|
}
|
||||||
103
lib/ui/image_input_web.dart
Normal file
103
lib/ui/image_input_web.dart
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
/// Ctrl+V and drag-and-drop of images onto the desktop (web) page.
|
||||||
|
///
|
||||||
|
/// Flutter web has no drop target of its own, so this listens on the DOM
|
||||||
|
/// `document` directly (`package:web`, no native plugin): `dragover` must be
|
||||||
|
/// cancelled for the page to accept a drop at all, and `drop` must be, or
|
||||||
|
/// Chrome navigates away to the dropped file. A paste is only taken over when
|
||||||
|
/// the clipboard carries files; plain-text pastes stay the text field's.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:js_interop';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:todo/ui/image_input_files.dart';
|
||||||
|
import 'package:web/web.dart' as web;
|
||||||
|
|
||||||
|
/// Receives pasted or dropped images and the count of non-image files.
|
||||||
|
typedef ImageInputCallback =
|
||||||
|
void Function(List<Uint8List> images, int rejected);
|
||||||
|
|
||||||
|
/// Calls [onImages] for images pasted or dropped anywhere on the page while
|
||||||
|
/// this widget's route is the visible one.
|
||||||
|
class ImageInputListener extends StatefulWidget {
|
||||||
|
/// Wraps [child], listening while mounted.
|
||||||
|
const ImageInputListener({
|
||||||
|
required this.onImages,
|
||||||
|
required this.child,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Receives the image bytes in order, plus the non-image count.
|
||||||
|
final ImageInputCallback onImages;
|
||||||
|
|
||||||
|
/// The screen body.
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ImageInputListener> createState() => _ImageInputListenerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImageInputListenerState extends State<ImageInputListener> {
|
||||||
|
late final JSFunction _onDragOver = _dragOver.toJS;
|
||||||
|
late final JSFunction _onDrop = _drop.toJS;
|
||||||
|
late final JSFunction _onPaste = _paste.toJS;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
web.document
|
||||||
|
..addEventListener('dragover', _onDragOver)
|
||||||
|
..addEventListener('drop', _onDrop)
|
||||||
|
..addEventListener('paste', _onPaste);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
web.document
|
||||||
|
..removeEventListener('dragover', _onDragOver)
|
||||||
|
..removeEventListener('drop', _onDrop)
|
||||||
|
..removeEventListener('paste', _onPaste);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The capture screen stays mounted under a pushed detail screen, and both
|
||||||
|
// listen on the same document: only the visible route may take the input,
|
||||||
|
// or one drop would attach to two notes.
|
||||||
|
bool get _active => mounted && (ModalRoute.of(context)?.isCurrent ?? true);
|
||||||
|
|
||||||
|
void _dragOver(web.DragEvent event) {
|
||||||
|
final types = event.dataTransfer?.types.toDart ?? const <JSString>[];
|
||||||
|
if (_active && types.any((t) => t.toDart == 'Files')) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drop(web.DragEvent event) {
|
||||||
|
final files = event.dataTransfer?.files;
|
||||||
|
if (!_active || files == null || files.length == 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
unawaited(_take(files));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _paste(web.ClipboardEvent event) {
|
||||||
|
final files = event.clipboardData?.files;
|
||||||
|
if (!_active || files == null || files.length == 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
unawaited(_take(files));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _take(web.FileList list) async {
|
||||||
|
final files = [for (var i = 0; i < list.length; i++) list.item(i)!];
|
||||||
|
final split = partitionImageFiles([for (final f in files) f.type]);
|
||||||
|
final images = <Uint8List>[
|
||||||
|
for (final i in split.images)
|
||||||
|
(await files[i].arrayBuffer().toDart).toDart.asUint8List(),
|
||||||
|
];
|
||||||
|
if (mounted) widget.onImages(images, split.rejected);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => widget.child;
|
||||||
|
}
|
||||||
104
lib/ui/image_picking.dart
Normal file
104
lib/ui/image_picking.dart
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
/// The 📎 button: pick images from the camera or gallery (Android) or from
|
||||||
|
/// disk (desktop), and hand their bytes on.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:file_selector/file_selector.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
|
/// Where the phone takes an image from.
|
||||||
|
enum ImagePickSource {
|
||||||
|
/// A new photo.
|
||||||
|
camera,
|
||||||
|
|
||||||
|
/// Existing photos, several at once.
|
||||||
|
gallery,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Picks images and returns their bytes; empty when the user cancels.
|
||||||
|
///
|
||||||
|
/// Injectable so widget tests never reach a platform picker.
|
||||||
|
typedef ImagePicking = Future<List<Uint8List>> Function(BuildContext context);
|
||||||
|
|
||||||
|
/// The platform picker: a Camera/Gallery sheet on Android, a multi-select
|
||||||
|
/// file dialog filtered to images on the desktop (web) build.
|
||||||
|
///
|
||||||
|
/// [fromPhone] is the platform call behind the sheet, injectable for tests.
|
||||||
|
Future<List<Uint8List>> pickImages(
|
||||||
|
BuildContext context, {
|
||||||
|
Future<List<Uint8List>> Function(ImagePickSource) fromPhone = _pickFromPhone,
|
||||||
|
}) async {
|
||||||
|
// The desktop build has no sheet: straight to the file dialog.
|
||||||
|
if (kIsWeb) return await _pickFiles(); // coverage:ignore-line
|
||||||
|
final source = await showModalBottomSheet<ImagePickSource>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.photo_camera_outlined),
|
||||||
|
title: const Text('Camera'),
|
||||||
|
onTap: () => Navigator.pop(context, ImagePickSource.camera),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.photo_library_outlined),
|
||||||
|
title: const Text('Gallery'),
|
||||||
|
onTap: () => Navigator.pop(context, ImagePickSource.gallery),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (source == null) return const [];
|
||||||
|
return await fromPhone(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
// coverage:ignore-start
|
||||||
|
// Thin calls into platform pickers with no test binding; the sheet above and
|
||||||
|
// everything downstream of the returned bytes are covered.
|
||||||
|
Future<List<Uint8List>> _pickFromPhone(ImagePickSource source) async {
|
||||||
|
final picker = ImagePicker();
|
||||||
|
final files = switch (source) {
|
||||||
|
ImagePickSource.camera => [
|
||||||
|
?await picker.pickImage(source: ImageSource.camera),
|
||||||
|
],
|
||||||
|
ImagePickSource.gallery => await picker.pickMultiImage(),
|
||||||
|
};
|
||||||
|
return [for (final file in files) await file.readAsBytes()];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Uint8List>> _pickFiles() async {
|
||||||
|
final files = await openFiles(
|
||||||
|
acceptedTypeGroups: const [
|
||||||
|
XTypeGroup(label: 'Images', mimeTypes: ['image/*']),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
return [for (final file in files) await file.readAsBytes()];
|
||||||
|
}
|
||||||
|
// coverage:ignore-end
|
||||||
|
|
||||||
|
/// App-bar action that picks images and passes them to [onImages].
|
||||||
|
class AttachImageButton extends StatelessWidget {
|
||||||
|
/// Creates the button; [picking] defaults to [pickImages].
|
||||||
|
const AttachImageButton({required this.onImages, this.picking, super.key});
|
||||||
|
|
||||||
|
/// Receives the picked images (never called for a cancelled pick).
|
||||||
|
final ValueChanged<List<Uint8List>> onImages;
|
||||||
|
|
||||||
|
/// The picker, injectable for tests.
|
||||||
|
final ImagePicking? picking;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IconButton(
|
||||||
|
tooltip: 'Attach image',
|
||||||
|
icon: const Icon(Icons.attach_file),
|
||||||
|
onPressed: () async {
|
||||||
|
final images = await (picking ?? pickImages)(context);
|
||||||
|
if (images.isNotEmpty) onImages(images);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
lib/ui/images/image_embed.dart
Normal file
115
lib/ui/images/image_embed.dart
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
/// One attached image as it appears in a note: the picture, or a placeholder
|
||||||
|
/// saying why it is not here yet. Tapping opens it full screen.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:todo/attachments/image_store_api.dart';
|
||||||
|
import 'package:todo/attachments/image_store_scope.dart';
|
||||||
|
import 'package:todo/images/image_ref.dart';
|
||||||
|
import 'package:todo/ui/images/image_viewer_screen.dart';
|
||||||
|
import 'package:todo/ui/images/local_image.dart';
|
||||||
|
|
||||||
|
/// Placeholder text for an image this device does not have yet.
|
||||||
|
const kImageNotDownloaded = 'Not downloaded yet';
|
||||||
|
|
||||||
|
/// Tooltip on the badge of an image dufs has not accepted yet.
|
||||||
|
const kImageWaitingToUpload = 'Waiting to upload';
|
||||||
|
|
||||||
|
/// Renders [ref] from the nearest [ImageStoreScope].
|
||||||
|
class ImageEmbed extends StatelessWidget {
|
||||||
|
/// Creates an embed; [fit] is `cover` for thumbnails, `contain` inline.
|
||||||
|
const ImageEmbed({
|
||||||
|
required this.ref,
|
||||||
|
this.fit = BoxFit.cover,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The image to show.
|
||||||
|
final ImageRef ref;
|
||||||
|
|
||||||
|
/// How the picture fills the embed's box.
|
||||||
|
final BoxFit fit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final store = ImageStoreScope.of(context);
|
||||||
|
final source = store.sourceFor(ref);
|
||||||
|
final picture = imageFor(source, fit: fit);
|
||||||
|
return Semantics(
|
||||||
|
label: 'Attached image',
|
||||||
|
button: source is! MissingImageSource,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: source is MissingImageSource
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(context).push<void>(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => ImageViewerScreen(source: source),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.passthrough,
|
||||||
|
children: [
|
||||||
|
picture,
|
||||||
|
if (store.isPending(ref))
|
||||||
|
const Positioned(
|
||||||
|
right: 4,
|
||||||
|
top: 4,
|
||||||
|
child: Tooltip(
|
||||||
|
message: kImageWaitingToUpload,
|
||||||
|
child: Icon(Icons.cloud_upload_outlined, size: 18),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The picture for [source], falling back to the not-downloaded placeholder
|
||||||
|
/// (also when a network load fails: the wrapper 404s until it has the file).
|
||||||
|
Widget imageFor(ImageSource source, {required BoxFit fit}) {
|
||||||
|
Widget placeholder(BuildContext context, Object error, StackTrace? stack) =>
|
||||||
|
const ImagePlaceholder();
|
||||||
|
return switch (source) {
|
||||||
|
LocalImageSource(:final path) => localImage(
|
||||||
|
path,
|
||||||
|
fit: fit,
|
||||||
|
errorBuilder: placeholder,
|
||||||
|
),
|
||||||
|
NetworkImageSource(:final url) => Image.network(
|
||||||
|
url,
|
||||||
|
fit: fit,
|
||||||
|
errorBuilder: placeholder,
|
||||||
|
),
|
||||||
|
MissingImageSource() => const ImagePlaceholder(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grey box with [kImageNotDownloaded].
|
||||||
|
class ImagePlaceholder extends StatelessWidget {
|
||||||
|
/// Creates the placeholder.
|
||||||
|
const ImagePlaceholder({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return ColoredBox(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Text(
|
||||||
|
kImageNotDownloaded,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
lib/ui/images/image_strip.dart
Normal file
48
lib/ui/images/image_strip.dart
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:todo/images/image_ref.dart';
|
||||||
|
import 'package:todo/ui/images/image_embed.dart';
|
||||||
|
|
||||||
|
/// Height of the thumbnail row under the editor.
|
||||||
|
const kImageStripHeight = 88.0;
|
||||||
|
|
||||||
|
/// A row of thumbnails for every image [text] links to, shown under the
|
||||||
|
/// editing field so a note's pictures are visible while writing it. The
|
||||||
|
/// editing modes show the links as text; without this the default (Raw)
|
||||||
|
/// mode would never show an image at all. Empty when there are none.
|
||||||
|
class ImageStrip extends StatelessWidget {
|
||||||
|
/// Creates the strip for [text].
|
||||||
|
const ImageStrip({required this.text, super.key});
|
||||||
|
|
||||||
|
/// The note text being edited.
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final refs = imageRefsIn(text).toList();
|
||||||
|
if (refs.isEmpty) return const SizedBox.shrink();
|
||||||
|
// Same centred column as the editor above it (rule 21), so on the wide
|
||||||
|
// desktop window the thumbnails sit under the text, not at the far edge.
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(
|
||||||
|
maxWidth: kProseMaxWidth,
|
||||||
|
maxHeight: kImageStripHeight,
|
||||||
|
),
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: refs.length,
|
||||||
|
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||||
|
itemBuilder: (context, i) => SizedBox(
|
||||||
|
width: kImageStripHeight,
|
||||||
|
child: ImageEmbed(ref: refs[i]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
lib/ui/images/image_viewer_screen.dart
Normal file
24
lib/ui/images/image_viewer_screen.dart
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:todo/attachments/image_store_api.dart';
|
||||||
|
import 'package:todo/ui/images/image_embed.dart';
|
||||||
|
|
||||||
|
/// One image, full screen, pinch/scroll to zoom.
|
||||||
|
class ImageViewerScreen extends StatelessWidget {
|
||||||
|
/// Creates the viewer for [source].
|
||||||
|
const ImageViewerScreen({required this.source, super.key});
|
||||||
|
|
||||||
|
/// Where the image is drawn from.
|
||||||
|
final ImageSource source;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(),
|
||||||
|
body: InteractiveViewer(
|
||||||
|
maxScale: 8,
|
||||||
|
child: Center(child: imageFor(source, fit: BoxFit.contain)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
lib/ui/images/local_image.dart
Normal file
5
lib/ui/images/local_image.dart
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/// `Image.file` without importing `dart:io` into web code.
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'local_image_io.dart'
|
||||||
|
if (dart.library.js_interop) 'local_image_web.dart';
|
||||||
10
lib/ui/images/local_image_io.dart
Normal file
10
lib/ui/images/local_image_io.dart
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// A cached image file on this device.
|
||||||
|
Widget localImage(
|
||||||
|
String path, {
|
||||||
|
required BoxFit fit,
|
||||||
|
required ImageErrorWidgetBuilder errorBuilder,
|
||||||
|
}) => Image.file(File(path), fit: fit, errorBuilder: errorBuilder);
|
||||||
8
lib/ui/images/local_image_web.dart
Normal file
8
lib/ui/images/local_image_web.dart
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// Never reached on the web: the web store only returns network sources.
|
||||||
|
Widget localImage(
|
||||||
|
String path, {
|
||||||
|
required BoxFit fit,
|
||||||
|
required ImageErrorWidgetBuilder errorBuilder,
|
||||||
|
}) => const SizedBox.shrink();
|
||||||
@ -1,11 +1,18 @@
|
|||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.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.
|
/// A lightweight, read-only renderer for the note format.
|
||||||
///
|
///
|
||||||
/// The app's notes use a small, known Markdown subset — an `#` title, `##`
|
/// The app's notes use a small, known Markdown subset — an `#` title, `##`
|
||||||
/// section headings, italic `_guidance_` lines, `-` bullet lists and plain
|
/// 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
|
/// without pulling in a full Markdown engine. It keeps the quick-capture app
|
||||||
/// lean and is trivial to test.
|
/// lean and is trivial to test.
|
||||||
///
|
///
|
||||||
@ -30,6 +37,18 @@ class MarkdownView extends StatelessWidget {
|
|||||||
widgets.add(const SizedBox(height: 8));
|
widgets.add(const SizedBox(height: 8));
|
||||||
continue;
|
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('## ')) {
|
if (trimmed.startsWith('## ')) {
|
||||||
widgets.add(
|
widgets.add(
|
||||||
Padding(
|
Padding(
|
||||||
|
|||||||
@ -1,9 +1,15 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:todo/data/app_settings.dart';
|
import 'package:todo/data/app_settings.dart';
|
||||||
import 'package:todo/data/note.dart';
|
import 'package:todo/data/note.dart';
|
||||||
import 'package:todo/data/note_repository.dart';
|
import 'package:todo/data/note_repository.dart';
|
||||||
import 'package:todo/data/note_template.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';
|
import 'package:todo/ui/note_form.dart';
|
||||||
|
|
||||||
/// Full-screen view of a single note: read it in full, edit its body through
|
/// 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.note,
|
||||||
required this.repository,
|
required this.repository,
|
||||||
required this.appSettings,
|
required this.appSettings,
|
||||||
|
this.imagePicking,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -32,6 +39,9 @@ class NoteDetailScreen extends StatefulWidget {
|
|||||||
/// exactly what made editing look different from capture.
|
/// exactly what made editing look different from capture.
|
||||||
final ValueNotifier<AppSettings> appSettings;
|
final ValueNotifier<AppSettings> appSettings;
|
||||||
|
|
||||||
|
/// The 📎 picker; null uses the platform one. Injected by tests.
|
||||||
|
final ImagePicking? imagePicking;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<NoteDetailScreen> createState() => _NoteDetailScreenState();
|
State<NoteDetailScreen> createState() => _NoteDetailScreenState();
|
||||||
}
|
}
|
||||||
@ -39,6 +49,18 @@ class NoteDetailScreen extends StatefulWidget {
|
|||||||
class _NoteDetailScreenState extends State<NoteDetailScreen> {
|
class _NoteDetailScreenState extends State<NoteDetailScreen> {
|
||||||
late Note _note = widget.note;
|
late Note _note = widget.note;
|
||||||
|
|
||||||
|
/// Inserts attached-image links into the editor.
|
||||||
|
final ImageAttachController _attach = ImageAttachController();
|
||||||
|
|
||||||
|
Future<void> _attachImages(List<Uint8List> images, {int rejected = 0}) =>
|
||||||
|
attachImages(
|
||||||
|
context,
|
||||||
|
noteId: () => _note.id,
|
||||||
|
target: _attach,
|
||||||
|
images: images,
|
||||||
|
rejected: rejected,
|
||||||
|
);
|
||||||
|
|
||||||
Future<void> _persist(Note next) async {
|
Future<void> _persist(Note next) async {
|
||||||
setState(() => _note = next);
|
setState(() => _note = next);
|
||||||
await widget.repository.upsert(next);
|
await widget.repository.upsert(next);
|
||||||
@ -65,6 +87,10 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(title.isEmpty ? '(empty)' : title),
|
title: Text(title.isEmpty ? '(empty)' : title),
|
||||||
actions: [
|
actions: [
|
||||||
|
AttachImageButton(
|
||||||
|
onImages: _attachImages,
|
||||||
|
picking: widget.imagePicking,
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Delete note',
|
tooltip: 'Delete note',
|
||||||
icon: const Icon(Icons.delete_outline),
|
icon: const Icon(Icons.delete_outline),
|
||||||
@ -72,23 +98,29 @@ class _NoteDetailScreenState extends State<NoteDetailScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Padding(
|
body: ImageInputListener(
|
||||||
padding: const EdgeInsets.all(16),
|
onImages: (images, rejected) =>
|
||||||
// The same form capture renders, gated on the same setting, so an
|
unawaited(_attachImages(images, rejected: rejected)),
|
||||||
// edited note and a fresh one cannot look different.
|
child: Padding(
|
||||||
child: ValueListenableBuilder<AppSettings>(
|
padding: const EdgeInsets.all(16),
|
||||||
valueListenable: widget.appSettings,
|
// The same form capture renders, gated on the same setting, so an
|
||||||
builder: (context, settings, _) => NoteForm(
|
// edited note and a fresh one cannot look different.
|
||||||
advancedMode: settings.advancedMode,
|
child: ValueListenableBuilder<AppSettings>(
|
||||||
initialText: _note.text,
|
valueListenable: widget.appSettings,
|
||||||
priority: _note.priority,
|
builder: (context, settings, _) => NoteForm(
|
||||||
status: _note.status,
|
advancedMode: settings.advancedMode,
|
||||||
onPriorityChanged: (p) => _persist(
|
initialText: _note.text,
|
||||||
_note.copyWith(priority: p, updatedAt: DateTime.now()),
|
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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:todo/data/note.dart';
|
import 'package:todo/data/note.dart';
|
||||||
import 'package:todo/data/note_template.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/markdown_view.dart';
|
||||||
import 'package:todo/ui/note_editor_chrome.dart';
|
import 'package:todo/ui/note_editor_chrome.dart';
|
||||||
import 'package:todo/ui/note_editor_document.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';
|
export 'package:todo/ui/note_editor_mode.dart';
|
||||||
|
|
||||||
|
part 'note_editor_images.dart';
|
||||||
part 'note_editor_views.dart';
|
part 'note_editor_views.dart';
|
||||||
part 'note_editor_widget.dart';
|
part 'note_editor_widget.dart';
|
||||||
|
|
||||||
@ -31,6 +33,9 @@ class _NoteEditorState extends State<NoteEditor> {
|
|||||||
/// [_retemplateDraft] from overriding that choice as they keep typing.
|
/// [_retemplateDraft] from overriding that choice as they keep typing.
|
||||||
bool _templatePickedByUser = false;
|
bool _templatePickedByUser = false;
|
||||||
|
|
||||||
|
/// Detaches this editor from [NoteEditor.attachController].
|
||||||
|
VoidCallback? _unbindImages;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@ -40,10 +45,20 @@ class _NoteEditorState extends State<NoteEditor> {
|
|||||||
openingRaw: widget.initialMode == NoteEditorMode.raw,
|
openingRaw: widget.initialMode == NoteEditorMode.raw,
|
||||||
);
|
);
|
||||||
_mode = _resolveMode(widget.initialMode);
|
_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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_unbindImages?.call();
|
||||||
_doc.dispose();
|
_doc.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@ -140,6 +155,7 @@ class _NoteEditorState extends State<NoteEditor> {
|
|||||||
widget.onChromeVisibleChanged(
|
widget.onChromeVisibleChanged(
|
||||||
!_enteringGuided && _mode != NoteEditorMode.guided,
|
!_enteringGuided && _mode != NoteEditorMode.guided,
|
||||||
);
|
);
|
||||||
|
widget.onModeChanged?.call(_mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Commits the wizard's template choice and enters the bare stepper.
|
/// Commits the wizard's template choice and enters the bare stepper.
|
||||||
|
|||||||
55
lib/ui/note_editor_images.dart
Normal file
55
lib/ui/note_editor_images.dart
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
/// Inserting attached-image links into whichever field is being edited.
|
||||||
|
///
|
||||||
|
/// A `part` of `note_editor.dart` because it writes the editor's private
|
||||||
|
/// controllers (`_doc`) and follows its mode (`_mode`, `_currentStep`).
|
||||||
|
part of 'note_editor.dart';
|
||||||
|
|
||||||
|
extension _NoteEditorImages on _NoteEditorState {
|
||||||
|
/// Puts [lines] on their own lines at the cursor of the field in view:
|
||||||
|
/// the current step's field in Guided, the body in Raw. With no visible
|
||||||
|
/// cursor (View mode, or a field never focused) they go at the end.
|
||||||
|
void _insertImageLinks(List<String> lines) {
|
||||||
|
final TextEditingController target;
|
||||||
|
var atEnd = _mode == NoteEditorMode.preview;
|
||||||
|
if (_mode == NoteEditorMode.guided && !_doc.rawSource) {
|
||||||
|
_doc.ensureControllers(_doc.template);
|
||||||
|
final sections = _doc.template.sections;
|
||||||
|
target = _doc
|
||||||
|
.section[sections[_currentStep.clamp(0, sections.length - 1)].key]!;
|
||||||
|
} else if (_doc.rawSource) {
|
||||||
|
target = _doc.body;
|
||||||
|
} else {
|
||||||
|
// View mode over guided sections: the last section is the end.
|
||||||
|
_doc.ensureControllers(_doc.template);
|
||||||
|
target = _doc.section[_doc.template.sections.last.key]!;
|
||||||
|
atEnd = true;
|
||||||
|
}
|
||||||
|
_insertBlock(target, lines.join('\n'), atEnd: atEnd);
|
||||||
|
_retemplateDraft();
|
||||||
|
_onStepFieldChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces [controller]'s selection (or appends, when [atEnd] or there is
|
||||||
|
/// no selection) with [block], padded with newlines so it sits on its own
|
||||||
|
/// lines, and leaves the cursor just after it.
|
||||||
|
void _insertBlock(
|
||||||
|
TextEditingController controller,
|
||||||
|
String block, {
|
||||||
|
required bool atEnd,
|
||||||
|
}) {
|
||||||
|
final text = controller.text;
|
||||||
|
final selection = controller.selection;
|
||||||
|
final useCursor = !atEnd && selection.isValid;
|
||||||
|
final start = useCursor ? selection.start : text.length;
|
||||||
|
final end = useCursor ? selection.end : text.length;
|
||||||
|
final before = text.substring(0, start);
|
||||||
|
final after = text.substring(end);
|
||||||
|
final lead = before.isEmpty || before.endsWith('\n') ? '' : '\n';
|
||||||
|
final trail = after.startsWith('\n') ? '' : '\n';
|
||||||
|
final inserted = '$lead$block$trail';
|
||||||
|
controller.value = TextEditingValue(
|
||||||
|
text: '$before$inserted$after',
|
||||||
|
selection: TextSelection.collapsed(offset: before.length + inserted.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -41,6 +41,8 @@ class NoteEditor extends StatefulWidget {
|
|||||||
this.initialMode = NoteEditorMode.guided,
|
this.initialMode = NoteEditorMode.guided,
|
||||||
this.autofocus = false,
|
this.autofocus = false,
|
||||||
this.advancedMode = true,
|
this.advancedMode = true,
|
||||||
|
this.attachController,
|
||||||
|
this.onModeChanged,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -85,6 +87,14 @@ class NoteEditor extends StatefulWidget {
|
|||||||
/// in the gating.
|
/// in the gating.
|
||||||
final bool advancedMode;
|
final bool advancedMode;
|
||||||
|
|
||||||
|
/// Bound to this editor's insert-at-cursor while it is mounted, so the
|
||||||
|
/// screen can drop attached-image links into the field being edited.
|
||||||
|
final ImageAttachController? attachController;
|
||||||
|
|
||||||
|
/// Called with the displayed mode after each mode switch (the thumbnail
|
||||||
|
/// strip hides in View mode, which renders images inline).
|
||||||
|
final ValueChanged<NoteEditorMode>? onModeChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<NoteEditor> createState() => _NoteEditorState();
|
State<NoteEditor> createState() => _NoteEditorState();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,12 +12,15 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:todo/data/note.dart';
|
import 'package:todo/data/note.dart';
|
||||||
import 'package:todo/data/note_template.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_editor.dart';
|
||||||
import 'package:todo/ui/note_form_chrome_gate.dart';
|
import 'package:todo/ui/note_form_chrome_gate.dart';
|
||||||
import 'package:todo/ui/note_meta_row.dart';
|
import 'package:todo/ui/note_meta_row.dart';
|
||||||
|
|
||||||
/// The metadata row above a [NoteEditor], gated on advanced mode.
|
/// The metadata row above a [NoteEditor], gated on advanced mode, and the
|
||||||
class NoteForm extends StatelessWidget {
|
/// attached-image thumbnails below it.
|
||||||
|
class NoteForm extends StatefulWidget {
|
||||||
/// Creates the form for the given note values.
|
/// Creates the form for the given note values.
|
||||||
const NoteForm({
|
const NoteForm({
|
||||||
required this.advancedMode,
|
required this.advancedMode,
|
||||||
@ -29,6 +32,7 @@ class NoteForm extends StatelessWidget {
|
|||||||
this.initialText = '',
|
this.initialText = '',
|
||||||
this.initialTemplate,
|
this.initialTemplate,
|
||||||
this.autofocus = false,
|
this.autofocus = false,
|
||||||
|
this.attachController,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -60,10 +64,34 @@ class NoteForm extends StatelessWidget {
|
|||||||
/// Autofocus the editor, so a fresh capture needs zero taps before typing.
|
/// Autofocus the editor, so a fresh capture needs zero taps before typing.
|
||||||
final bool autofocus;
|
final bool autofocus;
|
||||||
|
|
||||||
|
/// Where the screen's attach actions insert image links.
|
||||||
|
final ImageAttachController? attachController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NoteForm> createState() => _NoteFormState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NoteFormState extends State<NoteForm> {
|
||||||
|
// The live text, for the thumbnail strip only: a notifier so a keystroke
|
||||||
|
// rebuilds the strip, not the editor.
|
||||||
|
late final ValueNotifier<String> _text = ValueNotifier(widget.initialText);
|
||||||
|
bool _previewing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_text.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onChanged(String text) {
|
||||||
|
_text.value = text;
|
||||||
|
widget.onChanged(text);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return NoteFormChromeGate(
|
return NoteFormChromeGate(
|
||||||
advancedMode: advancedMode,
|
advancedMode: widget.advancedMode,
|
||||||
builder:
|
builder:
|
||||||
(context, {required chromeVisible, required onChromeVisibleChanged}) {
|
(context, {required chromeVisible, required onChromeVisibleChanged}) {
|
||||||
return Column(
|
return Column(
|
||||||
@ -71,26 +99,38 @@ class NoteForm extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
if (chromeVisible) ...[
|
if (chromeVisible) ...[
|
||||||
NoteMetaRow(
|
NoteMetaRow(
|
||||||
priority: priority,
|
priority: widget.priority,
|
||||||
status: status,
|
status: widget.status,
|
||||||
onPriorityChanged: onPriorityChanged,
|
onPriorityChanged: widget.onPriorityChanged,
|
||||||
onStatusChanged: onStatusChanged,
|
onStatusChanged: widget.onStatusChanged,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
],
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
child: NoteEditor(
|
child: NoteEditor(
|
||||||
initialText: initialText,
|
initialText: widget.initialText,
|
||||||
initialTemplate: initialTemplate,
|
initialTemplate: widget.initialTemplate,
|
||||||
initialMode: NoteEditorMode.raw,
|
initialMode: NoteEditorMode.raw,
|
||||||
advancedMode: advancedMode,
|
advancedMode: widget.advancedMode,
|
||||||
priority: priority,
|
priority: widget.priority,
|
||||||
onPriorityChanged: onPriorityChanged,
|
onPriorityChanged: widget.onPriorityChanged,
|
||||||
onChromeVisibleChanged: onChromeVisibleChanged,
|
onChromeVisibleChanged: onChromeVisibleChanged,
|
||||||
autofocus: autofocus,
|
autofocus: widget.autofocus,
|
||||||
onChanged: onChanged,
|
onChanged: _onChanged,
|
||||||
|
attachController: widget.attachController,
|
||||||
|
onModeChanged: (mode) => setState(
|
||||||
|
() => _previewing = mode == NoteEditorMode.preview,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// A sibling of the editor's Expanded, not nested inside it:
|
||||||
|
// an extra Column around the expanding field breaks its
|
||||||
|
// height in release builds (see NoteEditorRawField).
|
||||||
|
if (!_previewing)
|
||||||
|
ValueListenableBuilder<String>(
|
||||||
|
valueListenable: _text,
|
||||||
|
builder: (context, text, _) => ImageStrip(text: text),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import 'package:todo/sync/firebase_backend.dart';
|
|||||||
import 'package:todo/sync/google_sign_in_backend.dart';
|
import 'package:todo/sync/google_sign_in_backend.dart';
|
||||||
import 'package:todo/sync/notes_markdown.dart';
|
import 'package:todo/sync/notes_markdown.dart';
|
||||||
import 'package:todo/sync/sync_settings.dart';
|
import 'package:todo/sync/sync_settings.dart';
|
||||||
|
import 'package:todo/ui/cloud_images_settings.dart';
|
||||||
import 'package:todo/ui/github_mirror_screen.dart';
|
import 'package:todo/ui/github_mirror_screen.dart';
|
||||||
|
|
||||||
/// Settings screen: the "Enable advanced" toggle plus links to the two sync
|
/// Settings screen: the "Enable advanced" toggle plus links to the two sync
|
||||||
@ -177,11 +178,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
googleFirebaseFactory:
|
googleFirebaseFactory:
|
||||||
widget.googleFirebaseFactory ?? openFirebaseWithGoogle,
|
widget.googleFirebaseFactory ?? openFirebaseWithGoogle,
|
||||||
googleAvailable: widget.googleAvailable ?? googleSignInSupported,
|
googleAvailable: widget.googleAvailable ?? googleSignInSupported,
|
||||||
backup: BackupSlot(
|
backup: BackupSlot(label: 'notes', export: _export, import: _import),
|
||||||
label: 'notes',
|
|
||||||
export: _export,
|
|
||||||
import: _import,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -231,6 +228,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
onTap: () => unawaited(_openSyncSettings()),
|
onTap: () => unawaited(_openSyncSettings()),
|
||||||
),
|
),
|
||||||
|
CloudImagesTile(httpClient: widget.httpClient),
|
||||||
ListTile(
|
ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: const Text('Advanced sync (GitHub)'),
|
title: const Text('Advanced sync (GitHub)'),
|
||||||
|
|||||||
100
pubspec.lock
100
pubspec.lock
@ -264,14 +264,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.14.4"
|
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:
|
flutter_secure_storage:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_secure_storage
|
name: flutter_secure_storage
|
||||||
sha256: d87713a152ee2f255117bdbbf43da1dea1797e0551e499e0334f0c9dcfafddd2
|
sha256: d4e1fb6b2cb524868929e78dc0282fa000554b22060fb53789dc481c9fc95bb8
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "11.1.1"
|
version: "11.2.0"
|
||||||
flutter_secure_storage_darwin:
|
flutter_secure_storage_darwin:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -420,13 +428,77 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.9.9"
|
version: "2.9.9"
|
||||||
image:
|
image:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: image
|
name: image
|
||||||
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
|
sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
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:
|
jni:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -595,14 +667,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
petitparser:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: petitparser
|
|
||||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.0.2"
|
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -970,7 +1034,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "15.2.0"
|
version: "15.2.0"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: web
|
name: web
|
||||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
@ -993,14 +1057,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
xml:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: xml
|
|
||||||
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.0.1"
|
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
11
pubspec.yaml
11
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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.12.2
|
sdk: ^3.12.2
|
||||||
@ -53,7 +53,7 @@ dependencies:
|
|||||||
|
|
||||||
# Stores the GitHub sync token in the OS keystore (Android Keystore /
|
# Stores the GitHub sync token in the OS keystore (Android Keystore /
|
||||||
# libsecret) instead of plaintext SharedPreferences.
|
# libsecret) instead of plaintext SharedPreferences.
|
||||||
flutter_secure_storage: 11.1.1
|
flutter_secure_storage: 11.2.0
|
||||||
|
|
||||||
github_device_auth:
|
github_device_auth:
|
||||||
git:
|
git:
|
||||||
@ -66,6 +66,11 @@ dependencies:
|
|||||||
google_sign_in: 7.2.0
|
google_sign_in: 7.2.0
|
||||||
http: 1.6.0
|
http: 1.6.0
|
||||||
idb_shim: 2.9.9
|
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: 1.9.1
|
||||||
path_provider: 2.1.6
|
path_provider: 2.1.6
|
||||||
share_plus: 13.3.0
|
share_plus: 13.3.0
|
||||||
@ -86,6 +91,8 @@ dependencies:
|
|||||||
path: sync_settings_ui
|
path: sync_settings_ui
|
||||||
url_launcher: 6.3.2
|
url_launcher: 6.3.2
|
||||||
uuid: 4.6.0
|
uuid: 4.6.0
|
||||||
|
# Paste and drag-and-drop listeners on the desktop (web) page.
|
||||||
|
web: 1.1.1
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
# Virtual clock for unit-testing debounced timers (e.g. LocalBackup).
|
# Virtual clock for unit-testing debounced timers (e.g. LocalBackup).
|
||||||
fake_async: 1.3.3
|
fake_async: 1.3.3
|
||||||
|
|||||||
27
test/attachments/image_password_store_test.dart
Normal file
27
test/attachments/image_password_store_test.dart
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:todo/attachments/image_password_store.dart';
|
||||||
|
|
||||||
|
import '../fake_secure_storage.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
const store = ImagePasswordStore();
|
||||||
|
|
||||||
|
test('write, read, login and delete', () async {
|
||||||
|
installFakeSecureStorage();
|
||||||
|
expect(await store.read(), '');
|
||||||
|
expect(await store.login(), isNull);
|
||||||
|
expect(await store.write('pw'), isTrue);
|
||||||
|
expect(await store.read(), 'pw');
|
||||||
|
final login = (await store.login())!;
|
||||||
|
expect((login.user, login.password), (kDufsImagesUser, 'pw'));
|
||||||
|
expect(await store.write(''), isTrue);
|
||||||
|
expect(await store.read(), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing keystore reads as no password and refuses writes', () async {
|
||||||
|
installFakeSecureStorage(throwing: true);
|
||||||
|
expect(await store.read(), '');
|
||||||
|
expect(await store.write('pw'), isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
70
test/attachments/image_store_api_test.dart
Normal file
70
test/attachments/image_store_api_test.dart
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:todo/attachments/image_store_api.dart';
|
||||||
|
import 'package:todo/attachments/image_store_scope.dart';
|
||||||
|
import 'package:todo/images/image_ref.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final ref = ImageRef.create('n1');
|
||||||
|
|
||||||
|
test('NoImageStore refuses to attach and has nothing', () async {
|
||||||
|
final store = NoImageStore();
|
||||||
|
await expectLater(
|
||||||
|
store.attach('n1', Uint8List(1)),
|
||||||
|
throwsA(isA<ImageStoreException>()),
|
||||||
|
);
|
||||||
|
await store.reconcile(['x']);
|
||||||
|
expect(store.sourceFor(ref), isA<MissingImageSource>());
|
||||||
|
expect(store.isPending(ref), isFalse);
|
||||||
|
final status = await store.status();
|
||||||
|
expect((status.configured, status.pending), (false, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ImageStoreException shows its message', () {
|
||||||
|
expect(const ImageStoreException('boom').toString(), 'boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('imageRefsAcross unions every note', () {
|
||||||
|
final other = ImageRef.create('n2');
|
||||||
|
expect(
|
||||||
|
imageRefsAcross([ref.markdown, '${other.markdown}\n${ref.markdown}']),
|
||||||
|
{ref, other},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the scope provides its store, else a NoImageStore', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final store = NoImageStore();
|
||||||
|
late ImageStore found;
|
||||||
|
late ImageStore read;
|
||||||
|
late ImageStore fallback;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
ImageStoreScope(
|
||||||
|
store: store,
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
found = ImageStoreScope.of(context);
|
||||||
|
read = ImageStoreScope.read(context);
|
||||||
|
return const SizedBox();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
fallback = ImageStoreScope.of(context);
|
||||||
|
return const SizedBox();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(found, same(store));
|
||||||
|
expect(read, same(store));
|
||||||
|
expect(fallback, isA<NoImageStore>());
|
||||||
|
});
|
||||||
|
}
|
||||||
107
test/attachments/image_store_io_test.dart
Normal file
107
test/attachments/image_store_io_test.dart
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:todo/attachments/image_password_store.dart';
|
||||||
|
import 'package:todo/attachments/image_store_api.dart';
|
||||||
|
import 'package:todo/attachments/image_store_io.dart';
|
||||||
|
import 'package:todo/images/image_cache_store.dart';
|
||||||
|
|
||||||
|
import '../fake_secure_storage.dart';
|
||||||
|
import '../images/fake_dufs.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
late Directory root;
|
||||||
|
late FakeDufs dufs;
|
||||||
|
late IoImageStore store;
|
||||||
|
late int notified;
|
||||||
|
|
||||||
|
IoImageStore make({Future<Uint8List?> Function(Uint8List)? prepare}) {
|
||||||
|
final s = IoImageStore(
|
||||||
|
ImageCacheStore(root),
|
||||||
|
httpClient: dufs.httpClient,
|
||||||
|
prepare: prepare ?? (b) async => b.first == 0xFF ? b : null,
|
||||||
|
);
|
||||||
|
notified = 0;
|
||||||
|
s.addListener(() => notified++);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
root = Directory.systemTemp.createTempSync('io_store');
|
||||||
|
dufs = FakeDufs();
|
||||||
|
installFakeSecureStorage(initial: {'dufsImagesPassword': 'pw'});
|
||||||
|
store = make();
|
||||||
|
});
|
||||||
|
tearDown(() => root.deleteSync(recursive: true));
|
||||||
|
|
||||||
|
test('attach stores, notifies and uploads straight away', () async {
|
||||||
|
final ref = (await store.attach('n1', Uint8List.fromList([0xFF, 1])))!;
|
||||||
|
expect(ref.noteId, 'n1');
|
||||||
|
expect(store.sourceFor(ref), isA<LocalImageSource>());
|
||||||
|
expect(notified, greaterThanOrEqualTo(1));
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(dufs.files[ref.relativePath], [0xFF, 1]);
|
||||||
|
expect(store.isPending(ref), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('attach returns null for a non-image and stores nothing', () async {
|
||||||
|
expect(await store.attach('n1', Uint8List.fromList([1])), isNull);
|
||||||
|
expect(root.listSync(), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('attach fails loudly when the image cannot be written', () async {
|
||||||
|
root.deleteSync(recursive: true);
|
||||||
|
File(root.path).writeAsStringSync('not a dir');
|
||||||
|
await expectLater(
|
||||||
|
store.attach('n1', Uint8List.fromList([0xFF])),
|
||||||
|
throwsA(isA<ImageStoreException>()),
|
||||||
|
);
|
||||||
|
File(root.path).deleteSync();
|
||||||
|
root.createSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without a password attach queues offline', () async {
|
||||||
|
installFakeSecureStorage();
|
||||||
|
final ref = (await store.attach('n1', Uint8List.fromList([0xFF])))!;
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(dufs.requests, isEmpty);
|
||||||
|
expect(store.isPending(ref), isTrue);
|
||||||
|
expect((await store.status()).configured, isFalse);
|
||||||
|
expect((await store.status()).pending, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reconcile prefetches what notes link to and notifies', () async {
|
||||||
|
dufs.files['n2/b.jpg'] = [7];
|
||||||
|
final before = notified;
|
||||||
|
await store.reconcile([
|
||||||
|
'x\n',
|
||||||
|
]);
|
||||||
|
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([
|
||||||
|
'',
|
||||||
|
]).single;
|
||||||
|
expect(store.sourceFor(ref), isA<MissingImageSource>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the default prepare and client are the real ones', () {
|
||||||
|
expect(
|
||||||
|
IoImageStore(
|
||||||
|
ImageCacheStore(root),
|
||||||
|
passwords: const ImagePasswordStore(),
|
||||||
|
),
|
||||||
|
isA<ImageStore>(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
119
test/attachments/image_store_web_test.dart
Normal file
119
test/attachments/image_store_web_test.dart
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:todo/attachments/image_store_api.dart';
|
||||||
|
import 'package:todo/attachments/image_store_web.dart';
|
||||||
|
import 'package:todo/images/image_ref.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final requests = <http.Request>[];
|
||||||
|
late int notified;
|
||||||
|
|
||||||
|
WebImageStore make(MockClientHandler handler) {
|
||||||
|
requests.clear();
|
||||||
|
final store = WebImageStore(
|
||||||
|
origin: 'http://localhost:8731',
|
||||||
|
httpClient: MockClient((r) {
|
||||||
|
requests.add(r);
|
||||||
|
return handler(r);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
notified = 0;
|
||||||
|
store.addListener(() => notified++);
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Response report({int downloaded = 0}) =>
|
||||||
|
http.Response(jsonEncode({'downloaded': downloaded}), 200);
|
||||||
|
|
||||||
|
test('openImageStore targets the serving wrapper', () async {
|
||||||
|
final store = await openImageStore() as WebImageStore;
|
||||||
|
expect(store.origin, 'http://localhost:8730');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('attach POSTs the raw bytes and kicks an upload', () async {
|
||||||
|
final store = make(
|
||||||
|
(r) async =>
|
||||||
|
r.url.path.endsWith('reconcile') ? report() : http.Response('', 204),
|
||||||
|
);
|
||||||
|
final ref = (await store.attach('n1', Uint8List.fromList([1, 2])))!;
|
||||||
|
await pumpEventQueue();
|
||||||
|
expect(requests.first.url.path, '/images/${ref.relativePath}');
|
||||||
|
expect(requests.first.bodyBytes, [1, 2]);
|
||||||
|
expect(requests.last.url.path, '/images/reconcile');
|
||||||
|
expect(jsonDecode(requests.last.body), {'refs': <String>[]});
|
||||||
|
expect(notified, 1);
|
||||||
|
expect(store.sourceFor(ref), isA<NetworkImageSource>());
|
||||||
|
expect(
|
||||||
|
(store.sourceFor(ref) as NetworkImageSource).url,
|
||||||
|
'http://localhost:8731/images/${ref.relativePath}?r=1',
|
||||||
|
);
|
||||||
|
expect(store.isPending(ref), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'attach: 415 is not an image, other codes and no wrapper throw',
|
||||||
|
() async {
|
||||||
|
expect(
|
||||||
|
await make(
|
||||||
|
(_) async => http.Response('', 415),
|
||||||
|
).attach('n1', Uint8List(1)),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
make((_) async => http.Response('', 500)).attach('n1', Uint8List(1)),
|
||||||
|
throwsA(isA<ImageStoreException>()),
|
||||||
|
);
|
||||||
|
await expectLater(
|
||||||
|
make(
|
||||||
|
(_) => throw const SocketException('no wrapper'),
|
||||||
|
).attach('n1', Uint8List(1)),
|
||||||
|
throwsA(
|
||||||
|
isA<ImageStoreException>().having(
|
||||||
|
(e) => e.toString(),
|
||||||
|
'message',
|
||||||
|
contains('not running'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('reconcile sends every linked ref; a download bumps the url', () async {
|
||||||
|
final ref = ImageRef.create('n1');
|
||||||
|
final store = make((_) async => report(downloaded: 1));
|
||||||
|
await store.reconcile(['a ${ref.markdown}', 'none']);
|
||||||
|
expect(jsonDecode(requests.single.body), {
|
||||||
|
'refs': [ref.relativePath],
|
||||||
|
});
|
||||||
|
expect(notified, 1);
|
||||||
|
expect((store.sourceFor(ref) as NetworkImageSource).url, endsWith('r=1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reconcile ignores failures and no-op reports', () async {
|
||||||
|
await make((_) async => report()).reconcile([]);
|
||||||
|
expect(notified, 0);
|
||||||
|
await make((_) async => http.Response('', 500)).reconcile([]);
|
||||||
|
await make((_) => throw const SocketException('x')).reconcile([]);
|
||||||
|
expect(notified, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status reads the wrapper, and degrades when it is gone', () async {
|
||||||
|
final ok = await make(
|
||||||
|
(_) async => http.Response('{"configured":true,"pending":2}', 200),
|
||||||
|
).status();
|
||||||
|
expect((ok.configured, ok.pending), (true, 2));
|
||||||
|
final empty = await make((_) async => http.Response('{}', 200)).status();
|
||||||
|
expect((empty.configured, empty.pending), (false, 0));
|
||||||
|
final gone = await make((_) => throw const SocketException('x')).status();
|
||||||
|
expect(gone.configured, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the default http client is created', () {
|
||||||
|
expect(WebImageStore(origin: 'http://x'), isA<ImageStore>());
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -17,6 +17,8 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'package:http/testing.dart';
|
import 'package:http/testing.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:todo/analytics/analytics_service.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/app_settings.dart';
|
||||||
import 'package:todo/data/note.dart';
|
import 'package:todo/data/note.dart';
|
||||||
import 'package:todo/data/note_template.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/sync/notes_markdown.dart';
|
||||||
import 'package:todo/ui/capture_screen.dart';
|
import 'package:todo/ui/capture_screen.dart';
|
||||||
import 'package:todo/ui/github_mirror_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 'package:todo/ui/settings_screen.dart';
|
||||||
|
|
||||||
import 'fake_note_repository.dart';
|
import 'fake_note_repository.dart';
|
||||||
@ -46,6 +49,8 @@ Future<FakeNoteRepository> pumpCapture(
|
|||||||
// behavior pass advancedMode: false explicitly.
|
// behavior pass advancedMode: false explicitly.
|
||||||
ValueNotifier<AppSettings>? appSettings,
|
ValueNotifier<AppSettings>? appSettings,
|
||||||
AnalyticsService? analytics,
|
AnalyticsService? analytics,
|
||||||
|
ImageStore? imageStore,
|
||||||
|
ImagePicking? imagePicking,
|
||||||
}) async {
|
}) async {
|
||||||
SharedPreferences.setMockInitialValues(prefs);
|
SharedPreferences.setMockInitialValues(prefs);
|
||||||
installFakeSecureStorage();
|
installFakeSecureStorage();
|
||||||
@ -67,23 +72,25 @@ Future<FakeNoteRepository> pumpCapture(
|
|||||||
writer: (_) async {},
|
writer: (_) async {},
|
||||||
debounce: Duration.zero,
|
debounce: Duration.zero,
|
||||||
);
|
);
|
||||||
await tester.pumpWidget(
|
final app = MaterialApp(
|
||||||
MaterialApp(
|
home: CaptureScreen(
|
||||||
home: CaptureScreen(
|
repository: repo,
|
||||||
repository: repo,
|
appSettings:
|
||||||
appSettings:
|
appSettings ?? ValueNotifier(const AppSettings(advancedMode: true)),
|
||||||
appSettings ?? ValueNotifier(const AppSettings(advancedMode: true)),
|
analytics: analytics,
|
||||||
analytics: analytics,
|
httpClient: httpClient,
|
||||||
httpClient: httpClient,
|
// Both injected so the widget never reaches for the platform: the
|
||||||
// Both injected so the widget never reaches for the platform: the
|
// real factories want the OS keystore and an application-support
|
||||||
// real factories want the OS keystore and an application-support
|
// directory, neither of which exists under `flutter test`.
|
||||||
// directory, neither of which exists under `flutter test`.
|
firebaseFactory: firebaseFactory ?? () async => null,
|
||||||
firebaseFactory: firebaseFactory ?? () async => null,
|
stateStore: InMemorySyncStateStore(),
|
||||||
stateStore: InMemorySyncStateStore(),
|
localBackup: backup,
|
||||||
localBackup: backup,
|
imagePicking: imagePicking,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
imageStore == null ? app : ImageStoreScope(store: imageStore, child: app),
|
||||||
|
);
|
||||||
await tester.pump(); // flush initial stream + settings load
|
await tester.pump(); // flush initial stream + settings load
|
||||||
return repo;
|
return repo;
|
||||||
}
|
}
|
||||||
|
|||||||
45
test/desktop/dufs_login_env_test.dart
Normal file
45
test/desktop/dufs_login_env_test.dart
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:todo/desktop/dufs_login_env.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Directory dir;
|
||||||
|
setUp(() => dir = Directory.systemTemp.createTempSync('dufs_env'));
|
||||||
|
tearDown(() => dir.deleteSync(recursive: true));
|
||||||
|
|
||||||
|
File env(String body) =>
|
||||||
|
File(p.join(dir.path, 'todo.env'))..writeAsStringSync(body);
|
||||||
|
|
||||||
|
test('default path is ~/.config/dufs/logins/todo.env', () {
|
||||||
|
expect(defaultDufsLoginEnvPath('/h'), '/h/.config/dufs/logins/todo.env');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reads the login add_dufs_login.sh writes', () {
|
||||||
|
final login = readDufsLoginEnv(
|
||||||
|
env(
|
||||||
|
'# written by add_dufs_login.sh\n'
|
||||||
|
'DUFS_URL=https://kuhy-cloud.duckdns.org/\n'
|
||||||
|
'DUFS_USER=todo\nDUFS_PASSWORD=a=b\nDUFS_PATH=/todo-images\nnoise\n',
|
||||||
|
),
|
||||||
|
)!;
|
||||||
|
expect(login.user, 'todo');
|
||||||
|
expect(login.password, 'a=b', reason: 'only the first = splits');
|
||||||
|
expect(login.baseUrl, 'https://kuhy-cloud.duckdns.org');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps a url without a trailing slash as is', () {
|
||||||
|
final login = readDufsLoginEnv(
|
||||||
|
env('DUFS_URL=http://x\nDUFS_USER=u\nDUFS_PASSWORD=p\n'),
|
||||||
|
)!;
|
||||||
|
expect(login.baseUrl, 'http://x');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('absent or incomplete files give no login', () {
|
||||||
|
expect(readDufsLoginEnv(File(p.join(dir.path, 'nope.env'))), isNull);
|
||||||
|
expect(readDufsLoginEnv(env('DUFS_USER=u\nDUFS_PASSWORD=p\n')), isNull);
|
||||||
|
expect(readDufsLoginEnv(env('DUFS_URL=x\nDUFS_USER=u\n')), isNull);
|
||||||
|
expect(readDufsLoginEnv(env('DUFS_URL=x\nDUFS_PASSWORD=p\n')), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
164
test/desktop/wrapper_image_routes_test.dart
Normal file
164
test/desktop/wrapper_image_routes_test.dart
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:todo/desktop/wrapper_image_routes.dart';
|
||||||
|
import 'package:todo/desktop/wrapper_server.dart';
|
||||||
|
import 'package:todo/images/image_cache_store.dart';
|
||||||
|
import 'package:todo/images/image_ref.dart';
|
||||||
|
|
||||||
|
import '../images/fake_dufs.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Directory root;
|
||||||
|
late WrapperServer server;
|
||||||
|
late String base;
|
||||||
|
late FakeDufs dufs;
|
||||||
|
late ImageCacheStore store;
|
||||||
|
const rel = 'n1/0815ff8a-203e-4f54-9cc5-8a58b27982ec.jpg';
|
||||||
|
|
||||||
|
File envFile() => File(p.join(root.path, 'dufs', 'todo.env'));
|
||||||
|
|
||||||
|
void writeLogin() {
|
||||||
|
envFile()
|
||||||
|
..parent.createSync(recursive: true)
|
||||||
|
..writeAsStringSync(
|
||||||
|
'DUFS_URL=https://d\nDUFS_USER=todo\nDUFS_PASSWORD=pw\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
root = Directory.systemTemp.createTempSync('wrapper_images');
|
||||||
|
dufs = FakeDufs();
|
||||||
|
store = ImageCacheStore(Directory(p.join(root.path, 'images')));
|
||||||
|
server = WrapperServer(
|
||||||
|
webRoot: root.path,
|
||||||
|
backlogPath: p.join(root.path, 'BACKLOG.md'),
|
||||||
|
logPath: p.join(root.path, 'log.json'),
|
||||||
|
todoCredentialsPath: p.join(root.path, 'none.json'),
|
||||||
|
images: WrapperImageRoutes(
|
||||||
|
store: store,
|
||||||
|
loginEnvPath: envFile().path,
|
||||||
|
httpClient: dufs.httpClient,
|
||||||
|
// Identity "re-encode" that still refuses non-images, so the
|
||||||
|
// routes are tested without decoding real photos.
|
||||||
|
prepare: (bytes) async => bytes.first == 0xFF ? bytes : null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await server.start(0);
|
||||||
|
base = 'http://localhost:${server.port}';
|
||||||
|
});
|
||||||
|
tearDown(() async {
|
||||||
|
await server.stop();
|
||||||
|
root.deleteSync(recursive: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<http.Response> reconcile(Object body) =>
|
||||||
|
http.post(Uri.parse('$base/images/reconcile'), body: jsonEncode(body));
|
||||||
|
|
||||||
|
test('the default routes live under the given home', () {
|
||||||
|
final routes = defaultWrapperImageRoutes('/h');
|
||||||
|
expect(routes.store.root.path, '/h/.local/share/todo-desktop/images');
|
||||||
|
expect(routes.loginEnvPath, '/h/.config/dufs/logins/todo.env');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST stores and queues an image, GET serves it', () async {
|
||||||
|
final post = await http.post(
|
||||||
|
Uri.parse('$base/images/$rel'),
|
||||||
|
body: Uint8List.fromList([0xFF, 1, 2]),
|
||||||
|
);
|
||||||
|
expect(post.statusCode, 204);
|
||||||
|
expect(store.isPending(ImageRef.tryParse(rel)!), isTrue);
|
||||||
|
final get = await http.get(Uri.parse('$base/images/$rel'));
|
||||||
|
expect(get.statusCode, 200);
|
||||||
|
expect(get.headers['content-type'], 'image/jpeg');
|
||||||
|
expect(get.headers['cache-control'], contains('max-age'));
|
||||||
|
expect(get.bodyBytes, [0xFF, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-images are refused with 415 and nothing is stored', () async {
|
||||||
|
final post = await http.post(
|
||||||
|
Uri.parse('$base/images/$rel'),
|
||||||
|
body: Uint8List.fromList([1, 2]),
|
||||||
|
);
|
||||||
|
expect(post.statusCode, 415);
|
||||||
|
expect(store.pending(), isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('oversized uploads are refused with 413', () async {
|
||||||
|
final big = Uint8List(kMaxImageUploadBytes + 1)..[0] = 0xFF;
|
||||||
|
final post = await http.post(Uri.parse('$base/images/$rel'), body: big);
|
||||||
|
expect(post.statusCode, 413);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown image, bad path and bad method', () async {
|
||||||
|
expect((await http.get(Uri.parse('$base/images/$rel'))).statusCode, 404);
|
||||||
|
expect(
|
||||||
|
(await http.get(Uri.parse('$base/images/n1/x.png'))).statusCode,
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
expect((await http.delete(Uri.parse('$base/images/$rel'))).statusCode, 405);
|
||||||
|
expect(
|
||||||
|
(await http.get(Uri.parse('$base/images/reconcile'))).statusCode,
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status reports login and queue', () async {
|
||||||
|
Future<Map<String, dynamic>> status() async =>
|
||||||
|
jsonDecode((await http.get(Uri.parse('$base/images/status'))).body)
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
expect(await status(), {'configured': false, 'pending': 0});
|
||||||
|
writeLogin();
|
||||||
|
await http.post(
|
||||||
|
Uri.parse('$base/images/$rel'),
|
||||||
|
body: Uint8List.fromList([0xFF]),
|
||||||
|
);
|
||||||
|
expect(await status(), {'configured': true, 'pending': 1});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reconcile uploads with the env login and prefetches', () async {
|
||||||
|
writeLogin();
|
||||||
|
await http.post(
|
||||||
|
Uri.parse('$base/images/$rel'),
|
||||||
|
body: Uint8List.fromList([0xFF, 9]),
|
||||||
|
);
|
||||||
|
dufs.files['n2/b.jpg'] = [5];
|
||||||
|
final response = await reconcile({
|
||||||
|
'refs': [rel, 'n2/b.jpg', '../bad.jpg'],
|
||||||
|
});
|
||||||
|
expect(response.statusCode, 200);
|
||||||
|
expect(jsonDecode(response.body), {
|
||||||
|
'uploaded': 1,
|
||||||
|
'downloaded': 1,
|
||||||
|
'evicted': 0,
|
||||||
|
'pending': 0,
|
||||||
|
'missing': 0,
|
||||||
|
});
|
||||||
|
expect(dufs.files[rel], [0xFF, 9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reconcile without a login only reports', () async {
|
||||||
|
final response = await reconcile({'refs': <String>[]});
|
||||||
|
expect(jsonDecode(response.body)['uploaded'], 0);
|
||||||
|
expect(dufs.requests, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reconcile rejects malformed bodies', () async {
|
||||||
|
for (final body in ['not json', '[]', '{"refs": [1]}', '{}']) {
|
||||||
|
final response = await http.post(
|
||||||
|
Uri.parse('$base/images/reconcile'),
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
expect(response.statusCode, 400, reason: body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('jpg is served with an image content type', () {
|
||||||
|
expect(WrapperServer.contentTypeFor('a.JPG').mimeType, 'image/jpeg');
|
||||||
|
expect(WrapperServer.contentTypeFor('a.jpeg').mimeType, 'image/jpeg');
|
||||||
|
});
|
||||||
|
}
|
||||||
74
test/fake_image_store.dart
Normal file
74
test/fake_image_store.dart
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
/// An in-memory [ImageStore] for widget tests.
|
||||||
|
///
|
||||||
|
/// Deliberately NOT named `*_test.dart`: the runner would collect it.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:todo/attachments/image_store_api.dart';
|
||||||
|
import 'package:todo/images/image_ref.dart';
|
||||||
|
|
||||||
|
/// Records attaches and reconciles; every image is "local" at a fake path
|
||||||
|
/// unless listed in [missing].
|
||||||
|
class FakeImageStore extends ImageStore {
|
||||||
|
/// Bytes accepted per ref, in attach order.
|
||||||
|
final Map<ImageRef, Uint8List> attached = {};
|
||||||
|
|
||||||
|
/// Note texts passed to each reconcile.
|
||||||
|
final List<List<String>> reconciles = [];
|
||||||
|
|
||||||
|
/// Refs reported as not on this device.
|
||||||
|
final Set<ImageRef> missing = {};
|
||||||
|
|
||||||
|
/// Refs reported as waiting to upload.
|
||||||
|
final Set<ImageRef> pending = {};
|
||||||
|
|
||||||
|
/// Inputs whose first byte is this are "not an image".
|
||||||
|
int notImageMarker = 0;
|
||||||
|
|
||||||
|
/// When set, attach throws this.
|
||||||
|
ImageStoreException? failure;
|
||||||
|
|
||||||
|
/// Where local images appear to live, e.g. a real temp file.
|
||||||
|
String localPath = '/nonexistent/image.jpg';
|
||||||
|
|
||||||
|
/// Returns [NetworkImageSource]s instead of local ones.
|
||||||
|
bool network = false;
|
||||||
|
|
||||||
|
/// When set, attach waits for it first (to act mid-attach).
|
||||||
|
Future<void>? gate;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ImageRef?> attach(String noteId, Uint8List bytes) async {
|
||||||
|
await gate;
|
||||||
|
final failure = this.failure;
|
||||||
|
if (failure != null) throw failure;
|
||||||
|
if (bytes.isNotEmpty && bytes.first == notImageMarker) return null;
|
||||||
|
final ref = ImageRef.create(noteId);
|
||||||
|
attached[ref] = bytes;
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reconcile(Iterable<String> noteTexts) async =>
|
||||||
|
reconciles.add(noteTexts.toList());
|
||||||
|
|
||||||
|
@override
|
||||||
|
ImageSource sourceFor(ImageRef ref) {
|
||||||
|
if (missing.contains(ref)) return const MissingImageSource();
|
||||||
|
return network
|
||||||
|
? NetworkImageSource('http://localhost/images/$ref')
|
||||||
|
: LocalImageSource(localPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool isPending(ImageRef ref) => pending.contains(ref);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<ImageStoreStatus> status() async =>
|
||||||
|
ImageStoreStatus(configured: true, pending: pending.length);
|
||||||
|
|
||||||
|
/// Lets a test fire a change notification.
|
||||||
|
void changed() => notifyListeners();
|
||||||
|
}
|
||||||
110
test/images/dufs_image_client_test.dart
Normal file
110
test/images/dufs_image_client_test.dart
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:todo/images/dufs_image_client.dart';
|
||||||
|
import 'package:todo/images/image_ref.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final ref = ImageRef.tryParse('n1/a.jpg')!;
|
||||||
|
const login = DufsLogin(user: 'todo', password: 'pw', baseUrl: 'https://d');
|
||||||
|
final auth = 'Basic ${base64.encode(utf8.encode('todo:pw'))}';
|
||||||
|
|
||||||
|
DufsImageClient client(MockClientHandler handler) =>
|
||||||
|
DufsImageClient(login, httpClient: MockClient(handler));
|
||||||
|
|
||||||
|
test('the default login points at kuhy-cloud', () {
|
||||||
|
expect(const DufsLogin(user: 'u', password: 'p').baseUrl, kDufsBaseUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upload PUTs the bytes with basic auth; true on 2xx', () async {
|
||||||
|
late http.Request seen;
|
||||||
|
final ok = await client((r) async {
|
||||||
|
seen = r;
|
||||||
|
return http.Response('', 201);
|
||||||
|
}).upload(ref, Uint8List.fromList([1, 2]));
|
||||||
|
expect(ok, isTrue);
|
||||||
|
expect(seen.method, 'PUT');
|
||||||
|
expect(seen.url.toString(), 'https://d/todo-images/n1/a.jpg');
|
||||||
|
expect(seen.headers['authorization'], auth);
|
||||||
|
expect(seen.headers['content-type'], 'image/jpeg');
|
||||||
|
expect(seen.bodyBytes, [1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upload is false on a refusal or a network error', () async {
|
||||||
|
expect(
|
||||||
|
await client(
|
||||||
|
(_) async => http.Response('', 403),
|
||||||
|
).upload(ref, Uint8List(1)),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await client(
|
||||||
|
(_) => throw const SocketException('down'),
|
||||||
|
).upload(ref, Uint8List(1)),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('download returns bytes on 200, null otherwise', () async {
|
||||||
|
expect(
|
||||||
|
await client((r) async {
|
||||||
|
expect(r.headers['authorization'], auth);
|
||||||
|
return http.Response.bytes([7, 8], 200);
|
||||||
|
}).download(ref),
|
||||||
|
[7, 8],
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await client((_) async => http.Response('', 404)).download(ref),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await client((_) => throw const SocketException('down')).download(ref),
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('check maps PROPFIND answers', () async {
|
||||||
|
Future<DufsCheck> answer(int code) => client((r) async {
|
||||||
|
expect(r.method, 'PROPFIND');
|
||||||
|
expect(r.headers['depth'], '0');
|
||||||
|
expect(r.url.toString(), 'https://d/todo-images/');
|
||||||
|
return http.Response('', code);
|
||||||
|
}).check();
|
||||||
|
expect(await answer(207), DufsCheck.ok);
|
||||||
|
expect(await answer(401), DufsCheck.rejected);
|
||||||
|
expect(await answer(403), DufsCheck.rejected);
|
||||||
|
expect(await answer(502), DufsCheck.unreachable);
|
||||||
|
expect(
|
||||||
|
await client((_) => throw const SocketException('down')).check(),
|
||||||
|
DufsCheck.unreachable,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a default http client is created when none is given', () {
|
||||||
|
DufsImageClient(login).close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('close closes the http client', () {
|
||||||
|
var closed = false;
|
||||||
|
final inner = _ClosingClient(() => closed = true);
|
||||||
|
DufsImageClient(login, httpClient: inner).close();
|
||||||
|
expect(closed, isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ClosingClient extends http.BaseClient {
|
||||||
|
_ClosingClient(this.onClose);
|
||||||
|
|
||||||
|
final void Function() onClose;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<http.StreamedResponse> send(http.BaseRequest request) =>
|
||||||
|
throw UnimplementedError();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void close() => onClose();
|
||||||
|
}
|
||||||
48
test/images/fake_dufs.dart
Normal file
48
test/images/fake_dufs.dart
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
/// An in-memory dufs for image-sync tests: a map of `todo-images/...` paths
|
||||||
|
/// to bytes behind a MockClient, with an [online] switch.
|
||||||
|
///
|
||||||
|
/// Deliberately NOT named `*_test.dart`: the runner would collect it.
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:todo/images/dufs_image_client.dart';
|
||||||
|
|
||||||
|
/// Fake server state plus a client factory over it.
|
||||||
|
class FakeDufs {
|
||||||
|
/// Stored files, keyed by the path after `/todo-images/`.
|
||||||
|
final Map<String, List<int>> files = {};
|
||||||
|
|
||||||
|
/// When false every request fails like a dropped connection.
|
||||||
|
bool online = true;
|
||||||
|
|
||||||
|
/// Every request's `METHOD path`, in order.
|
||||||
|
final List<String> requests = [];
|
||||||
|
|
||||||
|
/// The HTTP client the image client talks through.
|
||||||
|
http.Client httpClient() => MockClient((request) async {
|
||||||
|
requests.add('${request.method} ${request.url.path}');
|
||||||
|
if (!online) throw const SocketException('offline');
|
||||||
|
final key = request.url.path.replaceFirst('/todo-images/', '');
|
||||||
|
switch (request.method) {
|
||||||
|
case 'PUT':
|
||||||
|
files[key] = request.bodyBytes;
|
||||||
|
return http.Response('', 201);
|
||||||
|
case 'GET':
|
||||||
|
final bytes = files[key];
|
||||||
|
return bytes == null
|
||||||
|
? http.Response('', 404)
|
||||||
|
: http.Response.bytes(bytes, 200);
|
||||||
|
default:
|
||||||
|
return http.Response('', 207);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/// A client over this fake.
|
||||||
|
DufsImageClient client() => DufsImageClient(
|
||||||
|
const DufsLogin(user: 'todo', password: 'pw', baseUrl: 'https://d'),
|
||||||
|
httpClient: httpClient(),
|
||||||
|
);
|
||||||
|
}
|
||||||
185
test/images/image_cache_store_test.dart
Normal file
185
test/images/image_cache_store_test.dart
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:todo/images/image_cache_store.dart';
|
||||||
|
import 'package:todo/images/image_ref.dart';
|
||||||
|
|
||||||
|
import 'fake_dufs.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Directory root;
|
||||||
|
late ImageCacheStore store;
|
||||||
|
late FakeDufs dufs;
|
||||||
|
var now = DateTime(2026, 9, 25, 12);
|
||||||
|
|
||||||
|
final a = ImageRef.tryParse('n1/a.jpg')!;
|
||||||
|
final b = ImageRef.tryParse('n2/b.jpg')!;
|
||||||
|
final bytes = Uint8List.fromList([1, 2, 3]);
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
root = Directory.systemTemp.createTempSync('image_cache');
|
||||||
|
now = DateTime.now();
|
||||||
|
store = ImageCacheStore(root, now: () => now);
|
||||||
|
dufs = FakeDufs();
|
||||||
|
});
|
||||||
|
tearDown(() => root.deleteSync(recursive: true));
|
||||||
|
|
||||||
|
void age(ImageRef ref, Duration by) =>
|
||||||
|
store.fileFor(ref).setLastModifiedSync(now.subtract(by));
|
||||||
|
|
||||||
|
test('put stores the file and queues it', () async {
|
||||||
|
await store.put(a, bytes);
|
||||||
|
expect(store.fileFor(a).readAsBytesSync(), bytes);
|
||||||
|
expect(store.isPending(a), isTrue);
|
||||||
|
expect(store.pending(), [a]);
|
||||||
|
expect(File('${store.fileFor(a).path}.tmp').existsSync(), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty or missing root has nothing pending', () {
|
||||||
|
root.deleteSync(recursive: true);
|
||||||
|
expect(store.pending(), isEmpty);
|
||||||
|
root.createSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reconcile uploads the queue and clears the marker', () async {
|
||||||
|
await store.put(a, bytes);
|
||||||
|
final report = await store.reconcile({a}, dufs.client());
|
||||||
|
expect(dufs.files['n1/a.jpg'], bytes);
|
||||||
|
expect(store.isPending(a), isFalse);
|
||||||
|
expect(report.toJson(), {
|
||||||
|
'uploaded': 1,
|
||||||
|
'downloaded': 0,
|
||||||
|
'evicted': 0,
|
||||||
|
'pending': 0,
|
||||||
|
'missing': 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed upload keeps the marker for next time', () async {
|
||||||
|
await store.put(a, bytes);
|
||||||
|
dufs.online = false;
|
||||||
|
final report = await store.reconcile({a}, dufs.client());
|
||||||
|
expect(store.isPending(a), isTrue);
|
||||||
|
expect(report.pending, 1);
|
||||||
|
dufs.online = true;
|
||||||
|
expect((await store.reconcile({a}, dufs.client())).uploaded, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a marker whose file never landed is skipped, not uploaded', () async {
|
||||||
|
await store.put(a, bytes);
|
||||||
|
store.fileFor(a).deleteSync();
|
||||||
|
final report = await store.reconcile({}, dufs.client());
|
||||||
|
expect(report.uploaded, 0);
|
||||||
|
expect(dufs.files, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without a client only eviction runs', () async {
|
||||||
|
await store.put(a, bytes);
|
||||||
|
final report = await store.reconcile({b}, null);
|
||||||
|
expect(dufs.requests, isEmpty);
|
||||||
|
expect(report.pending, 1);
|
||||||
|
expect(report.missing, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reconcile downloads linked images this device lacks', () async {
|
||||||
|
dufs.files['n2/b.jpg'] = [9];
|
||||||
|
final report = await store.reconcile({b}, dufs.client());
|
||||||
|
expect(store.fileFor(b).readAsBytesSync(), [9]);
|
||||||
|
expect(report.downloaded, 1);
|
||||||
|
expect(report.missing, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an image missing on dufs stays missing', () async {
|
||||||
|
final report = await store.reconcile({b}, dufs.client());
|
||||||
|
expect(report.downloaded, 0);
|
||||||
|
expect(report.missing, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('evicts old unlinked cached images only', () async {
|
||||||
|
dufs.files
|
||||||
|
..['n1/a.jpg'] = [1]
|
||||||
|
..['n2/b.jpg'] = [2];
|
||||||
|
await store.reconcile({a, b}, dufs.client());
|
||||||
|
age(a, const Duration(hours: 1));
|
||||||
|
age(b, const Duration(hours: 1));
|
||||||
|
final report = await store.reconcile({b}, dufs.client());
|
||||||
|
expect(report.evicted, 1);
|
||||||
|
expect(store.fileFor(a).existsSync(), isFalse);
|
||||||
|
expect(store.fileFor(b).existsSync(), isTrue);
|
||||||
|
expect(dufs.files.keys, contains('n1/a.jpg'), reason: 'never on dufs');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eviction spares queued and unparseable files', () async {
|
||||||
|
await store.put(a, bytes);
|
||||||
|
age(a, const Duration(hours: 1));
|
||||||
|
File(p.join(root.path, 'n1', 'odd name.jpg'))
|
||||||
|
..writeAsBytesSync([1])
|
||||||
|
..setLastModifiedSync(now.subtract(const Duration(hours: 1)));
|
||||||
|
dufs.online = false; // a stays queued
|
||||||
|
final report = await store.reconcile({b}, dufs.client());
|
||||||
|
expect(report.evicted, 0);
|
||||||
|
expect(store.fileFor(a).existsSync(), isTrue);
|
||||||
|
dufs.online = true; // uploaded now, so old + unlinked => evicted
|
||||||
|
expect((await store.reconcile({b}, dufs.client())).evicted, 1);
|
||||||
|
expect(store.fileFor(a).existsSync(), isFalse);
|
||||||
|
expect(File(p.join(root.path, 'n1', 'odd name.jpg')).existsSync(), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a young unlinked image survives the grace period', () async {
|
||||||
|
dufs.files['n1/a.jpg'] = [1];
|
||||||
|
await store.reconcile({a}, dufs.client());
|
||||||
|
final report = await store.reconcile({b}, dufs.client());
|
||||||
|
expect(report.evicted, 0);
|
||||||
|
expect(store.fileFor(a).existsSync(), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty ref set never evicts', () async {
|
||||||
|
dufs.files['n1/a.jpg'] = [1];
|
||||||
|
await store.reconcile({a}, dufs.client());
|
||||||
|
age(a, const Duration(days: 1));
|
||||||
|
expect((await store.reconcile({}, dufs.client())).evicted, 0);
|
||||||
|
expect(store.fileFor(a).existsSync(), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calls before a pass starts merge into it', () async {
|
||||||
|
dufs.files
|
||||||
|
..['n1/a.jpg'] = [1]
|
||||||
|
..['n2/b.jpg'] = [2];
|
||||||
|
final first = store.reconcile({a}, null);
|
||||||
|
final second = store.reconcile({}, dufs.client()); // upload-only
|
||||||
|
final third = store.reconcile({b}, null);
|
||||||
|
expect(identical(first, second) && identical(second, third), isTrue);
|
||||||
|
final report = await first;
|
||||||
|
// One pass: refs unioned, and the only client given was kept.
|
||||||
|
expect(report.downloaded, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a call during a running pass gets one follow-up pass', () async {
|
||||||
|
dufs.files['n2/b.jpg'] = [2];
|
||||||
|
final running = store.reconcile({}, dufs.client());
|
||||||
|
await Future<void>.delayed(Duration.zero); // let it start
|
||||||
|
final next = store.reconcile({b}, dufs.client());
|
||||||
|
expect(identical(running, next), isFalse);
|
||||||
|
await running;
|
||||||
|
expect((await next).downloaded, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failing pass does not wedge the queue', () async {
|
||||||
|
final boom = ImageCacheStore(
|
||||||
|
Directory(p.join(root.path, 'file-not-dir')),
|
||||||
|
now: () => now,
|
||||||
|
);
|
||||||
|
File(boom.root.path).writeAsStringSync('x');
|
||||||
|
dufs.files['n1/a.jpg'] = [1]; // downloading it must create a dir: fails
|
||||||
|
await expectLater(
|
||||||
|
boom.reconcile({a}, dufs.client()),
|
||||||
|
throwsA(isA<FileSystemException>()),
|
||||||
|
);
|
||||||
|
File(boom.root.path).deleteSync();
|
||||||
|
final report = await boom.reconcile({}, null);
|
||||||
|
expect(report.pending, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
57
test/images/image_processing_test.dart
Normal file
57
test/images/image_processing_test.dart
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:todo/images/image_processing.dart';
|
||||||
|
|
||||||
|
/// A JPEG built in-test (the binary gate forbids committed fixtures), with
|
||||||
|
/// GPS coordinates and an EXIF rotation, like a phone photo.
|
||||||
|
Uint8List phonePhoto({int width = 4000, int height = 3000}) {
|
||||||
|
final photo = img.Image(width: width, height: height)
|
||||||
|
..clear(img.ColorRgb8(200, 30, 30));
|
||||||
|
photo.exif.imageIfd.orientation = 6;
|
||||||
|
photo.exif.gpsIfd['GPSLatitude'] = img.IfdValueRational(52, 1);
|
||||||
|
return img.encodeJpg(photo);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('the fixture really carries GPS and a rotation', () {
|
||||||
|
final exif = img.decodeJpgExif(phonePhoto(width: 40, height: 30))!;
|
||||||
|
expect(exif.gpsIfd.isEmpty, isFalse);
|
||||||
|
expect(exif.imageIfd.orientation, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shrinks, applies the rotation and drops all EXIF', () async {
|
||||||
|
final out = (await prepareImageInBackground(phonePhoto()))!;
|
||||||
|
final back = img.decodeJpg(out)!;
|
||||||
|
// Landscape pixels + "rotate 90" => upright portrait, long side capped.
|
||||||
|
expect(back.width, 1920);
|
||||||
|
expect(back.height, kMaxImageSide);
|
||||||
|
expect(img.decodeJpgExif(out)?.isEmpty ?? true, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('caps a landscape image on its width', () {
|
||||||
|
final wide = img.encodeJpg(img.Image(width: 3000, height: 1000));
|
||||||
|
final back = img.decodeJpg(prepareImage(wide)!)!;
|
||||||
|
expect(back.width, kMaxImageSide);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves a small image at its size', () {
|
||||||
|
final small = img.encodePng(img.Image(width: 300, height: 200));
|
||||||
|
final back = img.decodeJpg(prepareImage(small)!)!;
|
||||||
|
expect((back.width, back.height), (300, 200));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('flattens transparency onto white', () {
|
||||||
|
final clear = img.Image(width: 8, height: 8, numChannels: 4);
|
||||||
|
final back = img.decodeJpg(prepareImage(img.encodePng(clear))!)!;
|
||||||
|
final pixel = back.getPixel(4, 4);
|
||||||
|
expect([pixel.r, pixel.g, pixel.b], everyElement(greaterThan(250)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-images come back null, including ones the decoder throws on', () {
|
||||||
|
expect(prepareImage(Uint8List.fromList([1, 2, 3])), isNull);
|
||||||
|
expect(prepareImage(Uint8List.fromList('hello world'.codeUnits)), isNull);
|
||||||
|
expect(prepareImage(Uint8List(0)), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
74
test/images/image_ref_test.dart
Normal file
74
test/images/image_ref_test.dart
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:todo/images/image_ref.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
const rel = 'note-1/0815ff8a-203e-4f54-9cc5-8a58b27982ec.jpg';
|
||||||
|
final ref = ImageRef.tryParse(rel)!;
|
||||||
|
|
||||||
|
test('a ref exposes its parts, url and markdown', () {
|
||||||
|
expect(ref.noteId, 'note-1');
|
||||||
|
expect(ref.file, '0815ff8a-203e-4f54-9cc5-8a58b27982ec.jpg');
|
||||||
|
expect(ref.relativePath, rel);
|
||||||
|
expect(ref.url, '$kDufsBaseUrl/$kImagesDir/$rel');
|
||||||
|
expect(ref.markdown, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
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'
|
||||||
|
' ';
|
||||||
|
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(''), ref);
|
||||||
|
expect(imageRefOfLine('plain'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('backlog paths round-trip exactly', () {
|
||||||
|
final text = 'a\n${ref.markdown}\n';
|
||||||
|
final disk = toBacklogImagePaths(text);
|
||||||
|
expect(disk, contains(''));
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
119
test/ui_images/capture_images_test.dart
Normal file
119
test/ui_images/capture_images_test.dart
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:todo/data/app_settings.dart';
|
||||||
|
import 'package:todo/data/note.dart';
|
||||||
|
import 'package:todo/ui/image_input.dart';
|
||||||
|
import 'package:todo/ui/images/image_strip.dart';
|
||||||
|
|
||||||
|
import '../capture_screen_harness.dart';
|
||||||
|
import '../fake_image_store.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('📎 on an empty draft creates the note under the image id', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final store = FakeImageStore();
|
||||||
|
final repo = await pumpCapture(
|
||||||
|
tester,
|
||||||
|
imageStore: store,
|
||||||
|
imagePicking: (_) async => [
|
||||||
|
Uint8List.fromList([1]),
|
||||||
|
],
|
||||||
|
appSettings: ValueNotifier(const AppSettings(advancedMode: false)),
|
||||||
|
);
|
||||||
|
await tester.tap(find.byTooltip('Attach image'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
final ref = store.attached.keys.single;
|
||||||
|
final notes = await repo.listNotes();
|
||||||
|
expect(notes.single.id, ref.noteId);
|
||||||
|
expect(notes.single.text, contains(ref.markdown));
|
||||||
|
expect(find.byType(ImageStrip), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('📎 on a started draft files under that note', (tester) async {
|
||||||
|
final store = FakeImageStore();
|
||||||
|
final repo = await pumpCapture(
|
||||||
|
tester,
|
||||||
|
imageStore: store,
|
||||||
|
imagePicking: (_) async => [
|
||||||
|
Uint8List.fromList([1]),
|
||||||
|
],
|
||||||
|
appSettings: ValueNotifier(const AppSettings(advancedMode: false)),
|
||||||
|
);
|
||||||
|
await tester.enterText(find.byType(TextField), 'idea');
|
||||||
|
await tester.pump();
|
||||||
|
final id = (await repo.listNotes()).single.id;
|
||||||
|
await tester.tap(find.byTooltip('Attach image'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
expect(store.attached.keys.single.noteId, id);
|
||||||
|
expect((await repo.listNotes()).single.text, startsWith('idea\n);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('launch and resume reconcile with every note', (tester) async {
|
||||||
|
final store = FakeImageStore();
|
||||||
|
await pumpCapture(
|
||||||
|
tester,
|
||||||
|
imageStore: store,
|
||||||
|
seed: [
|
||||||
|
Note(
|
||||||
|
id: 'a',
|
||||||
|
text: 'one',
|
||||||
|
priority: Priority.medium,
|
||||||
|
status: Status.done,
|
||||||
|
createdAt: DateTime(2026),
|
||||||
|
updatedAt: DateTime(2026),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(store.reconciles, isNotEmpty);
|
||||||
|
expect(store.reconciles.last, ['one']);
|
||||||
|
final before = store.reconciles.length;
|
||||||
|
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
|
||||||
|
await tester.pump();
|
||||||
|
expect(store.reconciles.length, before + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a manual sync reconciles images after it', (tester) async {
|
||||||
|
final store = FakeImageStore();
|
||||||
|
await pumpCapture(
|
||||||
|
tester,
|
||||||
|
imageStore: store,
|
||||||
|
prefs: configuredPrefs,
|
||||||
|
httpClient: recordingMock([]),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
final before = store.reconciles.length;
|
||||||
|
await tester.tap(find.byTooltip('Sync'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(seconds: 1));
|
||||||
|
expect(store.reconciles.length, greaterThan(before));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a paste or drop attaches to the draft', (tester) async {
|
||||||
|
final store = FakeImageStore();
|
||||||
|
final repo = await pumpCapture(
|
||||||
|
tester,
|
||||||
|
imageStore: store,
|
||||||
|
appSettings: ValueNotifier(const AppSettings(advancedMode: false)),
|
||||||
|
);
|
||||||
|
tester.widget<ImageInputListener>(find.byType(ImageInputListener)).onImages(
|
||||||
|
[
|
||||||
|
Uint8List.fromList([1]),
|
||||||
|
],
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
expect(store.attached, hasLength(1));
|
||||||
|
expect(
|
||||||
|
(await repo.listNotes()).single.id,
|
||||||
|
store.attached.keys.single.noteId,
|
||||||
|
);
|
||||||
|
expect(find.text('Not an image'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
130
test/ui_images/cloud_images_settings_test.dart
Normal file
130
test/ui_images/cloud_images_settings_test.dart
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:todo/attachments/image_store_api.dart';
|
||||||
|
import 'package:todo/attachments/image_store_scope.dart';
|
||||||
|
import 'package:todo/ui/cloud_images_settings.dart';
|
||||||
|
|
||||||
|
import '../fake_image_store.dart';
|
||||||
|
import '../fake_secure_storage.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
Future<void> pump(
|
||||||
|
WidgetTester tester, {
|
||||||
|
ImageStore? store,
|
||||||
|
http.Client? client,
|
||||||
|
bool field = true,
|
||||||
|
}) async {
|
||||||
|
final app = MaterialApp(
|
||||||
|
home: CloudImagesScreen(httpClient: client, showPasswordField: field),
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
store == null ? app : ImageStoreScope(store: store, child: app),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
MockClient answer(int code) =>
|
||||||
|
MockClient((_) async => http.Response('', code));
|
||||||
|
|
||||||
|
Future<void> saveWith(WidgetTester tester, String password) async {
|
||||||
|
await tester.enterText(find.byType(TextField), password);
|
||||||
|
await tester.tap(find.text('Save & test'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('the settings tile opens the screen', (tester) async {
|
||||||
|
installFakeSecureStorage();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(home: Scaffold(body: CloudImagesTile())),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Cloud images'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(CloudImagesScreen), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('loads the saved password and the store status', (tester) async {
|
||||||
|
installFakeSecureStorage(initial: {'dufsImagesPassword': 'saved'});
|
||||||
|
await pump(tester, store: FakeImageStore());
|
||||||
|
expect(
|
||||||
|
tester.widget<TextField>(find.byType(TextField)).controller!.text,
|
||||||
|
'saved',
|
||||||
|
);
|
||||||
|
expect(find.text('Login: configured'), findsOneWidget);
|
||||||
|
expect(find.text('Waiting to upload: 0'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an accepted password is saved and the queue uploads', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
installFakeSecureStorage();
|
||||||
|
final store = FakeImageStore();
|
||||||
|
await pump(tester, store: store, client: answer(207));
|
||||||
|
await saveWith(tester, ' pw ');
|
||||||
|
expect(find.text('Saved — dufs accepted the login'), findsOneWidget);
|
||||||
|
expect(store.reconciles.single, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a rejected password says so and uploads nothing', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
installFakeSecureStorage();
|
||||||
|
final store = FakeImageStore();
|
||||||
|
await pump(tester, store: store, client: answer(401));
|
||||||
|
await saveWith(tester, 'bad');
|
||||||
|
expect(find.text('Saved, but dufs rejected the password'), findsOneWidget);
|
||||||
|
expect(store.reconciles, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an unreachable dufs is reported', (tester) async {
|
||||||
|
installFakeSecureStorage();
|
||||||
|
await pump(
|
||||||
|
tester,
|
||||||
|
store: FakeImageStore(),
|
||||||
|
client: MockClient((_) => throw const SocketException('down')),
|
||||||
|
);
|
||||||
|
await saveWith(tester, 'pw');
|
||||||
|
expect(find.text('Saved — dufs is unreachable right now'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('no keystore means nothing is saved', (tester) async {
|
||||||
|
installFakeSecureStorage(throwing: true);
|
||||||
|
await pump(tester, store: FakeImageStore(), client: answer(207));
|
||||||
|
await saveWith(tester, 'pw');
|
||||||
|
expect(
|
||||||
|
find.text('Could not save: the keystore is unavailable'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the desktop shows status only, with the setup command', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pump(tester, field: false);
|
||||||
|
expect(find.byType(TextField), findsNothing);
|
||||||
|
expect(
|
||||||
|
find.text('Login: missing — create it on the PC with'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.text(kAddLoginCommand), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('closing the screen mid-test is harmless', (tester) async {
|
||||||
|
installFakeSecureStorage();
|
||||||
|
final reply = Completer<http.Response>();
|
||||||
|
final store = FakeImageStore();
|
||||||
|
await pump(tester, store: store, client: MockClient((_) => reply.future));
|
||||||
|
await tester.enterText(find.byType(TextField), 'pw');
|
||||||
|
await tester.tap(find.text('Save & test'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
reply.complete(http.Response('', 207));
|
||||||
|
await tester.pump();
|
||||||
|
expect(store.reconciles, isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
171
test/ui_images/detail_and_misc_images_test.dart
Normal file
171
test/ui_images/detail_and_misc_images_test.dart
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:todo/attachments/image_store_scope.dart';
|
||||||
|
import 'package:todo/data/app_settings.dart';
|
||||||
|
import 'package:todo/data/note.dart';
|
||||||
|
import 'package:todo/data/note_template_parser.dart';
|
||||||
|
import 'package:todo/images/image_ref.dart';
|
||||||
|
import 'package:todo/sync/desktop_wrapper.dart';
|
||||||
|
import 'package:todo/sync/notes_markdown.dart';
|
||||||
|
import 'package:todo/ui/capture_draft.dart';
|
||||||
|
import 'package:todo/ui/image_input.dart';
|
||||||
|
import 'package:todo/ui/image_input_files.dart';
|
||||||
|
import 'package:todo/ui/images/image_embed.dart';
|
||||||
|
import 'package:todo/ui/images/image_strip.dart';
|
||||||
|
import 'package:todo/ui/markdown_view.dart';
|
||||||
|
import 'package:todo/ui/note_detail_screen.dart';
|
||||||
|
|
||||||
|
import '../fake_image_store.dart';
|
||||||
|
import '../fake_note_repository.dart';
|
||||||
|
|
||||||
|
Note note(String text) => Note(
|
||||||
|
id: 'n1',
|
||||||
|
text: text,
|
||||||
|
priority: Priority.medium,
|
||||||
|
status: Status.todo,
|
||||||
|
createdAt: DateTime(2026, 9, 25),
|
||||||
|
updatedAt: DateTime(2026, 9, 25),
|
||||||
|
);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final ref = ImageRef.create('n1');
|
||||||
|
|
||||||
|
testWidgets('detail: 📎 inserts at the cursor and persists', (tester) async {
|
||||||
|
tester.view.physicalSize = const Size(1200, 2400);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
final store = FakeImageStore();
|
||||||
|
final repo = FakeNoteRepository([note('first\nsecond')]);
|
||||||
|
addTearDown(repo.close);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ImageStoreScope(
|
||||||
|
store: store,
|
||||||
|
child: MaterialApp(
|
||||||
|
home: NoteDetailScreen(
|
||||||
|
note: note('first\nsecond'),
|
||||||
|
repository: repo,
|
||||||
|
appSettings: ValueNotifier(const AppSettings(advancedMode: true)),
|
||||||
|
imagePicking: (_) async => [
|
||||||
|
Uint8List.fromList([1]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
tester.widget<TextField>(find.byType(TextField)).controller!.selection =
|
||||||
|
const TextSelection.collapsed(offset: 5);
|
||||||
|
await tester.tap(find.byTooltip('Attach image'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
final added = store.attached.keys.single;
|
||||||
|
expect(added.noteId, 'n1');
|
||||||
|
expect(
|
||||||
|
(await repo.listNotes()).single.text,
|
||||||
|
'first\n${added.markdown}\nsecond',
|
||||||
|
);
|
||||||
|
expect(find.byType(ImageStrip), findsOneWidget);
|
||||||
|
// A paste or drop lands in the same note.
|
||||||
|
tester.widget<ImageInputListener>(find.byType(ImageInputListener)).onImages(
|
||||||
|
[
|
||||||
|
Uint8List.fromList([2]),
|
||||||
|
],
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
expect(store.attached, hasLength(2));
|
||||||
|
expect(store.attached.keys.last.noteId, 'n1');
|
||||||
|
// View mode renders inline and hides the strip.
|
||||||
|
await tester.tap(find.text('View'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(ImageStrip), findsNothing);
|
||||||
|
expect(find.byType(ImageEmbed), findsNWidgets(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('markdown view draws an image line as an embed', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(body: MarkdownView(text: 'text\n${ref.markdown}\n')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.byType(ImageEmbed), findsOneWidget);
|
||||||
|
expect(find.text('text'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the io input listener is a pass-through', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
Directionality(
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
child: ImageInputListener(onImages: (_, _) {}, child: const Text('x')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.text('x'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('partitionImageFiles keeps order and counts the rest', () {
|
||||||
|
final split = partitionImageFiles([
|
||||||
|
'image/png',
|
||||||
|
'application/pdf',
|
||||||
|
'IMAGE/JPEG',
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
expect(split.images, [0, 2]);
|
||||||
|
expect(split.rejected, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('noteTitle skips image lines', () {
|
||||||
|
expect(noteTitle('${ref.markdown}\n# Real title'), 'Real title');
|
||||||
|
expect(noteTitle(ref.markdown), 'Image');
|
||||||
|
expect(noteTitle('${ref.markdown}\n\n${ref.markdown}'), '2 images');
|
||||||
|
expect(noteTitle(''), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the backlog export uses disk paths and parses back', () {
|
||||||
|
final exported = NotesMarkdown.export([note('t\n${ref.markdown}')]);
|
||||||
|
expect(exported, contains(''));
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
148
test/ui_images/image_attach_test.dart
Normal file
148
test/ui_images/image_attach_test.dart
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:todo/attachments/image_store_api.dart';
|
||||||
|
import 'package:todo/attachments/image_store_scope.dart';
|
||||||
|
import 'package:todo/ui/image_attach.dart';
|
||||||
|
|
||||||
|
import '../fake_image_store.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('the controller inserts only while an editor is bound', () {
|
||||||
|
final controller = ImageAttachController();
|
||||||
|
final got = <List<String>>[];
|
||||||
|
expect(controller.insert(['a']), isFalse);
|
||||||
|
final unbind = controller.bind(got.add);
|
||||||
|
expect(controller.insert([]), isFalse);
|
||||||
|
expect(controller.insert(['a']), isTrue);
|
||||||
|
// A later editor's bind wins; the earlier unbind no longer detaches it.
|
||||||
|
final unbindSecond = controller.bind(got.add);
|
||||||
|
unbind();
|
||||||
|
expect(controller.insert(['b']), isTrue);
|
||||||
|
unbindSecond();
|
||||||
|
expect(controller.insert(['c']), isFalse);
|
||||||
|
expect(got, [
|
||||||
|
['a'],
|
||||||
|
['b'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<BuildContext> pumpScope(WidgetTester tester, ImageStore store) async {
|
||||||
|
late BuildContext context;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ImageStoreScope(
|
||||||
|
store: store,
|
||||||
|
child: MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Builder(
|
||||||
|
builder: (c) {
|
||||||
|
context = c;
|
||||||
|
return const SizedBox();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('attaches in order and inserts every link', (tester) async {
|
||||||
|
final store = FakeImageStore();
|
||||||
|
final context = await pumpScope(tester, store);
|
||||||
|
final controller = ImageAttachController();
|
||||||
|
final inserted = <List<String>>[];
|
||||||
|
controller.bind(inserted.add);
|
||||||
|
var asked = 0;
|
||||||
|
await attachImages(
|
||||||
|
context,
|
||||||
|
noteId: () {
|
||||||
|
asked++;
|
||||||
|
return 'n1';
|
||||||
|
},
|
||||||
|
target: controller,
|
||||||
|
images: [
|
||||||
|
Uint8List.fromList([1]),
|
||||||
|
Uint8List.fromList([2]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(asked, 2);
|
||||||
|
expect(store.attached.values.map((b) => b.first), [1, 2]);
|
||||||
|
expect(inserted.single, [
|
||||||
|
for (final ref in store.attached.keys) ref.markdown,
|
||||||
|
]);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(SnackBar), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('reports one non-image', (tester) async {
|
||||||
|
final store = FakeImageStore();
|
||||||
|
final context = await pumpScope(tester, store);
|
||||||
|
await attachImages(
|
||||||
|
context,
|
||||||
|
noteId: () => 'n1',
|
||||||
|
target: ImageAttachController(),
|
||||||
|
images: [
|
||||||
|
Uint8List.fromList([0]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Not an image'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('counts dropped non-images together', (tester) async {
|
||||||
|
final store = FakeImageStore();
|
||||||
|
final context = await pumpScope(tester, store);
|
||||||
|
await attachImages(
|
||||||
|
context,
|
||||||
|
noteId: () => 'n1',
|
||||||
|
target: ImageAttachController(),
|
||||||
|
images: [
|
||||||
|
Uint8List.fromList([0]),
|
||||||
|
],
|
||||||
|
rejected: 2,
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('3 files were not images'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a store failure is shown and stops the batch', (tester) async {
|
||||||
|
final store = FakeImageStore()
|
||||||
|
..failure = const ImageStoreException('helper down');
|
||||||
|
final context = await pumpScope(tester, store);
|
||||||
|
await attachImages(
|
||||||
|
context,
|
||||||
|
noteId: () => 'n1',
|
||||||
|
target: ImageAttachController(),
|
||||||
|
images: [
|
||||||
|
Uint8List.fromList([1]),
|
||||||
|
Uint8List.fromList([2]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('helper down'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('nothing is shown once the screen closed mid-attach', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final gate = Completer<void>();
|
||||||
|
final store = FakeImageStore()..gate = gate.future;
|
||||||
|
final context = await pumpScope(tester, store);
|
||||||
|
final pending = attachImages(
|
||||||
|
context,
|
||||||
|
noteId: () => 'n1',
|
||||||
|
target: ImageAttachController(),
|
||||||
|
images: [
|
||||||
|
Uint8List.fromList([0]),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
gate.complete();
|
||||||
|
await pending;
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.byType(SnackBar), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
97
test/ui_images/image_embed_test.dart
Normal file
97
test/ui_images/image_embed_test.dart
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
|
import 'package:todo/attachments/image_store_scope.dart';
|
||||||
|
import 'package:todo/images/image_ref.dart';
|
||||||
|
import 'package:todo/ui/images/image_embed.dart';
|
||||||
|
import 'package:todo/ui/images/image_strip.dart';
|
||||||
|
import 'package:todo/ui/images/image_viewer_screen.dart';
|
||||||
|
|
||||||
|
import '../fake_image_store.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final ref = ImageRef.create('n1');
|
||||||
|
late FakeImageStore store;
|
||||||
|
late Directory dir;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
store = FakeImageStore();
|
||||||
|
dir = Directory.systemTemp.createTempSync('embed');
|
||||||
|
final file = File('${dir.path}/a.jpg')
|
||||||
|
..writeAsBytesSync(img.encodeJpg(img.Image(width: 4, height: 4)));
|
||||||
|
store.localPath = file.path;
|
||||||
|
});
|
||||||
|
tearDown(() => dir.deleteSync(recursive: true));
|
||||||
|
|
||||||
|
Future<void> pump(WidgetTester tester, Widget child) => tester.pumpWidget(
|
||||||
|
ImageStoreScope(
|
||||||
|
store: store,
|
||||||
|
child: MaterialApp(
|
||||||
|
home: Scaffold(body: SizedBox(width: 200, height: 200, child: child)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets('a local image renders and opens full screen', (tester) async {
|
||||||
|
await pump(tester, ImageEmbed(ref: ref));
|
||||||
|
expect(find.byType(Image), findsOneWidget);
|
||||||
|
expect(find.byTooltip(kImageWaitingToUpload), findsNothing);
|
||||||
|
await tester.tap(find.byType(ImageEmbed));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(ImageViewerScreen), findsOneWidget);
|
||||||
|
expect(find.byType(InteractiveViewer), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a queued image carries the upload badge', (tester) async {
|
||||||
|
store.pending.add(ref);
|
||||||
|
await pump(tester, ImageEmbed(ref: ref));
|
||||||
|
expect(find.byTooltip(kImageWaitingToUpload), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a missing image is a placeholder and not tappable', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
store.missing.add(ref);
|
||||||
|
await pump(tester, ImageEmbed(ref: ref));
|
||||||
|
expect(find.text(kImageNotDownloaded), findsOneWidget);
|
||||||
|
await tester.tap(find.byType(ImageEmbed));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(ImageViewerScreen), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a broken file or url falls back to the placeholder', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
store.localPath = '${dir.path}/gone.jpg';
|
||||||
|
await pump(tester, ImageEmbed(ref: ref));
|
||||||
|
await tester.runAsync(
|
||||||
|
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text(kImageNotDownloaded), findsOneWidget);
|
||||||
|
store.network = true;
|
||||||
|
store.changed();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.runAsync(
|
||||||
|
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text(kImageNotDownloaded), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the strip shows one thumbnail per linked image', (tester) async {
|
||||||
|
final other = ImageRef.create('n2');
|
||||||
|
await pump(
|
||||||
|
tester,
|
||||||
|
ImageStrip(text: 'x\n${ref.markdown}\n${other.markdown}'),
|
||||||
|
);
|
||||||
|
expect(find.byType(ImageEmbed), findsNWidgets(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the strip is empty without images', (tester) async {
|
||||||
|
await pump(tester, const ImageStrip(text: 'no images'));
|
||||||
|
expect(find.byType(ImageEmbed), findsNothing);
|
||||||
|
});
|
||||||
|
}
|
||||||
85
test/ui_images/image_picking_test.dart
Normal file
85
test/ui_images/image_picking_test.dart
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:todo/ui/image_picking.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('the button passes picked images on, ignores a cancel', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final got = <List<Uint8List>>[];
|
||||||
|
var result = <Uint8List>[];
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: AttachImageButton(
|
||||||
|
onImages: got.add,
|
||||||
|
picking: (_) async => result,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.byTooltip('Attach image'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(got, isEmpty);
|
||||||
|
result = [
|
||||||
|
Uint8List.fromList([1]),
|
||||||
|
];
|
||||||
|
await tester.tap(find.byTooltip('Attach image'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(got.single.single, [1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<List<Uint8List>?> openSheet(
|
||||||
|
WidgetTester tester,
|
||||||
|
List<ImagePickSource> asked,
|
||||||
|
) async {
|
||||||
|
List<Uint8List>? result;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Builder(
|
||||||
|
builder: (context) => TextButton(
|
||||||
|
onPressed: () async => result = await pickImages(
|
||||||
|
context,
|
||||||
|
fromPhone: (source) async {
|
||||||
|
asked.add(source);
|
||||||
|
return [
|
||||||
|
Uint8List.fromList([source.index]),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
),
|
||||||
|
child: const Text('pick'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('pick'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('the phone sheet offers Camera and Gallery', (tester) async {
|
||||||
|
final asked = <ImagePickSource>[];
|
||||||
|
await openSheet(tester, asked);
|
||||||
|
expect(find.text('Camera'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Gallery'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(asked, [ImagePickSource.gallery]);
|
||||||
|
await tester.tap(find.text('pick'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('Camera'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(asked, [ImagePickSource.gallery, ImagePickSource.camera]);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('dismissing the sheet picks nothing', (tester) async {
|
||||||
|
final asked = <ImagePickSource>[];
|
||||||
|
await openSheet(tester, asked);
|
||||||
|
await tester.tapAt(const Offset(10, 10));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(asked, isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
164
test/ui_images/note_editor_images_test.dart
Normal file
164
test/ui_images/note_editor_images_test.dart
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:todo/data/note.dart';
|
||||||
|
import 'package:todo/data/note_template.dart';
|
||||||
|
import 'package:todo/data/note_template_parser.dart';
|
||||||
|
import 'package:todo/ui/image_attach.dart';
|
||||||
|
import 'package:todo/ui/note_editor.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late ImageAttachController attach;
|
||||||
|
late List<String> emitted;
|
||||||
|
|
||||||
|
Future<void> pump(
|
||||||
|
WidgetTester tester, {
|
||||||
|
String text = '',
|
||||||
|
NoteTemplate? template,
|
||||||
|
NoteEditorMode mode = NoteEditorMode.raw,
|
||||||
|
ValueChanged<NoteEditorMode>? onModeChanged,
|
||||||
|
Key? key,
|
||||||
|
}) async {
|
||||||
|
attach = ImageAttachController();
|
||||||
|
emitted = [];
|
||||||
|
tester.view.physicalSize = const Size(1200, 2400);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: NoteEditor(
|
||||||
|
key: key,
|
||||||
|
initialText: text,
|
||||||
|
initialTemplate: template,
|
||||||
|
initialMode: mode,
|
||||||
|
priority: Priority.medium,
|
||||||
|
onPriorityChanged: (_) {},
|
||||||
|
onChromeVisibleChanged: (_) {},
|
||||||
|
onChanged: emitted.add,
|
||||||
|
attachController: attach,
|
||||||
|
onModeChanged: onModeChanged,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
TextEditingController field(WidgetTester tester) =>
|
||||||
|
tester.widget<TextField>(find.byType(TextField)).controller!;
|
||||||
|
|
||||||
|
testWidgets('raw: inserts on its own line at the cursor', (tester) async {
|
||||||
|
await pump(tester, text: 'line1\nline2', template: NoteTemplate.blank);
|
||||||
|
field(tester).selection = const TextSelection.collapsed(offset: 5);
|
||||||
|
attach.insert(['IMG1', 'IMG2']);
|
||||||
|
await tester.pump();
|
||||||
|
expect(field(tester).text, 'line1\nIMG1\nIMG2\nline2');
|
||||||
|
expect(field(tester).selection.baseOffset, 'line1\nIMG1\nIMG2'.length);
|
||||||
|
expect(emitted.last, 'line1\nIMG1\nIMG2\nline2');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('raw: a selection is replaced; no cursor appends', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await pump(tester, text: 'abc', template: NoteTemplate.blank);
|
||||||
|
field(tester).selection = const TextSelection(
|
||||||
|
baseOffset: 1,
|
||||||
|
extentOffset: 2,
|
||||||
|
);
|
||||||
|
attach.insert(['I']);
|
||||||
|
await tester.pump();
|
||||||
|
expect(field(tester).text, 'a\nI\nc');
|
||||||
|
field(tester).selection = const TextSelection.collapsed(offset: -1);
|
||||||
|
attach.insert(['J']);
|
||||||
|
await tester.pump();
|
||||||
|
expect(field(tester).text, 'a\nI\nc\nJ\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('raw: an empty note gets just the link', (tester) async {
|
||||||
|
await pump(tester, template: NoteTemplate.blank);
|
||||||
|
attach.insert(['I']);
|
||||||
|
await tester.pump();
|
||||||
|
expect(field(tester).text, 'I\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('guided: goes into the current step', (tester) async {
|
||||||
|
await pump(
|
||||||
|
tester,
|
||||||
|
template: NoteTemplate.llmDesignSpec,
|
||||||
|
mode: NoteEditorMode.guided,
|
||||||
|
);
|
||||||
|
expect(find.text('Exit guided'), findsNothing);
|
||||||
|
attach.insert(['IMG']);
|
||||||
|
await tester.pump();
|
||||||
|
expect(field(tester).text, 'IMG\n');
|
||||||
|
expect(emitted.last, contains('IMG'));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('view over a raw note appends to the end', (tester) async {
|
||||||
|
final modes = <NoteEditorMode>[];
|
||||||
|
await pump(
|
||||||
|
tester,
|
||||||
|
text: 'freeform text',
|
||||||
|
template: NoteTemplate.blank,
|
||||||
|
onModeChanged: modes.add,
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('View'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(modes, [NoteEditorMode.preview]);
|
||||||
|
attach.insert(['IMG']);
|
||||||
|
await tester.pump();
|
||||||
|
expect(emitted.last, 'freeform text\nIMG\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('view over guided sections appends to the last section', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
const spec = NoteTemplate.llmDesignSpec;
|
||||||
|
final text = assemble(spec, {
|
||||||
|
for (final s in spec.sections) s.key: s.isTitle ? 'Title' : 'v ${s.key}',
|
||||||
|
});
|
||||||
|
await pump(
|
||||||
|
tester,
|
||||||
|
text: text,
|
||||||
|
template: spec,
|
||||||
|
mode: NoteEditorMode.preview,
|
||||||
|
);
|
||||||
|
attach.insert(['IMG']);
|
||||||
|
await tester.pump();
|
||||||
|
final last = spec.sections.last;
|
||||||
|
final tail = emitted.last.substring(
|
||||||
|
emitted.last.indexOf('## ${last.label}'),
|
||||||
|
);
|
||||||
|
expect(tail, contains('v ${last.key}\nIMG'));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a new controller on the same editor is rebound', (tester) async {
|
||||||
|
await pump(tester, text: 'a', template: NoteTemplate.blank);
|
||||||
|
final first = attach;
|
||||||
|
await pump(tester, text: 'a', template: NoteTemplate.blank);
|
||||||
|
expect(first.insert(['x']), isFalse);
|
||||||
|
expect(attach.insert(['y']), isTrue);
|
||||||
|
// Same controller again: nothing to rebind.
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: NoteEditor(
|
||||||
|
priority: Priority.medium,
|
||||||
|
onPriorityChanged: (_) {},
|
||||||
|
onChromeVisibleChanged: (_) {},
|
||||||
|
onChanged: (_) {},
|
||||||
|
attachController: attach,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(attach.insert(['z']), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('disposing the editor unbinds it', (tester) async {
|
||||||
|
await pump(tester, template: NoteTemplate.blank);
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
expect(attach.insert(['x']), isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -12,7 +12,9 @@ import 'package:crdt_sync/crdt_sync.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:path/path.dart' as p;
|
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/desktop/wrapper_server.dart';
|
||||||
|
import 'package:todo/images/image_cache_store.dart';
|
||||||
|
|
||||||
/// One test's temp web root and the [WrapperServer] serving it.
|
/// One test's temp web root and the [WrapperServer] serving it.
|
||||||
///
|
///
|
||||||
@ -45,6 +47,7 @@ class WrapperHarness {
|
|||||||
webRoot: webRoot.path,
|
webRoot: webRoot.path,
|
||||||
backlogPath: p.join(root.path, 'todo', 'BACKLOG.md'),
|
backlogPath: p.join(root.path, 'todo', 'BACKLOG.md'),
|
||||||
logPath: p.join(root.path, 'state', 'todo_notes.json'),
|
logPath: p.join(root.path, 'state', 'todo_notes.json'),
|
||||||
|
images: images(),
|
||||||
// Without this, the default falls back to the real
|
// Without this, the default falls back to the real
|
||||||
// $HOME/.config/todo/firebase_auth.json -- on a machine that has
|
// $HOME/.config/todo/firebase_auth.json -- on a machine that has
|
||||||
// actually signed in (a real desktop session), that file exists, so
|
// actually signed in (a real desktop session), that file exists, so
|
||||||
@ -67,6 +70,14 @@ class WrapperHarness {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Image routes rooted in this test's temp dir. Without them the server
|
||||||
|
/// defaults to the real `~/.local/share/todo-desktop/images` and the real
|
||||||
|
/// dufs login in `~/.config/dufs/logins/todo.env`.
|
||||||
|
WrapperImageRoutes images() => WrapperImageRoutes(
|
||||||
|
store: ImageCacheStore(Directory(p.join(root.path, 'images'))),
|
||||||
|
loginEnvPath: p.join(root.path, 'dufs', 'todo.env'),
|
||||||
|
);
|
||||||
|
|
||||||
Future<String> enabledOrigin(
|
Future<String> enabledOrigin(
|
||||||
Map<String, String> files, {
|
Map<String, String> files, {
|
||||||
String? credentialsJson,
|
String? credentialsJson,
|
||||||
@ -87,6 +98,7 @@ class WrapperHarness {
|
|||||||
webRoot: p.join(root.path, 'web'),
|
webRoot: p.join(root.path, 'web'),
|
||||||
backlogPath: p.join(root.path, 'todo', 'BACKLOG.md'),
|
backlogPath: p.join(root.path, 'todo', 'BACKLOG.md'),
|
||||||
logPath: p.join(root.path, 'state', 'todo_notes.json'),
|
logPath: p.join(root.path, 'state', 'todo_notes.json'),
|
||||||
|
images: images(),
|
||||||
serveSyncAccount: true,
|
serveSyncAccount: true,
|
||||||
syncConfigDir: configDir.path,
|
syncConfigDir: configDir.path,
|
||||||
todoCredentialsPath:
|
todoCredentialsPath:
|
||||||
|
|||||||
32
tool/image_e2e_wrapper.dart
Normal file
32
tool/image_e2e_wrapper.dart
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
// A throwaway desktop wrapper for end-to-end runs of the web build, on a
|
||||||
|
// port and HOME of its own, so a test page can never write the real
|
||||||
|
// BACKLOG.md, note-log copy or image cache (the page talks only to the
|
||||||
|
// wrapper that served it; see servingWrapperOrigin).
|
||||||
|
//
|
||||||
|
// HOME=$(mktemp -d) dart run tool/image_e2e_wrapper.dart \
|
||||||
|
// --port 8731 --web-root build/web
|
||||||
|
//
|
||||||
|
// Images use $HOME/.local/share/todo-desktop/images and the login in
|
||||||
|
// $HOME/.config/dufs/logins/todo.env, exactly like the real wrapper.
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:todo/desktop/wrapper_server.dart';
|
||||||
|
|
||||||
|
Future<void> main(List<String> args) async {
|
||||||
|
String arg(String name, String fallback) {
|
||||||
|
final i = args.indexOf(name);
|
||||||
|
return i >= 0 && i + 1 < args.length ? args[i + 1] : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
final home = Platform.environment['HOME']!;
|
||||||
|
final port = int.parse(arg('--port', '8731'));
|
||||||
|
final server = WrapperServer(
|
||||||
|
webRoot: p.absolute(arg('--web-root', 'build/web')),
|
||||||
|
// The real wrapper's layout, under the throwaway HOME.
|
||||||
|
backlogPath: defaultBacklogPath(home),
|
||||||
|
logPath: p.join(home, '.local', 'share', 'todo-desktop', 'todo_notes.json'),
|
||||||
|
);
|
||||||
|
await server.start(port);
|
||||||
|
stdout.writeln('e2e wrapper on http://localhost:$port (HOME=$home)');
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user