diff --git a/lib/data/note_template.dart b/lib/data/note_template.dart new file mode 100644 index 0000000..5c5252b --- /dev/null +++ b/lib/data/note_template.dart @@ -0,0 +1,321 @@ +/// 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 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: ', + helper: + "Repo + target files/paths (not terminal dumps), or " + "'new app: ', 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 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 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 values) { + if (template.isFreeform) return (values['body'] ?? '').trimRight(); + + final blocks = []; + 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 = {}; + var oi = 0; + for (final s in template.sections) { + if (!s.isTitle) order[s.key] = oi++; + } + + final values = {for (final s in template.sections) s.key: ''}; + final lines = text.split('\n'); + + final titleLines = []; + String? currentKey; // open section accumulating its block lines + final blocks = >{}; + 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] = []; + 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 lines) { + final out = List.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'); +} diff --git a/lib/ui/capture_screen.dart b/lib/ui/capture_screen.dart index bbbdb93..0521e2f 100644 --- a/lib/ui/capture_screen.dart +++ b/lib/ui/capture_screen.dart @@ -4,9 +4,11 @@ import 'package:uuid/uuid.dart'; import '../data/note.dart'; import '../data/note_repository.dart'; +import '../data/note_template.dart'; import '../sync/github_client.dart'; import '../sync/sync_service.dart'; import '../sync/sync_settings.dart'; +import 'note_editor.dart'; import 'notes_list_screen.dart'; import 'settings_screen.dart'; @@ -34,31 +36,12 @@ class CaptureScreen extends StatefulWidget { class _CaptureScreenState extends State { static const _uuid = Uuid(); - /// Placeholder for the note's title line; selected on reset so the first - /// keystroke replaces it. - static const _titlePlaceholder = ''; + /// Latest assembled text from the editor; persisted on change and re-saved + /// when only priority/status change. + String _draftText = ''; - /// The structured scaffold pre-filled into every new note (see the - /// `` format). Pre-filling beats a hint because the em-dashes - /// and labels are tedious to type on mobile — the user just fills the gaps. - static const _template = - '$_titlePlaceholder\n' - '\n' - 'what — \n' - 'where — \n' - 'must —\n' - '- \n' - 'nice —\n' - '- \n' - 'out —\n' - '- \n' - 'done — \n' - 'depends — \n' - 'estimate — \n' - 'refs — '; - - final TextEditingController _controller = TextEditingController(); - final FocusNode _focusNode = FocusNode(); + /// Bumped on save to recreate the editor with a fresh, empty template. + int _editorGeneration = 0; /// Id of the note currently being edited, or null before the first /// keystroke of a fresh draft. @@ -78,36 +61,11 @@ class _CaptureScreenState extends State { @override void initState() { super.initState(); - _resetToTemplate(); SyncSettings.load().then((s) { if (mounted) setState(() => _settings = s); }); } - /// Loads the blank template into the field with the title placeholder - /// selected, so typing immediately overwrites it. Setting the controller - /// value programmatically does not fire [_onChanged], so this never - /// persists a note on its own — only a real edit does. - void _resetToTemplate() { - _controller.value = const TextEditingValue( - text: _template, - selection: TextSelection( - baseOffset: 0, - extentOffset: _titlePlaceholder.length, - ), - ); - } - - /// Whether [text] is still the untouched scaffold (nothing worth saving). - bool _isPristine(String text) => text.trim() == _template.trim(); - - @override - void dispose() { - _controller.dispose(); - _focusNode.dispose(); - super.dispose(); - } - /// Opens the settings screen and adopts any saved configuration. Future _openSettings() async { final current = _settings ?? await SyncSettings.load(); @@ -168,10 +126,11 @@ class _CaptureScreenState extends State { /// Persists the current text on every change. Creates the note row on /// the first non-empty keystroke so empty drafts never hit storage. Future _onChanged(String text) async { + _draftText = text; if (_draftId == null) { - // Don't persist an empty field or the untouched template scaffold — - // a note is only created once the user actually fills something in. - if (text.isEmpty || _isPristine(text)) return; + // A note is only created once the user actually fills something in, so + // an empty template (no section typed yet) never hits storage. + if (text.trim().isEmpty) return; _draftId = _uuid.v4(); _draftCreatedAt = DateTime.now(); } @@ -210,7 +169,7 @@ class _CaptureScreenState extends State { await widget.repository.upsert( Note( id: _draftId!, - text: _controller.text, + text: _draftText, priority: _draftPriority, status: _draftStatus, createdAt: _draftCreatedAt!, @@ -225,14 +184,14 @@ class _CaptureScreenState extends State { // A note was actually persisted only if a draft row was created. final saved = _draftId != null; setState(() { - _resetToTemplate(); + _editorGeneration++; // recreate the editor with a fresh template + _draftText = ''; _draftId = null; _draftCreatedAt = null; _lastSavedAt = null; _draftPriority = Priority.defaultValue; _draftStatus = Status.todo; }); - _focusNode.requestFocus(); if (saved) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -316,19 +275,10 @@ class _CaptureScreenState extends State { ), const SizedBox(height: 12), Expanded( - child: TextField( - controller: _controller, - focusNode: _focusNode, + child: NoteEditor( + key: ValueKey(_editorGeneration), + initialTemplate: NoteTemplate.defaultTemplate, autofocus: true, - maxLines: null, - expands: true, - textAlignVertical: TextAlignVertical.top, - keyboardType: TextInputType.multiline, - style: theme.textTheme.bodyLarge, - decoration: const InputDecoration( - border: InputBorder.none, - hintText: 'Write your idea…', - ), onChanged: _onChanged, ), ), diff --git a/lib/ui/markdown_view.dart b/lib/ui/markdown_view.dart new file mode 100644 index 0000000..01eee16 --- /dev/null +++ b/lib/ui/markdown_view.dart @@ -0,0 +1,105 @@ +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 = []; + + 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, + ), + ), + ); + } +} diff --git a/lib/ui/note_detail_screen.dart b/lib/ui/note_detail_screen.dart new file mode 100644 index 0000000..5c230f6 --- /dev/null +++ b/lib/ui/note_detail_screen.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; + +import '../data/note.dart'; +import '../data/note_repository.dart'; +import '../data/note_template.dart'; +import 'note_editor.dart'; + +/// Full-screen view of a single note: read it in full, edit its body through +/// the guided [NoteEditor], 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 { + const NoteDetailScreen({ + required this.note, + required this.repository, + super.key, + }); + + final Note note; + final NoteRepository repository; + + @override + State createState() => _NoteDetailScreenState(); +} + +class _NoteDetailScreenState extends State { + late Note _note = widget.note; + + Future _persist(Note next) async { + setState(() => _note = next); + await widget.repository.upsert(next); + } + + Future _onTextChanged(String text) => + _persist(_note.copyWith(text: text, updatedAt: DateTime.now())); + + Future _delete() async { + 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: [ + IconButton( + tooltip: 'Delete note', + icon: const Icon(Icons.delete_outline), + onPressed: _delete, + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: _MetaDropdown( + label: 'Priority', + value: _note.priority, + values: Priority.values, + labelOf: (p) => p.label, + onChanged: (p) => _persist( + _note.copyWith(priority: p, updatedAt: DateTime.now()), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _MetaDropdown( + label: 'Status', + value: _note.status, + values: Status.values, + labelOf: (s) => s.label, + onChanged: (s) => _persist( + _note.copyWith(status: s, updatedAt: DateTime.now()), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Expanded( + child: NoteEditor( + initialText: _note.text, + initialMode: NoteEditorMode.preview, + onChanged: _onTextChanged, + ), + ), + ], + ), + ), + ); + } +} + +/// A compact labelled dropdown for picking an enum value (priority/status). +class _MetaDropdown extends StatelessWidget { + const _MetaDropdown({ + required this.label, + required this.value, + required this.values, + required this.labelOf, + required this.onChanged, + }); + + final String label; + final T value; + final List values; + final String Function(T) labelOf; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return DropdownButtonFormField( + initialValue: value, + isDense: true, + decoration: InputDecoration( + labelText: label, + isDense: true, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + items: [ + for (final v in values) + DropdownMenuItem(value: v, child: Text(labelOf(v))), + ], + onChanged: (v) { + if (v != null) onChanged(v); + }, + ); + } +} diff --git a/lib/ui/note_editor.dart b/lib/ui/note_editor.dart new file mode 100644 index 0000000..88ee38a --- /dev/null +++ b/lib/ui/note_editor.dart @@ -0,0 +1,404 @@ +import 'package:flutter/material.dart'; + +import '../data/note_template.dart'; +import 'markdown_view.dart'; + +/// Which view of the note the editor is currently showing. +enum NoteEditorMode { + /// Read-only rendered Markdown (headings, guidance, bullets). + preview, + + /// Inline [Stepper], one step per template section. + guided, + + /// A single text field showing the assembled Markdown verbatim. + raw, +} + +/// A guided editor for a note's text, shared by the capture and detail +/// screens. +/// +/// It is a *view* over plain text: it parses [initialText] into template +/// sections and reports the re-assembled text through [onChanged] on every +/// edit. Storage stays plain text, so sync and markdown export are unaffected. +/// +/// Modes (see [NoteEditorMode]): +/// * **Preview** — the note rendered as Markdown, read-only. +/// * **Guided** — an inline [Stepper], one step per template section, each +/// with guidance on what to write and why the LLM needs it. +/// * **Raw** — a single text field showing the assembled text verbatim. +/// +/// Non-conforming or freeform text never enters the guided stepper (we never +/// force it into the template), so for such text Guided is unavailable and the +/// editable source stays the raw body, preserving the user's content. +class NoteEditor extends StatefulWidget { + const NoteEditor({ + required this.onChanged, + this.initialText = '', + this.initialTemplate, + this.initialMode = NoteEditorMode.guided, + this.autofocus = false, + super.key, + }); + + /// Called with the freshly assembled note text on every edit. + final ValueChanged onChanged; + + /// Existing note text to load. Empty for a fresh draft. + final String initialText; + + /// Template to author with. When null the template is detected from + /// [initialText] (used when opening an existing note). + final NoteTemplate? initialTemplate; + + /// Preferred mode to open in. Falls back to [NoteEditorMode.raw] when + /// [NoteEditorMode.guided] is requested for text that can't be guided + /// (freeform template or non-conforming content). + final NoteEditorMode initialMode; + + /// Autofocus the first field, so a fresh capture needs zero taps before + /// typing — preserving the app's instant-capture invariant. + final bool autofocus; + + @override + State createState() => _NoteEditorState(); +} + +class _NoteEditorState extends State { + late NoteTemplate _template; + late NoteEditorMode _mode; + + /// Whether the editable content currently lives in [_body] (raw source) + /// rather than the per-section controllers (guided source). Preview keeps + /// whichever source was last active so [_currentText] stays correct. + late bool _rawSource; + + int _currentStep = 0; + + /// One controller per structured section (keyed by section key). + final Map _section = {}; + + /// Single field used for the freeform [NoteTemplate.blank] body and for raw + /// mode of a structured template. + final TextEditingController _body = TextEditingController(); + + @override + void initState() { + super.initState(); + final initial = widget.initialTemplate; + if (initial != null) { + _template = initial; + _loadSource(initial, widget.initialText, preferGuided: true); + } else { + // Detect: does the text cleanly fit the design-spec template? + final parsed = parse(NoteTemplate.llmDesignSpec, widget.initialText); + if (parsed.conforms) { + _template = NoteTemplate.llmDesignSpec; + _rawSource = false; + _fillSections(parsed.values); + } else { + // Freeform / legacy / hand-mangled — keep it as a raw body, untouched. + _template = NoteTemplate.blank; + _rawSource = true; + _body.text = widget.initialText; + } + } + _mode = _resolveMode(widget.initialMode); + } + + @override + void dispose() { + for (final c in _section.values) { + c.dispose(); + } + _body.dispose(); + super.dispose(); + } + + /// Whether the guided stepper can be *opened* right now: a structured + /// template whose current source still fits the template. Used to decide the + /// initial mode; the Guided segment itself is offered for any structured + /// template (a switch that no longer conforms is blocked at switch time). + bool get _canOpenGuided => !_template.isFreeform && !_rawSource; + + /// Picks the mode to actually display: honours [desired] unless Guided was + /// asked for when it can't be opened, in which case fall back to Raw. + NoteEditorMode _resolveMode(NoteEditorMode desired) { + if (desired == NoteEditorMode.guided && !_canOpenGuided) { + return NoteEditorMode.raw; + } + return desired; + } + + /// Ensures a controller exists for every section of [template]. + void _ensureControllers(NoteTemplate template) { + for (final s in template.sections) { + _section.putIfAbsent(s.key, () => TextEditingController()); + } + } + + void _fillSections(Map values) { + _ensureControllers(_template); + for (final s in _template.sections) { + _section[s.key]!.text = values[s.key] ?? ''; + } + } + + /// Loads [text] into [template], choosing the guided source when it conforms + /// (or when [preferGuided] and the text is empty) and the raw body otherwise. + void _loadSource( + NoteTemplate template, + String text, { + required bool preferGuided, + }) { + if (template.isFreeform) { + _rawSource = true; + _body.text = text; + return; + } + final parsed = parse(template, text); + if (parsed.conforms || (preferGuided && text.trim().isEmpty)) { + _rawSource = false; + _fillSections(parsed.values); + } else { + _rawSource = true; + _body.text = text; + } + } + + /// The current note text, assembled from whichever source is active. + String _currentText() { + if (_rawSource) return _body.text; + final values = { + for (final s in _template.sections) s.key: _section[s.key]?.text ?? '', + }; + return assemble(_template, values); + } + + void _emit() => widget.onChanged(_currentText()); + + void _switchTemplate(NoteTemplate next) { + if (next.id == _template.id) return; + final text = _currentText(); + setState(() { + _template = next; + _currentStep = 0; + _loadSource(next, text, preferGuided: true); + _mode = _resolveMode(_mode); + }); + _emit(); + } + + /// Switches the displayed mode, converting the editable source as needed. + void _setMode(NoteEditorMode next) { + if (next == _mode) return; + setState(() { + switch (next) { + case NoteEditorMode.guided: + if (_rawSource) { + // raw -> guided: only if the edited text still fits the template. + final parsed = parse(_template, _body.text); + if (!parsed.conforms && _body.text.trim().isNotEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "Text doesn't match the template — staying in raw", + ), + duration: Duration(seconds: 2), + ), + ); + return; + } + _fillSections(parsed.values); + _rawSource = false; + } + _currentStep = 0; + _mode = NoteEditorMode.guided; + case NoteEditorMode.raw: + if (!_rawSource) { + // guided -> raw: materialise the assembled text into the body. + _body.text = _currentText(); + _rawSource = true; + } + _mode = NoteEditorMode.raw; + case NoteEditorMode.preview: + // Read-only render of the current source; nothing to convert. + _mode = NoteEditorMode.preview; + } + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonFormField( + initialValue: _template.id, + isDense: true, + decoration: const InputDecoration( + labelText: 'Template', + isDense: true, + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + items: [ + for (final t in NoteTemplate.all) + DropdownMenuItem(value: t.id, child: Text(t.label)), + ], + onChanged: (id) { + if (id == null) return; + _switchTemplate(NoteTemplate.all.firstWhere((t) => t.id == id)); + }, + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: SegmentedButton( + showSelectedIcon: false, + segments: [ + const ButtonSegment( + value: NoteEditorMode.preview, + icon: Icon(Icons.visibility_outlined), + label: Text('View'), + ), + // Guided is offered for any structured template; switching to it + // is blocked at switch time if the raw text no longer conforms. + if (!_template.isFreeform) + const ButtonSegment( + value: NoteEditorMode.guided, + icon: Icon(Icons.checklist), + label: Text('Guided'), + ), + const ButtonSegment( + value: NoteEditorMode.raw, + icon: Icon(Icons.notes), + label: Text('Raw'), + ), + ], + selected: {_mode}, + onSelectionChanged: (s) => _setMode(s.first), + ), + ), + const SizedBox(height: 12), + Expanded(child: _buildBody(theme)), + ], + ); + } + + Widget _buildBody(ThemeData theme) { + switch (_mode) { + case NoteEditorMode.preview: + return MarkdownView(text: _currentText()); + case NoteEditorMode.raw: + return _buildRaw(theme); + case NoteEditorMode.guided: + return _buildStepper(theme); + } + } + + Widget _buildRaw(ThemeData theme) { + return TextField( + controller: _body, + autofocus: widget.autofocus, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + keyboardType: TextInputType.multiline, + style: theme.textTheme.bodyLarge, + decoration: const InputDecoration( + border: InputBorder.none, + hintText: 'Write your idea…', + ), + onChanged: (_) => _emit(), + ); + } + + Widget _buildStepper(ThemeData theme) { + _ensureControllers(_template); + final sections = _template.sections; + return SingleChildScrollView( + child: Stepper( + currentStep: _currentStep.clamp(0, sections.length - 1), + physics: const NeverScrollableScrollPhysics(), + onStepTapped: (i) => setState(() => _currentStep = i), + onStepContinue: _currentStep < sections.length - 1 + ? () => setState(() => _currentStep++) + : null, + onStepCancel: _currentStep > 0 + ? () => setState(() => _currentStep--) + : null, + controlsBuilder: (context, details) { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + children: [ + if (details.onStepContinue != null) + FilledButton( + onPressed: details.onStepContinue, + child: const Text('Next'), + ), + if (details.onStepCancel != null) ...[ + const SizedBox(width: 8), + TextButton( + onPressed: details.onStepCancel, + child: const Text('Back'), + ), + ], + ], + ), + ); + }, + steps: [ + for (var i = 0; i < sections.length; i++) + _stepFor(theme, sections[i], i), + ], + ), + ); + } + + Step _stepFor(ThemeData theme, TemplateSection section, int index) { + final controller = _section[section.key]!; + final hasValue = controller.text.trim().isNotEmpty; + return Step( + title: Text(section.isTitle ? 'title' : section.label), + state: hasValue + ? StepState.complete + : (index == _currentStep ? StepState.editing : StepState.indexed), + isActive: index <= _currentStep, + content: Align( + alignment: Alignment.centerLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + section.helper, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + TextField( + controller: controller, + autofocus: widget.autofocus && index == 0, + maxLines: section.inline ? 1 : null, + minLines: section.inline ? 1 : 3, + keyboardType: TextInputType.multiline, + decoration: InputDecoration( + hintText: section.hint, + border: const OutlineInputBorder(), + isDense: true, + ), + // Rebuild so the step's completion tick reflects the new value. + onChanged: (_) { + setState(() {}); + _emit(); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/notes_list_screen.dart b/lib/ui/notes_list_screen.dart index 9c01acc..e9ed736 100644 --- a/lib/ui/notes_list_screen.dart +++ b/lib/ui/notes_list_screen.dart @@ -4,6 +4,8 @@ import 'package:flutter/material.dart'; import '../data/note.dart'; import '../data/note_repository.dart'; +import '../data/note_template.dart'; +import 'note_detail_screen.dart'; /// The default status selection: hide completed/dropped work. This is the /// app's notion of "unfiltered", so it does not count towards the filter @@ -106,7 +108,17 @@ class _NotesListScreenState extends State { } } - /// Opens the per-note actions sheet (priority, status, delete). + /// Opens the full note: read it, edit the body, change priority/status. + Future _openNote(Note note) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + NoteDetailScreen(note: note, repository: widget.repository), + ), + ); + } + + /// Opens the per-note quick-actions sheet (priority, status, delete). Future _openNoteActions(Note note) async { await showModalBottomSheet( context: context, @@ -218,7 +230,8 @@ class _NotesListScreenState extends State { separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, i) => _NoteTile( note: notes[i], - onTap: () => _openNoteActions(notes[i]), + onTap: () => _openNote(notes[i]), + onActions: () => _openNoteActions(notes[i]), ), ); }, @@ -232,14 +245,23 @@ class _NotesListScreenState extends State { /// One row in the notes list: first line, then a metadata subtitle. class _NoteTile extends StatelessWidget { - const _NoteTile({required this.note, required this.onTap}); + const _NoteTile({ + required this.note, + required this.onTap, + required this.onActions, + }); final Note note; + + /// Open the full note for reading/editing. final VoidCallback onTap; + /// Open the quick-actions sheet (priority/status/delete). + final VoidCallback onActions; + @override Widget build(BuildContext context) { - final firstLine = note.text.split('\n').first; + final firstLine = noteTitle(note.text); // Every note has a status and a priority now, so both are always shown. final meta = [ note.status.label, @@ -253,8 +275,13 @@ class _NoteTile extends StatelessWidget { overflow: TextOverflow.ellipsis, ), subtitle: Text(meta), - trailing: const Icon(Icons.more_vert), + trailing: IconButton( + icon: const Icon(Icons.more_vert), + tooltip: 'Quick actions', + onPressed: onActions, + ), onTap: onTap, + onLongPress: onActions, ); } @@ -300,7 +327,7 @@ class _NoteActionsSheetState extends State<_NoteActionsSheet> { @override Widget build(BuildContext context) { - final firstLine = widget.note.text.split('\n').first; + final firstLine = noteTitle(widget.note.text); return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), diff --git a/test/capture_screen_test.dart b/test/capture_screen_test.dart index 2c50fae..0766899 100644 --- a/test/capture_screen_test.dart +++ b/test/capture_screen_test.dart @@ -44,12 +44,15 @@ void main() { 'sync.token': 'tok', }; - testWidgets('pre-fills the structured template', (tester) async { + testWidgets('opens the guided editor with section guidance', (tester) async { await pumpCapture(tester); - expect(find.textContaining(''), findsOneWidget); - expect(find.textContaining('what —'), findsOneWidget); - expect(find.textContaining('done —'), findsOneWidget); + // The guided stepper shows the design-spec sections and the title step's + // guidance, with no note persisted yet. + expect(find.text('Guided'), findsOneWidget); + expect(find.textContaining('imperative'), findsOneWidget); // title helper + expect(find.text('what'), findsOneWidget); // a section step header + expect(find.text('done'), findsOneWidget); expect(find.text('0 saved'), findsOneWidget); }); @@ -67,10 +70,8 @@ void main() { ) async { final repo = await pumpCapture(tester); - await tester.enterText( - find.byType(TextField), - 'My idea\n\nwhat — build the thing', - ); + // The first field in the guided stepper is the title section. + await tester.enterText(find.byType(TextField).first, 'My idea'); await tester.pump(); final notes = await repo.listNotes(); @@ -86,7 +87,7 @@ void main() { ) async { final repo = await pumpCapture(tester); - await tester.enterText(find.byType(TextField), 'A real idea'); + await tester.enterText(find.byType(TextField).first, 'A real idea'); await tester.pump(); await tester.tap(find.text('Save')); await tester.pump(); // build the snackbar @@ -95,7 +96,8 @@ void main() { await tester.pump(); expect(await repo.listNotes(), hasLength(1)); - expect(find.textContaining(''), findsOneWidget); + // The editor reset to a fresh guided template (title guidance shown again). + expect(find.textContaining('imperative'), findsOneWidget); }); testWidgets('tapping Sync while unconfigured prompts for a token', ( @@ -127,7 +129,7 @@ void main() { ) async { final repo = await pumpCapture(tester); - await tester.enterText(find.byType(TextField), 'Prioritised idea'); + await tester.enterText(find.byType(TextField).first, 'Prioritised idea'); await tester.pump(); await tester.tap( @@ -146,7 +148,7 @@ void main() { ) async { final repo = await pumpCapture(tester); - await tester.enterText(find.byType(TextField), 'Status idea'); + await tester.enterText(find.byType(TextField).first, 'Status idea'); await tester.pump(); await tester.tap( diff --git a/test/markdown_view_test.dart b/test/markdown_view_test.dart new file mode 100644 index 0000000..ba57bf7 --- /dev/null +++ b/test/markdown_view_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/ui/markdown_view.dart'; + +void main() { + Future pumpView(WidgetTester tester, String text) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold(body: MarkdownView(text: text)), + ), + ); + await tester.pump(); + } + + /// Finds the rendered Text widget whose data equals [data] and returns it. + Text textWidget(WidgetTester tester, String data) => + tester.widget(find.text(data)); + + testWidgets('renders an h1 title bold and large', (tester) async { + await pumpView(tester, '# Big title'); + final t = textWidget(tester, 'Big title'); + expect(t.style?.fontWeight, FontWeight.bold); + }); + + testWidgets('renders an h2 section heading without its marker', ( + tester, + ) async { + await pumpView(tester, '## what'); + expect(find.text('what'), findsOneWidget); + expect(textWidget(tester, 'what').style?.fontWeight, FontWeight.bold); + }); + + testWidgets('renders a whole-line italic guidance line', (tester) async { + await pumpView(tester, '_guidance text_'); + final t = textWidget(tester, 'guidance text'); + expect(t.style?.fontStyle, FontStyle.italic); + }); + + testWidgets('renders dash and star bullets with a bullet glyph', ( + tester, + ) async { + await pumpView(tester, '- first\n* second'); + expect(find.text('first'), findsOneWidget); + expect(find.text('second'), findsOneWidget); + expect(find.textContaining('•'), findsNWidgets(2)); + }); + + testWidgets('renders a plain paragraph and blank-line spacers', ( + tester, + ) async { + await pumpView(tester, 'a paragraph\n\nanother'); + expect(find.text('a paragraph'), findsOneWidget); + expect(find.text('another'), findsOneWidget); + // The blank line between paragraphs becomes a spacer box. + expect(find.byType(SizedBox), findsWidgets); + }); + + testWidgets('a full assembled note renders all element types', ( + tester, + ) async { + await pumpView( + tester, + '# Title\n\n## what\n_why we need it_\n\n- one\n- two\nplain tail', + ); + expect(find.text('Title'), findsOneWidget); + expect(find.text('what'), findsOneWidget); + expect(find.text('why we need it'), findsOneWidget); + expect(find.text('one'), findsOneWidget); + expect(find.text('plain tail'), findsOneWidget); + }); +} diff --git a/test/note_detail_screen_test.dart b/test/note_detail_screen_test.dart new file mode 100644 index 0000000..634d155 --- /dev/null +++ b/test/note_detail_screen_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/data/note.dart'; +import 'package:todo/ui/markdown_view.dart'; +import 'package:todo/ui/note_detail_screen.dart'; + +import 'fake_note_repository.dart'; + +void main() { + Note seedNote(String text) => Note( + id: 'n1', + text: text, + priority: Priority.medium, + status: Status.todo, + createdAt: DateTime(2026, 6, 15, 9), + updatedAt: DateTime(2026, 6, 15, 9), + ); + + Future pumpDetail(WidgetTester tester, Note note) async { + tester.view.physicalSize = const Size(1200, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final repo = FakeNoteRepository([note]); + addTearDown(repo.close); + await tester.pumpWidget( + MaterialApp( + home: NoteDetailScreen(note: note, repository: repo), + ), + ); + await tester.pump(); + return repo; + } + + testWidgets('opens in the rendered Markdown view with the title in the bar', ( + tester, + ) async { + await pumpDetail(tester, seedNote('# My note\n\n## what\n_why_\n\nbody')); + + expect(find.byType(MarkdownView), findsOneWidget); + // Title appears both in the app bar and the rendered body. + expect(find.text('My note'), findsWidgets); + }); + + testWidgets('changing the priority dropdown persists the note', ( + tester, + ) async { + final repo = await pumpDetail(tester, seedNote('# T')); + + await tester.tap( + find.byWidgetPredicate((w) => w is DropdownButtonFormField), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('High').last); + await tester.pumpAndSettle(); + + expect((await repo.listNotes()).single.priority, Priority.high); + }); + + testWidgets('changing the status dropdown persists the note', (tester) async { + final repo = await pumpDetail(tester, seedNote('# T')); + + await tester.tap( + find.byWidgetPredicate((w) => w is DropdownButtonFormField), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Done').last); + await tester.pumpAndSettle(); + + expect((await repo.listNotes()).single.status, Status.done); + }); + + testWidgets('editing the body in Raw mode persists the new text', ( + tester, + ) async { + final repo = await pumpDetail(tester, seedNote('# T\n\n## what\nold')); + + await tester.tap(find.text('Raw')); + await tester.pump(); + await tester.enterText(find.byType(TextField), '# T\n\n## what\nnew body'); + await tester.pump(); + + expect((await repo.listNotes()).single.text, contains('new body')); + }); + + testWidgets('the delete action removes the note and pops', (tester) async { + final repo = await pumpDetail(tester, seedNote('# Bye')); + + await tester.tap(find.byTooltip('Delete note')); + await tester.pumpAndSettle(); + + expect(await repo.listNotes(), isEmpty); + expect(find.byType(NoteDetailScreen), findsNothing); + }); +} diff --git a/test/note_editor_test.dart b/test/note_editor_test.dart new file mode 100644 index 0000000..7942af5 --- /dev/null +++ b/test/note_editor_test.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/data/note_template.dart'; +import 'package:todo/ui/markdown_view.dart'; +import 'package:todo/ui/note_editor.dart'; + +void main() { + const spec = NoteTemplate.llmDesignSpec; + const blank = NoteTemplate.blank; + + // Pumps an editor and exposes the latest text emitted via onChanged. + Future> pumpEditor( + WidgetTester tester, { + String initialText = '', + NoteTemplate? initialTemplate, + NoteEditorMode initialMode = NoteEditorMode.guided, + }) async { + final emitted = []; + tester.view.physicalSize = const Size(1200, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NoteEditor( + initialText: initialText, + initialTemplate: initialTemplate, + initialMode: initialMode, + onChanged: emitted.add, + ), + ), + ), + ); + await tester.pump(); + return emitted; + } + + testWidgets('guided: typing the title emits an assembled # heading', ( + tester, + ) async { + final emitted = await pumpEditor(tester, initialTemplate: spec); + + expect(find.text('Guided'), findsOneWidget); + await tester.enterText(find.byType(TextField).first, 'Dark mode'); + await tester.pump(); + + expect(emitted.last, startsWith('# Dark mode')); + }); + + testWidgets('stepper Next/Back and tapping a step header navigate', ( + tester, + ) async { + await pumpEditor(tester, initialTemplate: spec); + + // A vertical Stepper keeps every step's controls in the tree (collapsed), + // so the buttons resolve to many identical widgets — they all drive the + // same shared continue/cancel callbacks, so tap the first. + expect(find.byType(Stepper), findsOneWidget); + // Only the current step's controls are hit-testable (others are collapsed + // to zero height), so .hitTestable() resolves the single visible button. + // Settle the expand/collapse animation so the next step's controls lay out. + await tester.tap(find.text('Next').hitTestable()); // advance past the title + await tester.pumpAndSettle(); + await tester.tap(find.text('Back').hitTestable()); // and back + await tester.pumpAndSettle(); + // Jump directly to a step by tapping its header. + await tester.tap(find.text('done')); + await tester.pump(); + expect(find.byType(Stepper), findsOneWidget); + }); + + testWidgets('switching to View renders the note as Markdown', (tester) async { + await pumpEditor(tester, initialTemplate: spec); + await tester.enterText(find.byType(TextField).first, 'Render me'); + await tester.pump(); + + await tester.tap(find.text('View')); + await tester.pump(); + + expect(find.byType(MarkdownView), findsOneWidget); + expect(find.text('Render me'), findsOneWidget); // rendered heading text + }); + + testWidgets('guided → Raw materialises the assembled text and edits emit', ( + tester, + ) async { + final emitted = await pumpEditor(tester, initialTemplate: spec); + await tester.enterText(find.byType(TextField).first, 'T'); + await tester.pump(); + + await tester.tap(find.text('Raw')); + await tester.pump(); + + final raw = tester.widget(find.byType(TextField)); + expect(raw.controller!.text, startsWith('# T')); + + await tester.enterText(find.byType(TextField), '# T\n\n## what\nedited'); + await tester.pump(); + expect(emitted.last, contains('edited')); + }); + + testWidgets('Raw → Guided is blocked with a snackbar when non-conforming', ( + tester, + ) async { + await pumpEditor( + tester, + initialTemplate: spec, + initialText: 'totally freeform text', + initialMode: NoteEditorMode.raw, + ); + + // Structured template + non-conforming raw text → switching is refused. + await tester.tap(find.text('Guided')); + await tester.pump(); + + expect(find.textContaining("doesn't match the template"), findsOneWidget); + expect(find.byType(Stepper), findsNothing); // still raw + }); + + testWidgets('guided → Raw → Guided round-trips when text still conforms', ( + tester, + ) async { + // Open guided from a conforming note (pre-fills the section controllers + // without typing into collapsed steps), so the assembled body conforms. + final conforming = assemble(spec, {'title': 'Keep', 'what': 'a body'}); + await pumpEditor(tester, initialTemplate: spec, initialText: conforming); + expect(find.byType(Stepper), findsOneWidget); + + // guided → raw makes the (still conforming) body the source… + await tester.tap(find.text('Raw')); + await tester.pump(); + expect(find.byType(Stepper), findsNothing); + + // …then raw → guided re-parses the conforming body back into sections. + await tester.tap(find.text('Guided')); + await tester.pump(); + expect(find.byType(Stepper), findsOneWidget); + }); + + testWidgets('freeform template offers only View and Raw', (tester) async { + final emitted = await pumpEditor(tester, initialTemplate: blank); + + expect(find.text('Guided'), findsNothing); + expect(find.text('View'), findsOneWidget); + expect(find.text('Raw'), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'free text'); + await tester.pump(); + expect(emitted.last, 'free text'); + }); + + testWidgets('switching template via the dropdown reloads the source', ( + tester, + ) async { + await pumpEditor(tester, initialTemplate: spec); + expect(find.byType(Stepper), findsOneWidget); + + await tester.tap(find.text('LLM design spec').first); // open the menu + await tester.pumpAndSettle(); + await tester.tap(find.text('Blank').last); + await tester.pumpAndSettle(); + + // Freeform now: the stepper is gone and Guided is no longer offered. + expect(find.byType(Stepper), findsNothing); + expect(find.text('Guided'), findsNothing); + }); + + testWidgets('detects a conforming note (no template given) as guided', ( + tester, + ) async { + final conforming = assemble(spec, {'title': 'Detected', 'what': 'x'}); + await pumpEditor(tester, initialText: conforming); + + expect(find.byType(Stepper), findsOneWidget); // guided by default + expect(find.text('Guided'), findsOneWidget); + }); + + testWidgets('detects a legacy note (no template given) as freeform raw', ( + tester, + ) async { + await pumpEditor(tester, initialText: 'old\n\nwhat — legacy'); + + // Non-conforming → blank/raw, no guided stepper offered. + expect(find.byType(Stepper), findsNothing); + expect(find.text('Guided'), findsNothing); + }); + + testWidgets('initialMode preview opens directly in the rendered view', ( + tester, + ) async { + final conforming = assemble(spec, {'title': 'Preview me'}); + await pumpEditor( + tester, + initialText: conforming, + initialMode: NoteEditorMode.preview, + ); + + expect(find.byType(MarkdownView), findsOneWidget); + expect(find.text('Preview me'), findsOneWidget); + }); + + testWidgets('initialMode guided falls back to Raw for non-conforming text', ( + tester, + ) async { + await pumpEditor( + tester, + initialTemplate: spec, + initialText: 'cannot be guided', + initialMode: NoteEditorMode.guided, + ); + + // Guided was requested but the text does not conform → opened in Raw. + expect(find.byType(Stepper), findsNothing); + final raw = tester.widget(find.byType(TextField)); + expect(raw.controller!.text, 'cannot be guided'); + }); +} diff --git a/test/note_template_test.dart b/test/note_template_test.dart new file mode 100644 index 0000000..b5dc2a8 --- /dev/null +++ b/test/note_template_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:todo/data/note_template.dart'; + +void main() { + const spec = NoteTemplate.llmDesignSpec; + const blank = NoteTemplate.blank; + + group('assemble', () { + test( + 'builds # title + ## sections with italic guidance, dropping empty', + () { + final text = assemble(spec, { + 'title': 'Add dark mode', + 'what': 'A theme toggle.', + 'must': '- respects system theme', + // everything else blank → dropped + }); + + expect(text, contains('# Add dark mode')); + expect(text, contains('## what')); + expect(text, contains('_${spec.sections[1].helper}_')); + expect(text, contains('A theme toggle.')); + expect(text, contains('## must')); + // Dropped empty sections leave no heading behind. + expect(text, isNot(contains('## nice'))); + expect(text, isNot(contains('## refs'))); + }, + ); + + test('omits the title line when the title is blank', () { + final text = assemble(spec, {'what': 'something'}); + // No `# ` title line; the body starts straight at the first section. + expect(text.startsWith('# '), isFalse); + expect(text.startsWith('## what'), isTrue); + }); + + test('freeform template returns the trimmed body verbatim', () { + expect(assemble(blank, {'body': 'just text\n\n'}), 'just text'); + expect(assemble(blank, {}), ''); + }); + }); + + group('parse round-trip', () { + test('assemble(parse(text)) is idempotent for conforming text', () { + final text = assemble(spec, { + 'title': 'Title here', + 'what': 'The what.', + 'done': 'I can X and Y happens', + }); + + final parsed = parse(spec, text); + expect(parsed.conforms, isTrue); + expect(parsed.values['title'], 'Title here'); + expect(parsed.values['what'], 'The what.'); + expect(parsed.values['done'], 'I can X and Y happens'); + // Re-assembling the parsed values reproduces the exact text. + expect(assemble(spec, parsed.values), text); + }); + + test('legacy "what —" notes are reported non-conforming', () { + const legacy = 'Shutdown timer\n\nwhat — a timer\nwhere — new app'; + final parsed = parse(spec, legacy); + expect(parsed.conforms, isFalse); + }); + + test('out-of-order / duplicate headings are non-conforming', () { + const text = '# T\n\n## done\nx\n\n## what\ny'; + expect(parse(spec, text).conforms, isFalse); + + const dup = '# T\n\n## what\na\n\n## what\nb'; + expect(parse(spec, dup).conforms, isFalse); + }); + + test('stray content before the first heading is non-conforming', () { + const text = 'loose line\n\n## what\nv'; + expect(parse(spec, text).conforms, isFalse); + }); + + test('strips leading blank lines before a section value', () { + // A raw-edited note may put blank lines (and no guidance) under a + // heading; parsing must skip those blanks to recover the value. + const text = '# T\n\n## what\n\n\nthe value'; + final parsed = parse(spec, text); + expect(parsed.conforms, isTrue); + expect(parsed.values['what'], 'the value'); + }); + + test('a ## subheading inside a value is kept, not split out', () { + final text = assemble(spec, { + 'title': 'T', + 'what': 'intro\n## not a real section\nmore', + }); + final parsed = parse(spec, text); + expect(parsed.conforms, isTrue); + expect(parsed.values['what'], contains('## not a real section')); + }); + + test('freeform template always conforms with the whole text as body', () { + final parsed = parse(blank, 'anything goes\nline two'); + expect(parsed.conforms, isTrue); + expect(parsed.values['body'], 'anything goes\nline two'); + }); + }); + + group('noteTitle', () { + test('strips the leading # from a heading', () { + expect(noteTitle('# My title\n\n## what\nx'), 'My title'); + }); + + test('uses the first non-empty line for freeform notes', () { + expect(noteTitle('\n\nfirst real line\nsecond'), 'first real line'); + }); + + test('returns empty string for blank text', () { + expect(noteTitle(' \n '), ''); + }); + }); +} diff --git a/test/notes_list_screen_test.dart b/test/notes_list_screen_test.dart index e93b2a4..a27a383 100644 --- a/test/notes_list_screen_test.dart +++ b/test/notes_list_screen_test.dart @@ -105,7 +105,7 @@ void main() { testWidgets('per-note sheet deletes the note', (tester) async { final repo = await pumpList(tester, seed: [note('a', 'Delete me')]); - await tester.tap(find.text('Delete me')); + await tester.tap(find.byTooltip('Quick actions')); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); // sheet open await tester.tap(find.text('Delete note')); @@ -117,7 +117,7 @@ void main() { testWidgets('per-note sheet changes status via a chip', (tester) async { final repo = await pumpList(tester, seed: [note('a', 'Change me')]); - await tester.tap(find.text('Change me')); + await tester.tap(find.byTooltip('Quick actions')); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); await tester.tap(find.text('In progress')); @@ -303,7 +303,7 @@ void main() { testWidgets('per-note sheet changes priority via a chip', (tester) async { final repo = await pumpList(tester, seed: [note('a', 'Repriortise me')]); - await tester.tap(find.text('Repriortise me')); + await tester.tap(find.byTooltip('Quick actions')); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); await tester.tap(find.text('Low')); // default is Medium → change to Low @@ -311,4 +311,17 @@ void main() { expect((await repo.listNotes()).single.priority, Priority.low); }); + + testWidgets('tapping a note opens the detail screen', (tester) async { + await pumpList(tester, seed: [note('a', '# Open me\n\nbody')]); + + await tester.tap(find.text('Open me')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); // route transition + + // The detail screen shows the note title in its app bar plus the + // Priority/Status meta dropdowns and the editor mode toggle. + expect(find.text('Priority'), findsOneWidget); + expect(find.text('View'), findsOneWidget); + }); }