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

141 lines
4.5 KiB
Dart

import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:todo/images/attachment_links.dart';
import 'package:todo/ui/images/file_embed.dart';
import 'package:todo/ui/images/image_embed.dart';
/// Tallest an image is drawn inside a rendered note.
const kInlineImageHeight = 320.0;
/// A lightweight, read-only renderer for the note format.
///
/// The app's notes use a small, known Markdown subset — an `#` title, `##`
/// section headings, italic `_guidance_` lines, `-` bullet lists and plain
/// paragraphs, plus one attachment per line (see `attachment_links.dart`)
/// — so a tailored line-based renderer covers everything we emit without
/// pulling in a full Markdown engine. It keeps the quick-capture app lean and
/// is trivial to test.
///
/// The whole rendered note is wrapped in a [SelectionArea] so it can be copied
/// out in one go (the note is meant to be pasted into an LLM).
class MarkdownView extends StatelessWidget {
/// Creates a [MarkdownView] rendering [text].
const MarkdownView({required this.text, super.key});
/// The raw markdown source to render.
final String text;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final widgets = <Widget>[];
for (final line in text.split('\n')) {
final trimmed = line.trim();
if (trimmed.isEmpty) {
widgets.add(const SizedBox(height: 8));
continue;
}
if (attachmentOfLine(trimmed) case final attachment?) {
widgets.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: attachment.isImage
? ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: kInlineImageHeight,
),
child: ImageEmbed(ref: attachment.ref, fit: BoxFit.contain),
)
: FileEmbed(attachment: attachment),
),
);
continue;
}
if (trimmed.startsWith('## ')) {
widgets.add(
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Text(
trimmed.substring(3).trim(),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
);
continue;
}
if (trimmed.startsWith('# ')) {
widgets.add(
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
trimmed.substring(2).trim(),
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
);
continue;
}
// A whole-line italic marker — used for the section guidance lines.
if (trimmed.length >= 2 &&
trimmed.startsWith('_') &&
trimmed.endsWith('_')) {
widgets.add(
Text(
trimmed.substring(1, trimmed.length - 1),
style: theme.textTheme.bodyMedium?.copyWith(
fontStyle: FontStyle.italic,
color: theme.colorScheme.onSurfaceVariant,
),
),
);
continue;
}
if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
widgets.add(
Padding(
padding: const EdgeInsets.only(left: 4, top: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('• ', style: theme.textTheme.bodyLarge),
Expanded(
child: Text(
trimmed.substring(2),
style: theme.textTheme.bodyLarge,
),
),
],
),
),
);
continue;
}
widgets.add(Text(line, style: theme.textTheme.bodyLarge));
}
return SelectionArea(
child: SingleChildScrollView(
// Caps the reading column at ~70 chars (rule 21) — the desktop
// build is an arbitrarily wide Chrome `--app` window, so without
// this, body text runs the full viewport width.
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kProseMaxWidth),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widgets,
),
),
),
),
);
}
}