todo-app/lib/ui/markdown_view.dart
Krzysztof kuhy Rudnicki abd4ba3bd7 Add full note view/editor with templates and Markdown render
Notes were previously only openable via a quick-actions sheet; you
could not read or edit a note in full. Add a shared NoteEditor used by
both the capture and detail screens, plus selectable templates and a
rendered Markdown view.

- note_template.dart: pure assemble/parse layer over a Markdown subset
  (# title, ## sections + italic guidance, dropping empty sections).
  assemble(parse(text)) is idempotent for conforming text; non-conforming
  / legacy / freeform text is reported so the UI falls back to raw,
  untouched. Two templates: llm-design-spec (default) and blank.
- note_editor.dart: View / Guided / Raw modes. Guided is an inline
  Stepper (one step per section with its guidance); View renders the
  note via MarkdownView; Raw is the verbatim text. Guided is offered
  only for structured templates; switching to it is blocked when the
  raw text no longer conforms.
- markdown_view.dart: lean read-only renderer for the note subset,
  wrapped in a SelectionArea for copy-out.
- note_detail_screen.dart: full-screen note; opens in View, edits
  persist immediately, priority/status dropdowns, delete.
- capture_screen / notes_list_screen wired to the new editor and detail
  screen (tap a note opens it; quick actions move to the overflow button).

The editor is a view over plain text, so CRDT storage and Markdown
export/sync are unaffected. 138 tests, 100% line coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 21:59:31 +02:00

106 lines
3.1 KiB
Dart

import 'package:flutter/material.dart';
/// 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 — 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 {
const MarkdownView({required this.text, super.key});
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: 10));
continue;
}
if (trimmed.startsWith('## ')) {
widgets.add(
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 2),
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: 2),
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(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widgets,
),
),
);
}
}