mirror of
https://github.com/kuhyx/todo-app.git
synced 2026-07-04 13:23:15 +02:00
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>
322 lines
11 KiB
Dart
322 lines
11 KiB
Dart
/// Structured note templates and the pure text <-> sections conversion.
|
||
///
|
||
/// The whole point of a template is that the user fills it, then copies the
|
||
/// note verbatim into an LLM, and the LLM has everything it needs to start
|
||
/// work. So this layer is deliberately *pure* (no Flutter, no IO): the UI is
|
||
/// just a view over [assemble]/[parse], and the canonical storage stays plain
|
||
/// text (CRDT body + markdown export are unchanged).
|
||
///
|
||
/// The assembled format is Markdown: an `#` title, then one `##` section per
|
||
/// *filled* field with a one-line italic guidance under the heading. The
|
||
/// guidance is kept in the stored note on purpose — the note is pasted
|
||
/// verbatim to an LLM, so the description of what each section means must
|
||
/// travel with it.
|
||
///
|
||
/// Design invariants (see the round-trip tests):
|
||
/// * The **template** defines which sections exist; the stored text only
|
||
/// carries values. Parsing an unknown/legacy note never invents sections.
|
||
/// * `assemble(parse(text))` is idempotent for any text that *conforms* to a
|
||
/// template, so opening and closing a structured note never mutates it.
|
||
/// * Text that does **not** conform (freeform notes, the old `what —` format,
|
||
/// hand-mangled raw edits) is reported as such so the UI can fall back to a
|
||
/// raw editor and show it verbatim — we never force non-conforming text
|
||
/// into the guided stepper and never drop a line we couldn't place.
|
||
library;
|
||
|
||
/// One field of a structured template.
|
||
class TemplateSection {
|
||
const TemplateSection({
|
||
required this.key,
|
||
required this.label,
|
||
required this.helper,
|
||
this.hint = '',
|
||
this.inline = true,
|
||
this.isTitle = false,
|
||
});
|
||
|
||
/// Stable identifier for the section (also used as the values-map key).
|
||
final String key;
|
||
|
||
/// The Markdown heading written into the stored text (e.g. `## what`), and
|
||
/// shown in the stepper. The title section has no heading of its own.
|
||
final String label;
|
||
|
||
/// One-line guidance: *what* to write here and *why* the LLM needs it.
|
||
/// Shown in the stepper and embedded as an italic line under the section's
|
||
/// heading in the stored note.
|
||
final String helper;
|
||
|
||
/// Placeholder shown in the empty input.
|
||
final String hint;
|
||
|
||
/// Whether the stepper renders a single-line input (title/what/…) versus a
|
||
/// multi-line input for list-shaped sections (must/nice/out/refs). Purely a
|
||
/// UI hint; the stored format is the same for both.
|
||
final bool inline;
|
||
|
||
/// The title section is special: it has no `##` heading and is stored as the
|
||
/// note's `#` title line.
|
||
final bool isTitle;
|
||
}
|
||
|
||
/// A named template: an ordered list of [sections]. A template with no
|
||
/// sections is *freeform* (a single plain-text body, no structure).
|
||
class NoteTemplate {
|
||
const NoteTemplate({
|
||
required this.id,
|
||
required this.label,
|
||
required this.sections,
|
||
});
|
||
|
||
/// Stable identifier persisted alongside UI state (e.g. `llm-design-spec`).
|
||
final String id;
|
||
|
||
/// Human-readable name shown in the template picker.
|
||
final String label;
|
||
|
||
/// Ordered sections. Empty for the freeform [blank] template.
|
||
final List<TemplateSection> sections;
|
||
|
||
/// Whether this template has no structure (just a plain-text body).
|
||
bool get isFreeform => sections.isEmpty;
|
||
|
||
/// The LLM-oriented design-spec template (the default). Mirrors the user's
|
||
/// backlog format; every section carries a self-documenting guidance line so
|
||
/// the pasted note tells the LLM how to read it.
|
||
static const NoteTemplate llmDesignSpec = NoteTemplate(
|
||
id: 'llm-design-spec',
|
||
label: 'LLM design spec',
|
||
sections: [
|
||
TemplateSection(
|
||
key: 'title',
|
||
label: '',
|
||
isTitle: true,
|
||
hint: 'Imperative title',
|
||
helper: "One-line imperative summary — becomes the note's heading.",
|
||
),
|
||
TemplateSection(
|
||
key: 'what',
|
||
label: 'what',
|
||
hint: 'The concrete thing, 1–3 sentences',
|
||
helper: 'The concrete thing to build, in 1–3 sentences — the goal.',
|
||
),
|
||
TemplateSection(
|
||
key: 'where',
|
||
label: 'where',
|
||
hint: 'repo + files/paths, or new app: <name>',
|
||
helper:
|
||
"Repo + target files/paths (not terminal dumps), or "
|
||
"'new app: <name>', and platform(s).",
|
||
),
|
||
TemplateSection(
|
||
key: 'must',
|
||
label: 'must',
|
||
inline: false,
|
||
hint: '- required behaviour',
|
||
helper: 'Required behaviours, one per line — hard requirements.',
|
||
),
|
||
TemplateSection(
|
||
key: 'nice',
|
||
label: 'nice',
|
||
inline: false,
|
||
hint: '- optional behaviour',
|
||
helper:
|
||
'Optional behaviours; skipping them is fine. '
|
||
'Leave blank if none.',
|
||
),
|
||
TemplateSection(
|
||
key: 'out',
|
||
label: 'out',
|
||
inline: false,
|
||
hint: '- explicitly out of scope',
|
||
helper:
|
||
'Explicitly out of scope — stops gold-plating. '
|
||
'Leave blank if none.',
|
||
),
|
||
TemplateSection(
|
||
key: 'done',
|
||
label: 'done',
|
||
hint: 'I can X and Y happens',
|
||
helper:
|
||
"Observable success: 'I can X and Y happens' — used to verify "
|
||
'completion.',
|
||
),
|
||
TemplateSection(
|
||
key: 'depends',
|
||
label: 'depends',
|
||
hint: 'prerequisite tasks/notes',
|
||
helper: 'Prerequisite tasks/notes. Leave blank if none.',
|
||
),
|
||
TemplateSection(
|
||
key: 'estimate',
|
||
label: 'estimate',
|
||
hint: 'S | M | L',
|
||
helper: 'Rough size: S / M / L.',
|
||
),
|
||
TemplateSection(
|
||
key: 'refs',
|
||
label: 'refs',
|
||
inline: false,
|
||
hint: 'links/docs the agent should read',
|
||
helper:
|
||
'Links/docs/code the agent should read first, one per line. '
|
||
'Leave blank if none.',
|
||
),
|
||
],
|
||
);
|
||
|
||
/// An empty, structure-free template.
|
||
static const NoteTemplate blank = NoteTemplate(
|
||
id: 'blank',
|
||
label: 'Blank',
|
||
sections: [],
|
||
);
|
||
|
||
/// All selectable templates, default first.
|
||
static const List<NoteTemplate> all = [llmDesignSpec, blank];
|
||
|
||
/// The default template applied to new notes.
|
||
static const NoteTemplate defaultTemplate = llmDesignSpec;
|
||
}
|
||
|
||
/// Result of parsing stored text against a template.
|
||
class ParsedNote {
|
||
const ParsedNote({required this.values, required this.conforms});
|
||
|
||
/// Section-key -> value (trimmed). Sections absent from the text map to ''.
|
||
final Map<String, String> values;
|
||
|
||
/// Whether [text] cleanly matched [template]. When false the UI must show
|
||
/// the text in a raw editor rather than the guided stepper.
|
||
final bool conforms;
|
||
}
|
||
|
||
/// Builds the stored note text from section [values], dropping empty sections
|
||
/// so the pasted note carries no blank scaffold for the LLM to wade through.
|
||
///
|
||
/// [template] fixes the section order; [values] is keyed by section key. Each
|
||
/// present section becomes a `## label` heading, an italic guidance line, and
|
||
/// the value.
|
||
String assemble(NoteTemplate template, Map<String, String> values) {
|
||
if (template.isFreeform) return (values['body'] ?? '').trimRight();
|
||
|
||
final blocks = <String>[];
|
||
final title = (values['title'] ?? '').trim();
|
||
if (title.isNotEmpty) blocks.add('# $title');
|
||
|
||
for (final section in template.sections) {
|
||
if (section.isTitle) continue;
|
||
final value = (values[section.key] ?? '').trim();
|
||
if (value.isEmpty) continue; // drop empty sections
|
||
blocks.add('## ${section.label}\n_${section.helper}_\n\n$value');
|
||
}
|
||
return blocks.join('\n\n');
|
||
}
|
||
|
||
/// Parses stored [text] into section values for the given [template].
|
||
///
|
||
/// Freeform templates always conform: the whole text is the `body` value.
|
||
/// For structured templates the text conforms only if it uses the Markdown
|
||
/// section headings, those headings are known and in template order without
|
||
/// repeats, and nothing but the `#` title sits before the first section.
|
||
/// Anything else (freeform, the old `what —` format) is reported as
|
||
/// non-conforming so the caller can show it raw, untouched.
|
||
ParsedNote parse(NoteTemplate template, String text) {
|
||
if (template.isFreeform) {
|
||
return ParsedNote(values: {'body': text}, conforms: true);
|
||
}
|
||
|
||
// Index of each non-title section in template order, used to detect
|
||
// out-of-order or duplicate headings.
|
||
final order = <String, int>{};
|
||
var oi = 0;
|
||
for (final s in template.sections) {
|
||
if (!s.isTitle) order[s.key] = oi++;
|
||
}
|
||
|
||
final values = <String, String>{for (final s in template.sections) s.key: ''};
|
||
final lines = text.split('\n');
|
||
|
||
final titleLines = <String>[];
|
||
String? currentKey; // open section accumulating its block lines
|
||
final blocks = <String, List<String>>{};
|
||
var lastOrder = -1;
|
||
var sawSection = false;
|
||
var conforms = true;
|
||
|
||
for (final line in lines) {
|
||
final heading = _matchHeading(line, template);
|
||
if (heading != null) {
|
||
sawSection = true;
|
||
final idx = order[heading.key]!;
|
||
if (idx <= lastOrder) conforms = false; // out of order / duplicate
|
||
lastOrder = idx;
|
||
currentKey = heading.key;
|
||
blocks[heading.key] = <String>[];
|
||
continue;
|
||
}
|
||
if (currentKey != null) {
|
||
blocks[currentKey]!.add(line);
|
||
continue;
|
||
}
|
||
// Still before the first section: only the `#` title and blanks belong.
|
||
final trimmed = line.trim();
|
||
if (trimmed.isEmpty) continue;
|
||
if (trimmed.startsWith('# ') && !trimmed.startsWith('## ')) {
|
||
titleLines.add(trimmed.substring(2).trim());
|
||
} else {
|
||
conforms = false; // stray content before the first section
|
||
}
|
||
}
|
||
|
||
values['title'] = titleLines.join('\n').trim();
|
||
for (final entry in blocks.entries) {
|
||
values[entry.key] = _stripGuidance(entry.value).trim();
|
||
}
|
||
|
||
// A structured note with no recognised section headings is really freeform.
|
||
if (!sawSection) conforms = false;
|
||
|
||
return ParsedNote(values: values, conforms: conforms);
|
||
}
|
||
|
||
/// The note's display title: the `#` heading text without its `#` marker, or
|
||
/// the first non-empty line for freeform notes.
|
||
String noteTitle(String text) {
|
||
for (final line in text.split('\n')) {
|
||
final trimmed = line.trim();
|
||
if (trimmed.isEmpty) continue;
|
||
return trimmed.replaceFirst(RegExp(r'^#+\s*'), '');
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/// Returns the section whose `## label` heading is on [line], or null. Only
|
||
/// known section labels are treated as headings, so a `## subheading` the user
|
||
/// wrote inside a value is preserved as content rather than splitting the note.
|
||
TemplateSection? _matchHeading(String line, NoteTemplate template) {
|
||
if (!line.startsWith('## ')) return null;
|
||
final label = line.substring(3).trim();
|
||
for (final s in template.sections) {
|
||
if (!s.isTitle && s.label == label) return s;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// Drops the leading blank lines and the single italic guidance line (if any)
|
||
/// from a section's block, leaving just the user's value.
|
||
String _stripGuidance(List<String> lines) {
|
||
final out = List<String>.from(lines);
|
||
var i = 0;
|
||
while (i < out.length && out[i].trim().isEmpty) {
|
||
i++;
|
||
}
|
||
if (i < out.length) {
|
||
final t = out[i].trim();
|
||
if (t.length >= 2 && t.startsWith('_') && t.endsWith('_')) {
|
||
out.removeAt(i);
|
||
}
|
||
}
|
||
return out.join('\n');
|
||
}
|