todo-app/lib/ui/cloud_images_settings.dart
Krzysztof kuhy Rudnicki 9f2ffdb8a9
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file-length / file length <= 250 lines (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
release-apk / release (APK) (push) Has been cancelled
Attach any file to a note, of any size, not only images
📎 now picks any file (Android: Camera / Gallery / File; desktop: an
unfiltered dialog) and the desktop page accepts any dropped file. Photo and
screenshot formats up to 64 MB are still re-encoded and shown inline;
everything else is stored byte for byte on dufs beside the images and linked
as [name](url), shown as a row with its size that opens in the platform's
app (Android) or a new browser tab (desktop). BACKLOG.md rewrites file links
to ~/data/cloud/todo-images/ like image links.

There is no size cap, so no layer holds a file in memory: uploads and
downloads stream through DufsImageClient, writes land in a .tmp renamed when
whole (an interrupted download no longer leaves a truncated file that looks
done), the desktop page POSTs the browser's own Blob with fetch, and Android's
"File" uses file_picker, because file_selector's Android side reads the whole
file into memory on the UI thread. Verified with a 1 GB file end to end on
desktop (peak RSS +36 MB) and on an API 36 emulator.

The wrapper serves attachments from the origin that holds the GitHub token
and the notes, so only script-free types open inline (nosniff), the rest
download; writes carrying another site's Origin are refused (this also
closes cross-site overwrites of the backup files); and attachment routes run
concurrently so a long upload or a playing video cannot stall the page.

Also: cupertino_icons 2.0.0 (unused, but the freshness gate blocks the
pubspec otherwise), open_file 4.0.0 and file_picker 13.1.0 pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:17:06 +02:00

178 lines
5.7 KiB
Dart

/// 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 and files 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. Other files upload unchanged, at any '
'size.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
}