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

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

234 lines
7.6 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:flutter/services.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';
import 'package:todo/ui/app_route.dart';
import 'package:todo/ui/status_cue.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,
leading: const Icon(Icons.cloud, semanticLabel: 'Cloud images'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.of(context).push<void>(
appRoute(context, (_) => 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;
StatusCue? _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 = const StatusCue(
Icons.no_encryption,
'Could not save: the keystore is unavailable',
error: true,
);
if (saved) {
final client = DufsImageClient(
DufsLogin(user: kDufsImagesUser, password: password),
httpClient: widget.httpClient,
);
final check = await client.check();
result = switch (check) {
DufsCheck.ok => const StatusCue.ok('Saved — dufs accepted the login'),
DufsCheck.rejected => const StatusCue(
Icons.lock,
'Saved, but dufs rejected the password',
error: true,
),
DufsCheck.unreachable => const StatusCue(
Icons.cloud_off,
'Saved — dufs is unreachable right now',
error: true,
),
};
// 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 Icon(Icons.cloud_outlined, semanticLabel: 'Cloud images'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
if (widget.showPasswordField) ...[
TextField(
controller: _password,
obscureText: true,
decoration: const InputDecoration(
prefixIcon: Icon(
Icons.lock_outline,
semanticLabel: 'Password for the "todo" dufs login',
),
),
),
const SizedBox(height: 12),
FilledButton(
onPressed: _checking ? null : _saveAndTest,
child: const Icon(Icons.save, semanticLabel: 'Save & test'),
),
if (_checkResult case final result?) ...[
const SizedBox(height: 8),
StatusCueView(result),
],
const SizedBox(height: 16),
],
if (status != null) ...[
// Shape + colour: a filled check, or a red error ring followed by
// a copy button that puts the fixing command on the clipboard.
Row(
children: [
Icon(
status.configured ? Icons.check_circle : Icons.error_outline,
color: status.configured
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.error,
semanticLabel: status.configured
? 'Login: configured'
: 'Login: missing — create it on the PC with',
),
if (!status.configured)
IconButton(
onPressed: () => unawaited(
Clipboard.setData(
const ClipboardData(text: kAddLoginCommand),
),
),
icon: const Icon(
Icons.content_copy,
semanticLabel: 'Copy: $kAddLoginCommand',
),
),
],
),
const SizedBox(height: 8),
// Icon + the count itself: the number is the information.
Row(
children: [
const Icon(Icons.cloud_upload_outlined),
const SizedBox(width: 8),
Text(
'${status.pending}',
semanticsLabel: 'Waiting to upload: ${status.pending}',
),
],
),
],
const SizedBox(height: 16),
// Shrunk + no location, as glyphs; the sentence is spoken only.
const Row(
children: [
Icon(
Icons.photo_size_select_large,
semanticLabel:
'Images are shrunk to 2560px JPEGs without location data '
'before they leave this device. Other files upload '
'unchanged, at any size.',
),
SizedBox(width: 8),
Icon(Icons.location_off),
],
),
],
),
);
}
}