todo-app/lib/ui/note_detail_screen.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

138 lines
4.4 KiB
Dart

import 'dart:async';
import 'dart:typed_data';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:todo/data/app_settings.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/data/note_template.dart';
import 'package:todo/ui/attachment_picking.dart';
import 'package:todo/ui/image_attach.dart';
import 'package:todo/ui/image_input.dart';
import 'package:todo/ui/note_form.dart';
/// Full-screen view of a single note: read it in full, edit its body through
/// the shared [NoteForm], change its priority/status, or delete it.
///
/// Edits persist immediately (matching the capture screen's autosave), so
/// there is no explicit save button. The template is detected from the note's
/// text, falling back to a raw editor for freeform/legacy notes.
class NoteDetailScreen extends StatefulWidget {
/// Creates a [NoteDetailScreen] editing [note].
const NoteDetailScreen({
required this.note,
required this.repository,
required this.appSettings,
this.attachmentPicking,
super.key,
});
/// The note being viewed/edited.
final Note note;
/// The store edits persist to.
final NoteRepository repository;
/// Drives whether the metadata row and editor chrome are shown. Required,
/// not defaulted: this screen silently defaulting it to "advanced" is
/// exactly what made editing look different from capture.
final ValueNotifier<AppSettings> appSettings;
/// The 📎 picker; null uses the platform one. Injected by tests.
final AttachmentPicking? attachmentPicking;
@override
State<NoteDetailScreen> createState() => _NoteDetailScreenState();
}
class _NoteDetailScreenState extends State<NoteDetailScreen> {
late Note _note = widget.note;
/// Inserts attached-image links into the editor.
final ImageAttachController _attach = ImageAttachController();
Future<void> _attachImages(List<Uint8List> images, {int rejected = 0}) =>
attachImages(
context,
noteId: () => _note.id,
target: _attach,
images: images,
rejected: rejected,
);
Future<void> _attachFiles(List<XFile> files) => attachFiles(
context,
noteId: () => _note.id,
target: _attach,
files: files,
);
Future<void> _persist(Note next) async {
setState(() => _note = next);
await widget.repository.upsert(next);
}
Future<void> _onTextChanged(String text) =>
_persist(_note.copyWith(text: text, updatedAt: DateTime.now()));
Future<void> _delete() async {
final confirmed = await confirmDestructive(
context,
title: 'Delete note?',
message: 'This cannot be undone.',
);
if (!confirmed) return;
await widget.repository.delete(_note.id);
if (mounted) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final title = noteTitle(_note.text);
return Scaffold(
appBar: AppBar(
title: Text(title.isEmpty ? '(empty)' : title),
actions: [
AttachButton(
onFiles: _attachFiles,
picking: widget.attachmentPicking,
),
IconButton(
tooltip: 'Delete note',
icon: const Icon(Icons.delete_outline),
onPressed: _delete,
),
],
),
body: ImageInputListener(
onImages: (images, rejected) =>
unawaited(_attachImages(images, rejected: rejected)),
onFiles: (files) => unawaited(_attachFiles(files)),
child: Padding(
padding: const EdgeInsets.all(16),
// The same form capture renders, gated on the same setting, so an
// edited note and a fresh one cannot look different.
child: ValueListenableBuilder<AppSettings>(
valueListenable: widget.appSettings,
builder: (context, settings, _) => NoteForm(
advancedMode: settings.advancedMode,
initialText: _note.text,
priority: _note.priority,
status: _note.status,
onPriorityChanged: (p) => _persist(
_note.copyWith(priority: p, updatedAt: DateTime.now()),
),
onStatusChanged: (s) => _persist(
_note.copyWith(status: s, updatedAt: DateTime.now()),
),
onChanged: _onTextChanged,
attachController: _attach,
),
),
),
),
);
}
}